Merge branch with upstream

master
Tuan 3 years ago
commit 8f4f42da45
  1. 48
      .circleci/config.yml
  2. 4
      .dockerignore
  3. 7
      .env.production.sample
  4. 34
      .github/workflows/check-i18n.yml
  5. 485
      AUTHORS.md
  6. 164
      CHANGELOG.md
  7. 12
      CONTRIBUTING.md
  8. 73
      Dockerfile
  9. 58
      Gemfile
  10. 381
      Gemfile.lock
  11. 5
      SECURITY.md
  12. 2
      Vagrantfile
  13. 6
      app/controllers/accounts_controller.rb
  14. 8
      app/controllers/activitypub/outboxes_controller.rb
  15. 2
      app/controllers/admin/dashboard_controller.rb
  16. 2
      app/controllers/admin/domain_blocks_controller.rb
  17. 53
      app/controllers/admin/follow_recommendations_controller.rb
  18. 44
      app/controllers/admin/instances_controller.rb
  19. 3
      app/controllers/admin/statuses_controller.rb
  20. 4
      app/controllers/admin/tags_controller.rb
  21. 4
      app/controllers/api/v1/accounts_controller.rb
  22. 9
      app/controllers/api/v1/emails/confirmations_controller.rb
  23. 2
      app/controllers/api/v1/follow_requests_controller.rb
  24. 28
      app/controllers/api/v1/push/subscriptions_controller.rb
  25. 10
      app/controllers/api/v1/suggestions_controller.rb
  26. 19
      app/controllers/api/v2/suggestions_controller.rb
  27. 25
      app/controllers/api/web/push_subscriptions_controller.rb
  28. 21
      app/controllers/application_controller.rb
  29. 4
      app/controllers/auth/confirmations_controller.rb
  30. 4
      app/controllers/concerns/cache_concern.rb
  31. 5
      app/controllers/custom_css_controller.rb
  32. 10
      app/controllers/directories_controller.rb
  33. 7
      app/controllers/health_controller.rb
  34. 2
      app/controllers/media_proxy_controller.rb
  35. 5
      app/controllers/statuses_controller.rb
  36. 4
      app/helpers/admin/action_logs_helper.rb
  37. 18
      app/helpers/email_helper.rb
  38. 2
      app/helpers/jsonld_helper.rb
  39. 4
      app/helpers/settings_helper.rb
  40. 80
      app/helpers/statuses_helper.rb
  41. 1
      app/javascript/flavours/glitch/actions/importer/normalizer.js
  42. 1
      app/javascript/flavours/glitch/actions/search.js
  43. 17
      app/javascript/flavours/glitch/actions/store.js
  44. 24
      app/javascript/flavours/glitch/actions/suggestions.js
  45. 14
      app/javascript/flavours/glitch/actions/timelines.js
  46. 112
      app/javascript/flavours/glitch/blurhash.js
  47. 6
      app/javascript/flavours/glitch/components/account.js
  48. 9
      app/javascript/flavours/glitch/components/logo.js
  49. 2
      app/javascript/flavours/glitch/components/media_gallery.js
  50. 18
      app/javascript/flavours/glitch/components/modal_root.js
  51. 4
      app/javascript/flavours/glitch/components/regeneration_indicator.js
  52. 22
      app/javascript/flavours/glitch/components/status.js
  53. 2
      app/javascript/flavours/glitch/containers/mastodon.js
  54. 32
      app/javascript/flavours/glitch/containers/media_container.js
  55. 8
      app/javascript/flavours/glitch/containers/status_container.js
  56. 2
      app/javascript/flavours/glitch/features/account/components/header.js
  57. 9
      app/javascript/flavours/glitch/features/account_gallery/index.js
  58. 10
      app/javascript/flavours/glitch/features/compose/components/compose_form.js
  59. 6
      app/javascript/flavours/glitch/features/compose/components/publisher.js
  60. 18
      app/javascript/flavours/glitch/features/compose/components/search_results.js
  61. 25
      app/javascript/flavours/glitch/features/emoji_picker/index.js
  62. 85
      app/javascript/flavours/glitch/features/follow_recommendations/components/account.js
  63. 109
      app/javascript/flavours/glitch/features/follow_recommendations/index.js
  64. 2
      app/javascript/flavours/glitch/features/hashtag_timeline/components/column_settings.js
  65. 4
      app/javascript/flavours/glitch/features/home_timeline/index.js
  66. 8
      app/javascript/flavours/glitch/features/local_settings/page/index.js
  67. 82
      app/javascript/flavours/glitch/features/notifications/components/column_settings.js
  68. 41
      app/javascript/flavours/glitch/features/notifications/components/pill_bar_button.js
  69. 6
      app/javascript/flavours/glitch/features/notifications/index.js
  70. 28
      app/javascript/flavours/glitch/features/picture_in_picture/components/footer.js
  71. 4
      app/javascript/flavours/glitch/features/status/components/detailed_status.js
  72. 8
      app/javascript/flavours/glitch/features/status/index.js
  73. 32
      app/javascript/flavours/glitch/features/ui/components/audio_modal.js
  74. 31
      app/javascript/flavours/glitch/features/ui/components/columns_area.js
  75. 17
      app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
  76. 83
      app/javascript/flavours/glitch/features/ui/components/media_modal.js
  77. 13
      app/javascript/flavours/glitch/features/ui/components/modal_root.js
  78. 2
      app/javascript/flavours/glitch/features/ui/components/report_modal.js
  79. 29
      app/javascript/flavours/glitch/features/ui/components/video_modal.js
  80. 11
      app/javascript/flavours/glitch/features/ui/index.js
  81. 27
      app/javascript/flavours/glitch/features/video/index.js
  82. 4
      app/javascript/flavours/glitch/reducers/compose.js
  83. 1
      app/javascript/flavours/glitch/reducers/local_settings.js
  84. 2
      app/javascript/flavours/glitch/reducers/notifications.js
  85. 1
      app/javascript/flavours/glitch/reducers/settings.js
  86. 8
      app/javascript/flavours/glitch/reducers/suggestions.js
  87. 7
      app/javascript/flavours/glitch/reducers/timelines.js
  88. 1
      app/javascript/flavours/glitch/styles/about.scss
  89. 34
      app/javascript/flavours/glitch/styles/components/accounts.scss
  90. 36
      app/javascript/flavours/glitch/styles/components/boost.scss
  91. 133
      app/javascript/flavours/glitch/styles/components/columns.scss
  92. 65
      app/javascript/flavours/glitch/styles/components/emoji_picker.scss
  93. 5
      app/javascript/flavours/glitch/styles/components/index.scss
  94. 129
      app/javascript/flavours/glitch/styles/components/media.scss
  95. 8
      app/javascript/flavours/glitch/styles/components/modal.scss
  96. 15
      app/javascript/flavours/glitch/styles/components/status.scss
  97. 42
      app/javascript/flavours/glitch/styles/forms.scss
  98. 7
      app/javascript/flavours/glitch/styles/widgets.scss
  99. 4
      app/javascript/flavours/glitch/util/async-components.js
  100. 34
      app/javascript/flavours/glitch/util/emoji/emoji_compressed.js
  101. Some files were not shown because too many files have changed in this diff Show More

@ -129,6 +129,13 @@ jobs:
environment: *ruby_environment environment: *ruby_environment
<<: *install_ruby_dependencies <<: *install_ruby_dependencies
install-ruby3.0:
<<: *defaults
docker:
- image: circleci/ruby:3.0-buster-node
environment: *ruby_environment
<<: *install_ruby_dependencies
build: build:
<<: *defaults <<: *defaults
steps: steps:
@ -187,6 +194,18 @@ jobs:
- image: circleci/redis:5-alpine - image: circleci/redis:5-alpine
<<: *test_steps <<: *test_steps
test-ruby3.0:
<<: *defaults
docker:
- image: circleci/ruby:3.0-buster-node
environment: *ruby_environment
- image: circleci/postgres:12.2
environment:
POSTGRES_USER: root
POSTGRES_HOST_AUTH_METHOD: trust
- image: circleci/redis:5-alpine
<<: *test_steps
test-webui: test-webui:
<<: *defaults <<: *defaults
docker: docker:
@ -197,24 +216,6 @@ jobs:
name: Run jest name: Run jest
command: yarn test:jest command: yarn test:jest
check-i18n:
<<: *defaults
steps:
- *attach_workspace
- *install_system_dependencies
- run:
name: Check locale file normalization
command: bundle exec i18n-tasks check-normalized
- run:
name: Check for unused strings
command: bundle exec i18n-tasks unused -l en
- run:
name: Check for wrong string interpolations
command: bundle exec i18n-tasks check-consistent-interpolations
- run:
name: Check that all required locale files exist
command: bundle exec rake repo:check_locales_files
workflows: workflows:
version: 2 version: 2
build-and-test: build-and-test:
@ -227,6 +228,10 @@ workflows:
requires: requires:
- install - install
- install-ruby2.7 - install-ruby2.7
- install-ruby3.0:
requires:
- install
- install-ruby2.7
- build: - build:
requires: requires:
- install-ruby2.7 - install-ruby2.7
@ -241,9 +246,10 @@ workflows:
requires: requires:
- install-ruby2.6 - install-ruby2.6
- build - build
- test-ruby3.0:
requires:
- install-ruby3.0
- build
- test-webui: - test-webui:
requires: requires:
- install - install
- check-i18n:
requires:
- install-ruby2.7

@ -1,6 +1,10 @@
.bundle .bundle
.env .env
.env.* .env.*
.git
.gitattributes
.gitignore
.github
public/system public/system
public/assets public/assets
public/packs public/packs

@ -269,3 +269,10 @@ MAX_POLL_OPTION_CHARS=100
# Maximum search results to display # Maximum search results to display
# Only relevant when elasticsearch is installed # Only relevant when elasticsearch is installed
# MAX_SEARCH_RESULTS=20 # MAX_SEARCH_RESULTS=20
# Maximum custom emoji file sizes
# If undefined or smaller than MAX_EMOJI_SIZE, the value
# of MAX_EMOJI_SIZE will be used for MAX_REMOTE_EMOJI_SIZE
# Units are in bytes
MAX_EMOJI_SIZE=51200
MAX_REMOTE_EMOJI_SIZE=204800

@ -0,0 +1,34 @@
name: Check i18n
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
env:
RAILS_ENV: test
jobs:
check-i18n:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libicu-dev libidn11-dev libprotobuf-dev protobuf-compiler
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '2.7'
bundler-cache: true
- name: Check locale file normalization
run: bundle exec i18n-tasks check-normalized
- name: Check for unused strings
run: bundle exec i18n-tasks unused -l en
- name: Check for wrong string interpolations
run: bundle exec i18n-tasks check-consistent-interpolations
- name: Check that all required locale files exist
run: bundle exec rake repo:check_locales_files

@ -5,39 +5,38 @@ 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)
* [ThibG](https://github.com/ThibG)
* [dependabot-preview[bot]](https://github.com/apps/dependabot-preview)
* [dependabot[bot]](https://github.com/apps/dependabot) * [dependabot[bot]](https://github.com/apps/dependabot)
* [ClearlyClaire](https://github.com/ClearlyClaire)
* [dependabot-preview[bot]](https://github.com/apps/dependabot-preview)
* [ykzts](https://github.com/ykzts) * [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)
* [abcang](https://github.com/abcang)
* [yiskah](https://github.com/yiskah) * [yiskah](https://github.com/yiskah)
* [noellabo](https://github.com/noellabo)
* [nolanlawson](https://github.com/nolanlawson) * [nolanlawson](https://github.com/nolanlawson)
* [abcang](https://github.com/abcang)
* [mayaeh](https://github.com/mayaeh) * [mayaeh](https://github.com/mayaeh)
* [ysksn](https://github.com/ysksn) * [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) * [tribela](https://github.com/tribela)
* [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) * [zunda](https://github.com/zunda)
* [nclm](https://github.com/nclm) * [nclm](https://github.com/nclm)
* [ineffyble](https://github.com/ineffyble) * [ineffyble](https://github.com/ineffyble)
* [zunda](https://github.com/zunda)
* [shleeable](https://github.com/shleeable) * [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)
* [ariasuni](https://github.com/ariasuni)
* [nullkal](https://github.com/nullkal) * [nullkal](https://github.com/nullkal)
* [yookoala](https://github.com/yookoala) * [yookoala](https://github.com/yookoala)
* [Brawaru](https://github.com/Brawaru) * [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,7 +44,9 @@ and provided thanks to the work of the following contributors:
* [ashfurrow](https://github.com/ashfurrow) * [ashfurrow](https://github.com/ashfurrow)
* [danhunsaker](https://github.com/danhunsaker) * [danhunsaker](https://github.com/danhunsaker)
* [eramdam](https://github.com/eramdam) * [eramdam](https://github.com/eramdam)
* [Jeroen](mailto:jeroenpraat@users.noreply.github.com)
* [takayamaki](https://github.com/takayamaki) * [takayamaki](https://github.com/takayamaki)
* [dunn](https://github.com/dunn)
* [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) * [trwnh](https://github.com/trwnh)
@ -53,15 +54,15 @@ and provided thanks to the work of the following contributors:
* [hinaloe](https://github.com/hinaloe) * [hinaloe](https://github.com/hinaloe)
* [hcmiya](https://github.com/hcmiya) * [hcmiya](https://github.com/hcmiya)
* [stephenburgess8](https://github.com/stephenburgess8) * [stephenburgess8](https://github.com/stephenburgess8)
* [Wonderfall](mailto:wonderfall@targaryen.house) * [Wonderfall](https://github.com/Wonderfall)
* [matteoaquila](https://github.com/matteoaquila) * [matteoaquila](https://github.com/matteoaquila)
* [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)
* [jeroenpraat](mailto:jeroenpraat@users.noreply.github.com)
* [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)
@ -73,18 +74,19 @@ and provided thanks to the work of the following contributors:
* [SerCom_KC](mailto:sercom-kc@users.noreply.github.com) * [SerCom_KC](mailto:sercom-kc@users.noreply.github.com)
* [Sylvhem](https://github.com/Sylvhem) * [Sylvhem](https://github.com/Sylvhem)
* [MitarashiDango](https://github.com/MitarashiDango) * [MitarashiDango](https://github.com/MitarashiDango)
* [rinsuki](https://github.com/rinsuki)
* [angristan](https://github.com/angristan) * [angristan](https://github.com/angristan)
* [JeanGauthier](https://github.com/JeanGauthier) * [JeanGauthier](https://github.com/JeanGauthier)
* [kschaper](https://github.com/kschaper) * [kschaper](https://github.com/kschaper)
* [beatrix-bitrot](https://github.com/beatrix-bitrot) * [beatrix-bitrot](https://github.com/beatrix-bitrot)
* [koyuawsmbrtn](https://github.com/koyuawsmbrtn) * [koyuawsmbrtn](https://github.com/koyuawsmbrtn)
* [BenLubar](https://github.com/BenLubar) * [BenLubar](https://github.com/BenLubar)
* [mkljczk](https://github.com/mkljczk)
* [adbelle](https://github.com/adbelle) * [adbelle](https://github.com/adbelle)
* [evanminto](https://github.com/evanminto) * [evanminto](https://github.com/evanminto)
* [MightyPork](https://github.com/MightyPork) * [MightyPork](https://github.com/MightyPork)
* [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)
* [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)
@ -100,7 +102,6 @@ and provided thanks to the work of the following contributors:
* [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)
@ -135,10 +136,11 @@ and provided thanks to the work of the following contributors:
* [kadiix](https://github.com/kadiix) * [kadiix](https://github.com/kadiix)
* [kodacs](https://github.com/kodacs) * [kodacs](https://github.com/kodacs)
* [marcin mikołajczak](mailto:me@m4sk.in) * [marcin mikołajczak](mailto:me@m4sk.in)
* [JMendyk](https://github.com/JMendyk)
* [KScl](https://github.com/KScl) * [KScl](https://github.com/KScl)
* [sterdev](https://github.com/sterdev) * [sterdev](https://github.com/sterdev)
* [mashirozx](https://github.com/mashirozx)
* [TheKinrar](https://github.com/TheKinrar) * [TheKinrar](https://github.com/TheKinrar)
* [007lva](https://github.com/007lva)
* [AA4ch1](https://github.com/AA4ch1) * [AA4ch1](https://github.com/AA4ch1)
* [alexgleason](https://github.com/alexgleason) * [alexgleason](https://github.com/alexgleason)
* [Bèr Kessels](mailto:ber@berk.es) * [Bèr Kessels](mailto:ber@berk.es)
@ -150,6 +152,7 @@ and provided thanks to the work of the following contributors:
* [hendotcat](https://github.com/hendotcat) * [hendotcat](https://github.com/hendotcat)
* [d6rkaiz](https://github.com/d6rkaiz) * [d6rkaiz](https://github.com/d6rkaiz)
* [ladyisatis](https://github.com/ladyisatis) * [ladyisatis](https://github.com/ladyisatis)
* [JMendyk](https://github.com/JMendyk)
* [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)
@ -159,6 +162,7 @@ and provided thanks to the work of the following contributors:
* [ekiru](https://github.com/ekiru) * [ekiru](https://github.com/ekiru)
* [geta6](https://github.com/geta6) * [geta6](https://github.com/geta6)
* [happycoloredbanana](https://github.com/happycoloredbanana) * [happycoloredbanana](https://github.com/happycoloredbanana)
* [joenepraat](https://github.com/joenepraat)
* [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)
@ -169,13 +173,13 @@ and provided thanks to the work of the following contributors:
* [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)
* [alyssais](https://github.com/alyssais) * [alyssais](https://github.com/alyssais)
* [aablinov](https://github.com/aablinov) * [aablinov](https://github.com/aablinov)
* [stalker314314](https://github.com/stalker314314) * [stalker314314](https://github.com/stalker314314)
* [cohosh](https://github.com/cohosh)
* [cutls](https://github.com/cutls) * [cutls](https://github.com/cutls)
* [huertanix](https://github.com/huertanix) * [huertanix](https://github.com/huertanix)
* [eleboucher](https://github.com/eleboucher) * [eleboucher](https://github.com/eleboucher)
@ -184,7 +188,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)
* [Korbinian](mailto:kontakt@korbinian-michl.de) * [MonaLisaOverrdrive](https://github.com/MonaLisaOverrdrive)
* [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)
@ -204,6 +208,7 @@ and provided thanks to the work of the following contributors:
* [gled-rs](https://github.com/gled-rs) * [gled-rs](https://github.com/gled-rs)
* [Valentin_NC](mailto:valentin.ouvrard@nautile.sarl) * [Valentin_NC](mailto:valentin.ouvrard@nautile.sarl)
* [R0ckweb](https://github.com/R0ckweb) * [R0ckweb](https://github.com/R0ckweb)
* [Izorkin](https://github.com/Izorkin)
* [unasuke](https://github.com/unasuke) * [unasuke](https://github.com/unasuke)
* [caasi](https://github.com/caasi) * [caasi](https://github.com/caasi)
* [chr-1x](https://github.com/chr-1x) * [chr-1x](https://github.com/chr-1x)
@ -211,13 +216,14 @@ and provided thanks to the work of the following contributors:
* [foxiehkins](https://github.com/foxiehkins) * [foxiehkins](https://github.com/foxiehkins)
* [highemerly](https://github.com/highemerly) * [highemerly](https://github.com/highemerly)
* [hoodie](mailto:hoodiekitten@outlook.com) * [hoodie](mailto:hoodiekitten@outlook.com)
* [kaiyou](https://github.com/kaiyou)
* [luzi82](https://github.com/luzi82) * [luzi82](https://github.com/luzi82)
* [slice](https://github.com/slice) * [slice](https://github.com/slice)
* [tmm576](https://github.com/tmm576) * [tmm576](https://github.com/tmm576)
* [unsmell](mailto:unsmell@users.noreply.github.com) * [unsmell](mailto:unsmell@users.noreply.github.com)
* [valerauko](https://github.com/valerauko) * [valerauko](https://github.com/valerauko)
* [chriswmartin](https://github.com/chriswmartin) * [chriswmartin](https://github.com/chriswmartin)
* [vahnj](https://github.com/vahnj) * [SuperSandro2000](https://github.com/SuperSandro2000)
* [ikuradon](https://github.com/ikuradon) * [ikuradon](https://github.com/ikuradon)
* [AndreLewin](https://github.com/AndreLewin) * [AndreLewin](https://github.com/AndreLewin)
* [0xflotus](https://github.com/0xflotus) * [0xflotus](https://github.com/0xflotus)
@ -254,17 +260,20 @@ and provided thanks to the work of the following contributors:
* [ian-kelling](https://github.com/ian-kelling) * [ian-kelling](https://github.com/ian-kelling)
* [immae](https://github.com/immae) * [immae](https://github.com/immae)
* [J0WI](https://github.com/J0WI) * [J0WI](https://github.com/J0WI)
* [vahnj](https://github.com/vahnj)
* [foozmeat](https://github.com/foozmeat) * [foozmeat](https://github.com/foozmeat)
* [jasonrhodes](https://github.com/jasonrhodes) * [jasonrhodes](https://github.com/jasonrhodes)
* [Jason Snell](mailto:jason@newrelic.com) * [Jason Snell](mailto:jason@newrelic.com)
* [jviide](https://github.com/jviide) * [jviide](https://github.com/jviide)
* [YuleZ](https://github.com/YuleZ) * [YuleZ](https://github.com/YuleZ)
* [jtracey](https://github.com/jtracey)
* [crakaC](https://github.com/crakaC) * [crakaC](https://github.com/crakaC)
* [tkbky](https://github.com/tkbky) * [tkbky](https://github.com/tkbky)
* [Kaylee](mailto:kaylee@codethat.sucks) * [Kaylee](mailto:kaylee@codethat.sucks)
* [Kazhnuz](https://github.com/Kazhnuz) * [Kazhnuz](https://github.com/Kazhnuz)
* [mkody](https://github.com/mkody) * [mkody](https://github.com/mkody)
* [connyduck](https://github.com/connyduck) * [connyduck](https://github.com/connyduck)
* [Tak](https://github.com/Tak)
* [LindseyB](https://github.com/LindseyB) * [LindseyB](https://github.com/LindseyB)
* [Lorenz Diener](mailto:halcyon@icosahedron.website) * [Lorenz Diener](mailto:halcyon@icosahedron.website)
* [Markus Amalthea Magnuson](mailto:markus.magnuson@gmail.com) * [Markus Amalthea Magnuson](mailto:markus.magnuson@gmail.com)
@ -282,9 +291,9 @@ and provided thanks to the work of the following contributors:
* [lumenwrites](https://github.com/lumenwrites) * [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)
* [u1-liquid](https://github.com/u1-liquid) * [u1-liquid](https://github.com/u1-liquid)
* [rosylilly](https://github.com/rosylilly) * [rosylilly](https://github.com/rosylilly)
* [withshubh](https://github.com/withshubh)
* [sim6](https://github.com/sim6) * [sim6](https://github.com/sim6)
* [Sir-Boops](https://github.com/Sir-Boops) * [Sir-Boops](https://github.com/Sir-Boops)
* [stemid](https://github.com/stemid) * [stemid](https://github.com/stemid)
@ -305,15 +314,16 @@ and provided thanks to the work of the following contributors:
* [anon5r](https://github.com/anon5r) * [anon5r](https://github.com/anon5r)
* [aus-social](https://github.com/aus-social) * [aus-social](https://github.com/aus-social)
* [bsky](mailto:me@imbsky.net) * [bsky](mailto:me@imbsky.net)
* [chandrn7](https://github.com/chandrn7)
* [codl](https://github.com/codl) * [codl](https://github.com/codl)
* [cpsdqs](https://github.com/cpsdqs) * [cpsdqs](https://github.com/cpsdqs)
* [barzamin](https://github.com/barzamin) * [barzamin](https://github.com/barzamin)
* [gol-cha](https://github.com/gol-cha)
* [fhalna](https://github.com/fhalna) * [fhalna](https://github.com/fhalna)
* [haoyayoi](https://github.com/haoyayoi) * [haoyayoi](https://github.com/haoyayoi)
* [ik11235](https://github.com/ik11235) * [ik11235](https://github.com/ik11235)
* [kawax](https://github.com/kawax) * [kawax](https://github.com/kawax)
* [shrft](https://github.com/shrft) * [shrft](https://github.com/shrft)
* [007lva](https://github.com/007lva)
* [mbajur](https://github.com/mbajur) * [mbajur](https://github.com/mbajur)
* [matsurai25](https://github.com/matsurai25) * [matsurai25](https://github.com/matsurai25)
* [mecab](https://github.com/mecab) * [mecab](https://github.com/mecab)
@ -353,7 +363,7 @@ and provided thanks to the work of the following contributors:
* [a2](https://github.com/a2) * [a2](https://github.com/a2)
* [alfiedotwtf](https://github.com/alfiedotwtf) * [alfiedotwtf](https://github.com/alfiedotwtf)
* [0xa](https://github.com/0xa) * [0xa](https://github.com/0xa)
* [ArisuOngaku](https://github.com/ArisuOngaku) * [ashpieboop](https://github.com/ashpieboop)
* [virtualpain](https://github.com/virtualpain) * [virtualpain](https://github.com/virtualpain)
* [sapphirus](https://github.com/sapphirus) * [sapphirus](https://github.com/sapphirus)
* [amandavisconti](https://github.com/amandavisconti) * [amandavisconti](https://github.com/amandavisconti)
@ -367,6 +377,7 @@ and provided thanks to the work of the following contributors:
* [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)
* [aldatsa](https://github.com/aldatsa)
* [jumbosushi](https://github.com/jumbosushi) * [jumbosushi](https://github.com/jumbosushi)
* [acuteaura](https://github.com/acuteaura) * [acuteaura](https://github.com/acuteaura)
* [ayumin](https://github.com/ayumin) * [ayumin](https://github.com/ayumin)
@ -375,7 +386,7 @@ and provided thanks to the work of the following contributors:
* [li-bei](https://github.com/li-bei) * [li-bei](https://github.com/li-bei)
* [Benedikt Geißler](mailto:benedikt@g5r.eu) * [Benedikt Geißler](mailto:benedikt@g5r.eu)
* [BenisonSebastian](https://github.com/BenisonSebastian) * [BenisonSebastian](https://github.com/BenisonSebastian)
* [blakebarnett](https://github.com/blakebarnett) * [Blake](mailto:blake.barnett@postmates.com)
* [Brad Janke](mailto:brad.janke@gmail.com) * [Brad Janke](mailto:brad.janke@gmail.com)
* [bclindner](https://github.com/bclindner) * [bclindner](https://github.com/bclindner)
* [brycied00d](https://github.com/brycied00d) * [brycied00d](https://github.com/brycied00d)
@ -395,10 +406,12 @@ 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)
* [monsterpit-firedemon](https://github.com/monsterpit-firedemon) * [Daggertooth](mailto:dev@monsterpit.net)
* [watilde](https://github.com/watilde) * [watilde](https://github.com/watilde)
* [dalehenries](https://github.com/dalehenries)
* [daprice](https://github.com/daprice) * [daprice](https://github.com/daprice)
* [da2x](https://github.com/da2x) * [da2x](https://github.com/da2x)
* [danieljakots](https://github.com/danieljakots)
* [codesections](https://github.com/codesections) * [codesections](https://github.com/codesections)
* [dar5hak](https://github.com/dar5hak) * [dar5hak](https://github.com/dar5hak)
* [kant](https://github.com/kant) * [kant](https://github.com/kant)
@ -423,6 +436,7 @@ and provided thanks to the work of the following contributors:
* [espenronnevik](https://github.com/espenronnevik) * [espenronnevik](https://github.com/espenronnevik)
* [Expenses](mailto:expenses@airmail.cc) * [Expenses](mailto:expenses@airmail.cc)
* [fabianonline](https://github.com/fabianonline) * [fabianonline](https://github.com/fabianonline)
* [shello](https://github.com/shello)
* [Finariel](https://github.com/Finariel) * [Finariel](https://github.com/Finariel)
* [siuying](https://github.com/siuying) * [siuying](https://github.com/siuying)
* [zoc](https://github.com/zoc) * [zoc](https://github.com/zoc)
@ -433,7 +447,7 @@ and provided thanks to the work of the following contributors:
* [hattori6789](https://github.com/hattori6789) * [hattori6789](https://github.com/hattori6789)
* [algernon](https://github.com/algernon) * [algernon](https://github.com/algernon)
* [Fastbyte01](https://github.com/Fastbyte01) * [Fastbyte01](https://github.com/Fastbyte01)
* [myfreeweb](https://github.com/myfreeweb) * [unrelentingtech](https://github.com/unrelentingtech)
* [gfaivre](https://github.com/gfaivre) * [gfaivre](https://github.com/gfaivre)
* [Fiaxhs](https://github.com/Fiaxhs) * [Fiaxhs](https://github.com/Fiaxhs)
* [rasjonell](https://github.com/rasjonell) * [rasjonell](https://github.com/rasjonell)
@ -445,17 +459,20 @@ and provided thanks to the work of the following contributors:
* [Habu-Kagumba](https://github.com/Habu-Kagumba) * [Habu-Kagumba](https://github.com/Habu-Kagumba)
* [suzukaze](https://github.com/suzukaze) * [suzukaze](https://github.com/suzukaze)
* [Hiromi-Kai](https://github.com/Hiromi-Kai) * [Hiromi-Kai](https://github.com/Hiromi-Kai)
* [hishamhm](https://github.com/hishamhm) * [Hisham Muhammad](mailto:hisham@gobolinux.org)
* [Slaynash](https://github.com/Slaynash) * [Hugo "Slaynash" Flores](mailto:hugoflores@hotmail.fr)
* [musashino205](https://github.com/musashino205) * [INAGAKI Hiroshi](mailto:musashino205@users.noreply.github.com)
* [iwaim](https://github.com/iwaim) * [IWAI, Masaharu](mailto:iwaim.sub@gmail.com)
* [valrus](https://github.com/valrus) * [Ian McCowan](mailto:imccowan@gmail.com)
* [IMcD23](https://github.com/IMcD23) * [Ian McDowell](mailto:me@ianmcdowell.net)
* [yi0713](https://github.com/yi0713) * [Iijima Yasushi](mailto:kurage.cc@gmail.com)
* [iblech](https://github.com/iblech) * [Ikko Ashimine](mailto:eltociear@gmail.com)
* [Ingo Blechschmidt](mailto:iblech@web.de)
* [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](mailto:jack-michaud@users.noreply.github.com)
* [Floppy](https://github.com/Floppy) * [Jakub Mendyk](mailto:jakubmendyk.szkola@gmail.com)
* [James](mailto:james.allen.vaughan@gmail.com)
* [James Smith](mailto:james@floppy.org.uk)
* [Jarek Lipski](mailto:pub@loomchild.net) * [Jarek Lipski](mailto:pub@loomchild.net)
* [Jennifer Glauche](mailto:=^.^=@github19.jglauche.de) * [Jennifer Glauche](mailto:=^.^=@github19.jglauche.de)
* [Jennifer Kruse](mailto:jenkr55@gmail.com) * [Jennifer Kruse](mailto:jenkr55@gmail.com)
@ -464,6 +481,7 @@ and provided thanks to the work of the following contributors:
* [Jessica K. Litwin](mailto:jessica@litw.in) * [Jessica K. Litwin](mailto:jessica@litw.in)
* [Jo Decker](mailto:trolldecker@users.noreply.github.com) * [Jo Decker](mailto:trolldecker@users.noreply.github.com)
* [Joan Montané](mailto:jmontane@users.noreply.github.com) * [Joan Montané](mailto:jmontane@users.noreply.github.com)
* [Joe](mailto:401283+htmlbyjoe@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)
@ -483,7 +501,6 @@ and provided thanks to the work of the following contributors:
* [Krzysztof Jurewicz](mailto:krzysztof.jurewicz@gmail.com) * [Krzysztof Jurewicz](mailto:krzysztof.jurewicz@gmail.com)
* [Leo Wzukw](mailto:leowzukw@users.noreply.github.com) * [Leo Wzukw](mailto:leowzukw@users.noreply.github.com)
* [Leonie](mailto:62470640+bubblineyuri@users.noreply.github.com) * [Leonie](mailto:62470640+bubblineyuri@users.noreply.github.com)
* [Levi Bard](mailto:taktaktaktaktaktaktaktaktaktak@gmail.com)
* [Lex Alexander](mailto:l.alexander10@gmail.com) * [Lex Alexander](mailto:l.alexander10@gmail.com)
* [Lorenz Diener](mailto:lorenzd@gmail.com) * [Lorenz Diener](mailto:lorenzd@gmail.com)
* [Luc Didry](mailto:ldidry@users.noreply.github.com) * [Luc Didry](mailto:ldidry@users.noreply.github.com)
@ -560,6 +577,7 @@ and provided thanks to the work of the following contributors:
* [ScienJus](mailto:i@scienjus.com) * [ScienJus](mailto:i@scienjus.com)
* [Scott Larkin](mailto:scott@codeclimate.com) * [Scott Larkin](mailto:scott@codeclimate.com)
* [Scott Sweeny](mailto:scott@ssweeny.net) * [Scott Sweeny](mailto:scott@ssweeny.net)
* [Sean](mailto:sean@sean.taipei)
* [Sebastian Hübner](mailto:imolein@users.noreply.github.com) * [Sebastian Hübner](mailto:imolein@users.noreply.github.com)
* [Sebastian Morr](mailto:sebastian@morr.cc) * [Sebastian Morr](mailto:sebastian@morr.cc)
* [Sergei Č](mailto:noiwex1911@gmail.com) * [Sergei Č](mailto:noiwex1911@gmail.com)
@ -570,8 +588,10 @@ and provided thanks to the work of the following contributors:
* [Shouko Yu](mailto:imshouko@gmail.com) * [Shouko Yu](mailto:imshouko@gmail.com)
* [Sina Mashek](mailto:sina@mashek.xyz) * [Sina Mashek](mailto:sina@mashek.xyz)
* [Soft. Dev](mailto:24978+nileshkumar@users.noreply.github.com) * [Soft. Dev](mailto:24978+nileshkumar@users.noreply.github.com)
* [Sophie Parker](mailto:dev@cortices.me)
* [Soshi Kato](mailto:mail@sossii.com) * [Soshi Kato](mailto:mail@sossii.com)
* [Spanky](mailto:2788886+spankyworks@users.noreply.github.com) * [Spanky](mailto:2788886+spankyworks@users.noreply.github.com)
* [Stanislas](mailto:stanislas.lange@pm.me)
* [StefOfficiel](mailto:pichard.stephane@free.fr) * [StefOfficiel](mailto:pichard.stephane@free.fr)
* [Steven Tappert](mailto:admin@dark-it.net) * [Steven Tappert](mailto:admin@dark-it.net)
* [Stéphane Guillou](mailto:stephane.guillou@member.fsf.org) * [Stéphane Guillou](mailto:stephane.guillou@member.fsf.org)
@ -630,9 +650,9 @@ and provided thanks to the work of the following contributors:
* [evilny0](mailto:evilny0@moomoocamp.net) * [evilny0](mailto:evilny0@moomoocamp.net)
* [febrezo](mailto:felixbrezo@gmail.com) * [febrezo](mailto:felixbrezo@gmail.com)
* [fsubal](mailto:fsubal@users.noreply.github.com) * [fsubal](mailto:fsubal@users.noreply.github.com)
* [fusagiko / takayamaki](mailto:24884114+takayamaki@users.noreply.github.com)
* [fusshi-](mailto:dikky1218@users.noreply.github.com) * [fusshi-](mailto:dikky1218@users.noreply.github.com)
* [gentaro](mailto:gentaroooo@gmail.com) * [gentaro](mailto:gentaroooo@gmail.com)
* [gol-cha](mailto:info@mevo.xyz)
* [guigeekz](mailto:pattusg@gmail.com) * [guigeekz](mailto:pattusg@gmail.com)
* [hakoai](mailto:hk--76@qa2.so-net.ne.jp) * [hakoai](mailto:hk--76@qa2.so-net.ne.jp)
* [haosbvnker](mailto:github@chaosbunker.com) * [haosbvnker](mailto:github@chaosbunker.com)
@ -645,7 +665,7 @@ and provided thanks to the work of the following contributors:
* [jooops](mailto:joops@autistici.org) * [jooops](mailto:joops@autistici.org)
* [jukper](mailto:jukkaperanto@gmail.com) * [jukper](mailto:jukkaperanto@gmail.com)
* [jumoru](mailto:jumoru@mailbox.org) * [jumoru](mailto:jumoru@mailbox.org)
* [kaiyou](mailto:pierre@jaury.eu) * [kaias1jp](mailto:kaias1jp@gmail.com)
* [karlyeurl](mailto:karl.yeurl@gmail.com) * [karlyeurl](mailto:karl.yeurl@gmail.com)
* [kawaguchi](mailto:jiikko@users.noreply.github.com) * [kawaguchi](mailto:jiikko@users.noreply.github.com)
* [kedama](mailto:32974885+kedamadq@users.noreply.github.com) * [kedama](mailto:32974885+kedamadq@users.noreply.github.com)
@ -705,104 +725,131 @@ 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:
- ᏦᏁᎢᎵᏫ 😷 (KNTRO) (*Spanish, Argentina*) - GunChleoc (*Scottish Gaelic*)
- ᛤᚤᛠᛥⴲ 👽 (KNTRO) (*Spanish, Argentina*)
- adrmzz (*Sardinian*)
- Hồ Nhất Duy (kantcer) (*Vietnamese*)
- Zoltán Gera (gerazo) (*Hungarian*)
- Sveinn í Felli (sveinki) (*Icelandic*) - Sveinn í Felli (sveinki) (*Icelandic*)
- qezwan (*Persian, Sorani (Kurdish)*) - qezwan (*Persian, Sorani (Kurdish)*)
- Hồ Nhất Duy (kantcer) (*Vietnamese*) - NCAA (*Danish*)
- Ramdziana F Y (rafeyu) (*Indonesian*)
- taicv (*Vietnamese*) - taicv (*Vietnamese*)
- Zoltán Gera (gerazo) (*Hungarian*)
- ButterflyOfFire (BoFFire) (*French, Arabic, Kabyle*) - 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*) - Xosé M. (XoseM) (*Spanish, Galician*)
- Kristijan Tkalec (lapor) (*Slovenian*) - Evert Prants (IcyDiamond) (*Estonian*)
- stan ionut (stanionut12) (*Romanian*)
- Besnik_b (*Albanian*) - Besnik_b (*Albanian*)
- Emanuel Pina (emanuelpina) (*Portuguese*) - Emanuel Pina (emanuelpina) (*Portuguese*)
- Jeong Arm (Kjwon15) (*Japanese, Korean, Esperanto*)
- Alix Rossi (palindromordnilap) (*French, Esperanto, Corsican*)
- Thai Localization (thl10n) (*Thai*) - Thai Localization (thl10n) (*Thai*)
- Daniele Lira Mereb (danilmereb) (*Portuguese, Brazilian*)
- Joene (joenepraat) (*Dutch*)
- Kristijan Tkalec (lapor) (*Slovenian*)
- stan ionut (stanionut12) (*Romanian*)
- spla (*Spanish, Catalan*)
- мачко (ma4ko) (*Bulgarian*)
- 奈卜拉 (nebula_moe) (*Chinese Simplified*) - 奈卜拉 (nebula_moe) (*Chinese Simplified*)
- Jeong Arm (Kjwon15) (*Japanese, Korean, Esperanto*) - kamee (*Armenian*)
- AJ-عجائب البرمجة (Esmail_Hazem) (*Arabic*)
- Michal Stanke (mstanke) (*Czech*) - 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*) - Danial Behzadi (danialbehzadi) (*Persian*)
- yeft (*Chinese Traditional, Chinese Traditional, Hong Kong*) - borys_sh (*Ukrainian*)
- Asier Iturralde Sarasola (aldatsa) (*Basque*)
- Imre Kristoffer Eilertsen (DandelionSprout) (*Norwegian*)
- koyu (*German*) - koyu (*German*)
- yeft (*Chinese Traditional, Chinese Traditional, Hong Kong*)
- Miguel Mayol (mitcoes) (*Spanish, Catalan*)
- Sasha Sorokin (Brawaru) (*French, Catalan, Danish, German, Greek, Hungarian, Armenian, Korean, Russian, Albanian, Swedish, Ukrainian, Vietnamese, Galician*)
- Roboron (*Spanish*)
- Koala Yeung (yookoala) (*Chinese Traditional, Hong Kong*) - Koala Yeung (yookoala) (*Chinese Traditional, Hong Kong*)
- Ondřej Pokorný (unextro) (*Czech*)
- Osoitz (*Basque*) - Osoitz (*Basque*)
- Peterandre (*Norwegian, Norwegian Nynorsk*) - Peterandre (*Norwegian, Norwegian Nynorsk*)
- tzium (*Sardinian*) - tzium (*Sardinian*)
- Mélanie Chauvel (ariasuni) (*French, Arabic, Czech, German, Greek, Hungarian, Slovenian, Ukrainian, Chinese Simplified, Portuguese, Brazilian, Persian, Norwegian Nynorsk, Esperanto, Breton, Corsican, Sardinian, Kabyle*)
- Iváns (Ivans_translator) (*Galician*) - Iváns (Ivans_translator) (*Galician*)
- Sasha Sorokin (Sasha-Sorokin) (*French, Catalan, Danish, German, Greek, Hungarian, Armenian, Korean, Russian, Albanian, Swedish, Ukrainian, Vietnamese, Galician*) - Maya Minatsuki (mayaeh) (*Japanese*)
- kamee (*Armenian*) - Manuel Viens (manuelviens) (*French*)
- Alessandro Levati (Oct326) (*Italian*)
- lamnatos (*Greek*)
- Sean Young (assanges) (*Chinese Traditional*)
- tolstoevsky (*Russian*) - tolstoevsky (*Russian*)
- enolp (*Asturian*) - enolp (*Asturian*)
- FédiQuébec (manuelviens) (*French*) - Jasmine Cam Andrever (gourmas) (*Cornish*)
- lamnatos (*Greek*) - gagik_ (*Armenian*)
- Maya Minatsuki (mayaeh) (*Japanese*)
- Masoud Abkenar (mabkenar) (*Persian*) - Masoud Abkenar (mabkenar) (*Persian*)
- Alessandro Levati (Oct326) (*Italian*)
- arshat (*Kazakh*) - arshat (*Kazakh*)
- Roboron (*Spanish*) - Marcin Mikołajczak (mkljczkk) (*Czech, Polish, Russian*)
- ariasuni (*French, Arabic, Czech, German, Greek, Hungarian, Slovenian, Ukrainian, Chinese Simplified, Portuguese, Brazilian, Persian, Norwegian Nynorsk, Esperanto, Breton, Corsican, Sardinian, Kabyle*) - Marek Ľach (mareklach) (*Polish, Slovak*)
- Ali Demirtaş (alidemirtas) (*Turkish*) - Ali Demirtaş (alidemirtas) (*Turkish*)
- Blak Ouille (BlakOuille16) (*French*)
- Em St Cenydd (cancennau) (*Welsh*) - Em St Cenydd (cancennau) (*Welsh*)
- Marek Ľach (mareklach) (*Polish, Slovak*) - Diluns (*Occitan*)
- Muha Aliss (muhaaliss) (*Turkish*) - Muha Aliss (muhaaliss) (*Turkish*)
- Jurica (ahjk) (*Croatian*) - Jurica (ahjk) (*Croatian*)
- Aditoo17 (*Czech*) - Aditoo17 (*Czech*)
- Diluns (*Occitan*)
- gagik_ (*Armenian*)
- vishnuvaratharajan (*Tamil*) - vishnuvaratharajan (*Tamil*)
- Marcin Mikołajczak (mkljczkk) (*Czech, Polish, Russian*) - pulmonarycosignerkindness (*Swedish*)
- cybergene (cyber-gene) (*Japanese*)
- Takeçi (polygoat) (*French, Italian*)
- xatier (*Chinese Traditional*)
- Ihor Hordiichuk (ihor_ck) (*Ukrainian*)
- regulartranslator (*Portuguese, Brazilian*) - regulartranslator (*Portuguese, Brazilian*)
- ozzii (*French, Serbian (Cyrillic)*)
- Irfan (Irfan_Radz) (*Malay*)
- Saederup92 (*Danish*)
- Akarshan Biswas (biswasab) (*Bengali, Sanskrit*) - Akarshan Biswas (biswasab) (*Bengali, Sanskrit*)
- Yi-Jyun Pan (pan93412) (*Chinese Traditional*) - Yi-Jyun Pan (pan93412) (*Chinese Traditional*)
- Rafael H L Moretti (Moretti) (*Portuguese, Brazilian*)
- 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*) - ThibG (*French, Icelandic*)
- xatier (*Chinese Traditional*)
- otrapersona (*Spanish, Spanish, Mexico*) - otrapersona (*Spanish, Spanish, Mexico*)
- Store (HelaBasa) (*Sinhala*)
- Mauzi (*German, Swedish*)
- atarashiako (*Chinese Simplified*) - atarashiako (*Chinese Simplified*)
- 101010 (101010pl) (*Polish*) - 101010 (101010pl) (*Polish*)
- erictapen (*German*)
- Tagomago (tagomago) (*French, Spanish*)
- Jaz-Michael King (jazmichaelking) (*Welsh*)
- coxde (*Chinese Simplified*)
- T. E. Kalaycı (tekrei) (*Turkish*)
- silkevicious (*Italian*) - silkevicious (*Italian*)
- Floxu (fredrikdim1) (*Norwegian Nynorsk*) - Floxu (fredrikdim1) (*Norwegian Nynorsk*)
- Ryo (DrRyo) (*Korean*)
- Bertil Hedkvist (Berrahed) (*Swedish*) - Bertil Hedkvist (Berrahed) (*Swedish*)
- William(ѕ)ⁿ (wmlgr) (*Spanish*) - William(ѕ)ⁿ (wmlgr) (*Spanish*)
- norayr (*Armenian*) - norayr (*Armenian*)
- Satnam S Virdi (pika10singh) (*Punjabi*)
- Tiago Epifânio (tfve) (*Portuguese*) - Tiago Epifânio (tfve) (*Portuguese*)
- Ryo (DrRyo) (*Korean*) - Balázs Meskó (mesko.balazs) (*Hungarian*)
- Sokratis Alichanidis (alichani) (*Greek*)
- Mentor Gashi (mentorgashi.com) (*Albanian*) - Mentor Gashi (mentorgashi.com) (*Albanian*)
- Jaz-Michael King (jazmichaelking) (*Welsh*)
- carolinagiorno (*Portuguese, Brazilian*) - carolinagiorno (*Portuguese, Brazilian*)
- Hayk Khachatryan (brutusromanus123) (*Armenian*)
- Roby Thomas (roby.thomas) (*Malayalam*) - Roby Thomas (roby.thomas) (*Malayalam*)
- Bharat Kumar (Marwari) (*Hindi*) - Bharat Kumar (Marwari) (*Hindi*)
- Austra Muizniece (aus_m) (*Latvian*)
- ThonyVezbe (*Breton*) - ThonyVezbe (*Breton*)
- v4vachan (*Malayalam*)
- dkdarshan760 (*Sanskrit*) - dkdarshan760 (*Sanskrit*)
- Tagomago (tagomago) (*French, Spanish*)
- tykayn (*French*) - tykayn (*French*)
- axi (*Finnish*) - axi (*Finnish*)
- Selyan Slimane AMIRI (slimane_AMIRI) (*Kabyle*) - Selyan Slimane AMIRI (SelyanKab) (*Kabyle*)
- Balázs Meskó (mesko.balazs) (*Hungarian*) - Timur Seber (seber) (*Tatar*)
- taoxvx (*Danish*) - taoxvx (*Danish*)
- Hrach Mkrtchyan (mhrach87) (*Armenian*) - Hrach Mkrtchyan (mhrach87) (*Armenian*)
- sabri (thetomatoisavegetable) (*Spanish, Spanish, Argentina*) - sabri (thetomatoisavegetable) (*Spanish, Spanish, Argentina*)
- Dewi (Unkorneg) (*French, Breton*) - Dewi (Unkorneg) (*French, Breton*)
- Coelacanthus (*Chinese Simplified*) - CoelacanthusHex (*Chinese Simplified*)
- syncopams (*Chinese Simplified, Chinese Traditional, Chinese Traditional, Hong Kong*) - syncopams (*Chinese Simplified, Chinese Traditional, Chinese Traditional, Hong Kong*)
- Rhys Harrison (rhedders) (*Esperanto*)
- Hakim Oubouali (zenata1) (*Standard Moroccan Tamazight*)
- SteinarK (*Norwegian Nynorsk*) - SteinarK (*Norwegian Nynorsk*)
- Sokratis Alichanidis (alichani) (*Greek*) - Lalo Tafolla (lalotafo) (*Spanish, Spanish, Mexico*)
- Mathias B. Vagnes (vagnes) (*Norwegian*) - Mathias B. Vagnes (vagnes) (*Norwegian*)
- dashersyed (*Urdu (Pakistan)*) - dashersyed (*Urdu (Pakistan)*)
- Acolyte (666noob404) (*Ukrainian*) - Acolyte (666noob404) (*Ukrainian*)
@ -811,104 +858,124 @@ Following people have contributed to translation of Mastodon:
- Damjan Dimitrioski (gnud) (*Macedonian*) - Damjan Dimitrioski (gnud) (*Macedonian*)
- PPNplus (*Thai*) - PPNplus (*Thai*)
- shioko (*Chinese Simplified*) - shioko (*Chinese Simplified*)
- v4vachan (*Malayalam*) - ZiriSut (*Kabyle*)
- Hakim Oubouali (zenata1) (*Standard Moroccan Tamazight*)
- Evgeny Petrov (kondra007) (*Russian*) - Evgeny Petrov (kondra007) (*Russian*)
- Gwenn (Belvar) (*Breton*) - Gwenn (Belvar) (*Breton*)
- StanleyFrew (*French*) - StanleyFrew (*French*)
- Hayk Khachatryan (brutusromanus123) (*Armenian*) - Nikita Epifanov (Nikets) (*Russian*)
- jaranta (*Finnish*) - jaranta (*Finnish*)
- Felicia (midsommar) (*Swedish*) - Slobodan Simić (Слободан Симић) (slsimic) (*Serbian (Cyrillic)*)
- Felicia Jongleur (midsommar) (*Swedish*)
- Denys (dector) (*Ukrainian*) - Denys (dector) (*Ukrainian*)
- iVampireSP (*Chinese Simplified, Chinese Traditional*)
- Pukima (pukimaaa) (*German*) - Pukima (pukimaaa) (*German*)
- 游荡 (MamaShip) (*Chinese Simplified*)
- Vanege (*Esperanto*) - Vanege (*Esperanto*)
- Rikard Linde (rikardlinde) (*Swedish*)
- Jess Rafn (therealyez) (*Danish*) - Jess Rafn (therealyez) (*Danish*)
- strubbl (*German*) - strubbl (*German*)
- Stasiek Michalski (hellcp) (*Polish*) - Stasiek Michalski (hellcp) (*Polish*)
- dxwc (*Bengali*) - dxwc (*Bengali*)
- jmontane (*Catalan*) - jmontane (*Catalan*)
- Liboide (*Spanish*) - Liboide (*Spanish*)
- Hexandcube (hexandcube) (*Polish*)
- Chris Kay (chriskarasoulis) (*Greek*)
- Johan Schiff (schyffel) (*Swedish*) - Johan Schiff (schyffel) (*Swedish*)
- Arunmozhi (tecoholic) (*Tamil*) - Arunmozhi (tecoholic) (*Tamil*)
- zer0-x (ZER0-X) (*Arabic*)
- kat (katktv) (*Russian, Ukrainian*) - kat (katktv) (*Russian, Ukrainian*)
- Rikard Linde (rikardlinde) (*Swedish*) - Lauren Liberda (selfisekai) (*Polish*)
- mynameismonkey (*Welsh*)
- oti4500 (*Hungarian, Ukrainian*) - oti4500 (*Hungarian, Ukrainian*)
- Laura (selfisekai) (*Polish*) - Mats Gunnar Ahlqvist (goqbi) (*Swedish*)
- Rachida S. (ZiriSut) (*Kabyle*)
- diazepan (*Spanish, Spanish, Argentina*) - diazepan (*Spanish, Spanish, Argentina*)
- marzuquccen (*Kabyle*) - marzuquccen (*Kabyle*)
- Juan José Salvador Piedra (JuanjoSalvador) (*Spanish*) - VictorCorreia (victorcorreia1984) (*Afrikaans*)
- Tigran (tigransimonyan) (*Armenian*) - Tigran (tigransimonyan) (*Armenian*)
- Juan José Salvador Piedra (JuanjoSalvador) (*Spanish*)
- BurekzFinezt (*Serbian (Cyrillic)*) - BurekzFinezt (*Serbian (Cyrillic)*)
- SHeija (*Finnish*) - SHeija (*Finnish*)
- Gearguy (*Finnish*)
- atriix (*Swedish*) - atriix (*Swedish*)
- Jack R (isaac.97_WT) (*Spanish*) - Jack R (isaac.97_WT) (*Spanish*)
- antonyho (*Chinese Traditional, Hong Kong*) - antonyho (*Chinese Traditional, Hong Kong*)
- asnomgtu (*Hungarian*)
- ahangarha (*Persian*)
- andruhov (*Russian, Ukrainian*) - andruhov (*Russian, Ukrainian*)
- Aryamik Sharma (Aryamik) (*Swedish, Hindi*)
- phena109 (*Chinese Traditional, Hong Kong*) - phena109 (*Chinese Traditional, Hong Kong*)
- Aryamik Sharma (Aryamik) (*Swedish, Hindi*)
- Unmual (*Spanish*)
- 森の子リスのミーコの大冒険 (Phroneris) (*Japanese*) - 森の子リスのミーコの大冒険 (Phroneris) (*Japanese*)
- るいーね (ruine) (*Japanese*) - るいーね (ruine) (*Japanese*)
- ahangarha (*Persian*)
- Sam Tux (imahbub) (*Bengali*) - Sam Tux (imahbub) (*Bengali*)
- Kristoffer Grundström (Umeaboy) (*Swedish*)
- igordrozniak (*Polish*) - igordrozniak (*Polish*)
- Unmual (*Spanish*)
- Isaac Huang (caasih) (*Chinese Traditional*) - Isaac Huang (caasih) (*Chinese Traditional*)
- AW Unad (awcodify) (*Indonesian*) - AW Unad (awcodify) (*Indonesian*)
- Allen Zhong (AstroProfundis) (*Chinese Simplified*) - Allen Zhong (AstroProfundis) (*Chinese Simplified*)
- Cutls (cutls) (*Japanese*) - Cutls (cutls) (*Japanese*)
- Ray (Ipsumry) (*Spanish*)
- Falling Snowdin (tghgg) (*Vietnamese*) - Falling Snowdin (tghgg) (*Vietnamese*)
- coxde (*Chinese Simplified*) - Ray (Ipsumry) (*Spanish*)
- Gianfranco Fronteddu (gianfro.gianfro) (*Sardinian*)
- Rasmus Lindroth (RasmusLindroth) (*Swedish*) - Rasmus Lindroth (RasmusLindroth) (*Swedish*)
- Andrea Lo Iacono (niels0n) (*Italian*) - Andrea Lo Iacono (niels0n) (*Italian*)
- Parodper (*Galician*)
- fucsia (*Italian*)
- NadieAishi (*Spanish, Spanish, Mexico*)
- Kinshuk Sunil (kinshuksunil) (*Hindi*) - Kinshuk Sunil (kinshuksunil) (*Hindi*)
- Ullas Joseph (ullasjoseph) (*Malayalam*) - Ullas Joseph (ullasjoseph) (*Malayalam*)
- Goudarz Jafari (Goudarz) (*Persian*) - Goudarz Jafari (Goudarz) (*Persian*)
- Yu-Pai Liu (tedliou) (*Chinese Traditional*) - Yu-Pai Liu (tedliou) (*Chinese Traditional*)
- Amarin Cemthong (acitmaster) (*Thai*) - Amarin Cemthong (acitmaster) (*Thai*)
- Johannes Nilsson (nlssn) (*Swedish*)
- juanda097 (juanda-097) (*Spanish*) - juanda097 (juanda-097) (*Spanish*)
- Anunnakey (*Macedonian*) - Anunnakey (*Macedonian*)
- fragola (*Italian*) - erikkemp (*Dutch*)
- erikstl (*Esperanto*) - erikstl (*Esperanto*)
- twpenguin (*Chinese Traditional*)
- bobchao (*Chinese Traditional*) - bobchao (*Chinese Traditional*)
- Esther (esthermations) (*Portuguese*) - twpenguin (*Chinese Traditional*)
- MadeInSteak (*Finnish*) - MadeInSteak (*Finnish*)
- Heimen Stoffels (vistausss) (*Dutch*) - Esther (esthermations) (*Portuguese*)
- t_aus_m (*German*)
- Heimen Stoffels (Vistaus) (*Dutch*)
- Rajarshi Guha (rajarshiguha) (*Bengali*) - Rajarshi Guha (rajarshiguha) (*Bengali*)
- Andrew (iAndrew3) (*Romanian*) - Mo_der Steven (SakuraPuare) (*Chinese Simplified*)
- Gopal Sharma (gopalvirat) (*Hindi*) - Gopal Sharma (gopalvirat) (*Hindi*)
- arethsu (*Swedish*) - arethsu (*Swedish*)
- Tofiq Abdula (Xwla) (*Sorani (Kurdish)*)
- Carlos Solís (csolisr) (*Esperanto*) - Carlos Solís (csolisr) (*Esperanto*)
- Tofiq Abdula (Xwla) (*Sorani (Kurdish)*)
- Parthan S Ramanujam (parthan) (*Tamil*) - Parthan S Ramanujam (parthan) (*Tamil*)
- Kasper Nymand (KasperNymand) (*Danish*) - Kasper Nymand (KasperNymand) (*Danish*)
- Jeff Huang (s8321414) (*Chinese Traditional*)
- TS (morte) (*Finnish*) - TS (morte) (*Finnish*)
- subram (*Turkish*) - subram (*Turkish*)
- SensDeViata (*Ukrainian*) - SensDeViata (*Ukrainian*)
- Ptrcmd (ptrcmd) (*Chinese Traditional*) - Ptrcmd (ptrcmd) (*Chinese Traditional*)
- SergioFMiranda (*Portuguese, Brazilian*) - SergioFMiranda (*Portuguese, Brazilian*)
- Scvoet (scvoet) (*Chinese Simplified*) - Percy (scvoet) (*Chinese Simplified*)
- Vivek K J (Vivekkj) (*Malayalam*)
- hiroTS (*Chinese Traditional*) - hiroTS (*Chinese Traditional*)
- johne32rus23 (*Russian*) - johne32rus23 (*Russian*)
- AzureNya (*Chinese Simplified*) - AzureNya (*Chinese Simplified*)
- OctolinGamer (octolingamer) (*Portuguese, Brazilian*) - OctolinGamer (octolingamer) (*Portuguese, Brazilian*)
- Ram varma (ram4varma) (*Tamil*) - Ram varma (ram4varma) (*Tamil*)
- Hexandcube (hexandcube) (*Polish*)
- 北䑓如法 (Nyoho) (*Japanese*) - 北䑓如法 (Nyoho) (*Japanese*)
- Pukima (Pukimaa) (*German*)
- diorama (*Italian*)
- Daniel Dimitrov (daniel.dimitrov) (*Bulgarian*)
- frumble (*German*) - frumble (*German*)
- kekkepikkuni (*Tamil*) - kekkepikkuni (*Tamil*)
- Neo_Chen (NeoChen1024) (*Chinese Traditional*)
- oorsutri (*Tamil*) - oorsutri (*Tamil*)
- Rhys Harrison (rhedders) (*Esperanto*) - Neo_Chen (NeoChen1024) (*Chinese Traditional*)
- Nithin V (Nithin896) (*Tamil*) - Nithin V (Nithin896) (*Tamil*)
- Marcus Myge (mygg-priv) (*Norwegian*)
- Miro Rauhala (mirorauhala) (*Finnish*) - Miro Rauhala (mirorauhala) (*Finnish*)
- diorama (*Italian*)
- AlexKoala (alexkoala) (*Korean*) - AlexKoala (alexkoala) (*Korean*)
- ಚಿ ನಟರ (chiraag-nataraj) (*Kannada*)
- Aswin C (officialcjunior) (*Malayalam*) - Aswin C (officialcjunior) (*Malayalam*)
- Guillaume Turchini (orion78fr) (*French*) - Guillaume Turchini (orion78fr) (*French*)
- Ganesh D (auntgd) (*Marathi*) - Ganesh D (auntgd) (*Marathi*)
- mawoka-myblock (mawoka) (*German*)
- dragnucs2 (*Arabic*) - dragnucs2 (*Arabic*)
- Ryan Ho (koungho) (*Chinese Traditional*) - Ryan Ho (koungho) (*Chinese Traditional*)
- Pedro Henrique (exploronauta) (*Portuguese, Brazilian*) - Pedro Henrique (exploronauta) (*Portuguese, Brazilian*)
@ -916,203 +983,245 @@ Following people have contributed to translation of Mastodon:
- Vasanthan (vasanthan) (*Tamil*) - Vasanthan (vasanthan) (*Tamil*)
- 硫酸鶏 (acid_chicken) (*Japanese*) - 硫酸鶏 (acid_chicken) (*Japanese*)
- clarmin b8 (clarminb8) (*Sorani (Kurdish)*) - clarmin b8 (clarminb8) (*Sorani (Kurdish)*)
- programizer (*German*)
- manukp (*Malayalam*) - manukp (*Malayalam*)
- psymyn (*Hebrew*)
- earth dweller (sanethoughtyt) (*Marathi*) - earth dweller (sanethoughtyt) (*Marathi*)
- psymyn (*Hebrew*)
- meijerivoi (toilet) (*Finnish*) - meijerivoi (toilet) (*Finnish*)
- essaar (*Tamil*) - essaar (*Tamil*)
- serubeena (*Swedish*) - serubeena (*Swedish*)
- Karol Kosek (krkkPL) (*Polish*)
- Rintan (*Japanese*) - Rintan (*Japanese*)
- valarivan (*Tamil*) - Karol Kosek (krkkPL) (*Polish*)
- Khó͘ Tiat-lêng (khotiatleng) (*Chinese Traditional, Taigi*)
- Hernik (hernik27) (*Czech*) - Hernik (hernik27) (*Czech*)
- Sebastián Andil (Selrond) (*Slovak*) - valarivan (*Tamil*)
- kuchengrab (*German*)
- friedbeans (*Croatian*)
- Abi Turi (abi123) (*Georgian*)
- Hinaloe (hinaloe) (*Japanese*) - Hinaloe (hinaloe) (*Japanese*)
- filippodb (*Italian*) - Sebastián Andil (Selrond) (*Slovak*)
- KEINOS (*Japanese*) - KEINOS (*Japanese*)
- filippodb (*Italian*)
- Asbjørn Olling (a2) (*Danish*)
- Balázs Meskó (meskobalazs) (*Hungarian*) - Balázs Meskó (meskobalazs) (*Hungarian*)
- Bottle (suryasalem2010) (*Tamil*) - Bottle (suryasalem2010) (*Tamil*)
- JzshAC (*Chinese Simplified*)
- Wrya ali (John12) (*Sorani (Kurdish)*) - Wrya ali (John12) (*Sorani (Kurdish)*)
- Khóo (khootiatling) (*Chinese Traditional*) - JzshAC (*Chinese Simplified*)
- Steven Tappert (sammy8806) (*German*)
- Antillion (antillion99) (*Spanish*) - Antillion (antillion99) (*Spanish*)
- Pukima (Pukimaa) (*German*) - Steven Tappert (sammy8806) (*German*)
- Reg3xp (*Persian*) - Reg3xp (*Persian*)
- hiphipvargas (*Portuguese*) - Wassim EL BOUHAMIDI (elbouhamidiw) (*Arabic*)
- gowthamanb (*Tamil*) - gowthamanb (*Tamil*)
- hiphipvargas (*Portuguese*)
- Ch. (sftblw) (*Korean*) - Ch. (sftblw) (*Korean*)
- Jeff Huang (s8321414) (*Chinese Traditional*)
- Arttu Ylhävuori (arttu.ylhavuori) (*Finnish*) - Arttu Ylhävuori (arttu.ylhavuori) (*Finnish*)
- tctovsli (*Norwegian Nynorsk*) - tctovsli (*Norwegian Nynorsk*)
- Timo Tijhof (Krinkle) (*Dutch*) - Timo Tijhof (Krinkle) (*Dutch*)
- Mikkel B. Goldschmidt (mikkelbjoern) (*Danish*)
- mecqor labi (mecqorlabi) (*Persian*)
- Odyssey346 (alexader612) (*Norwegian*)
- Yamagishi Kazutoshi (ykzts) (*Japanese, Icelandic, Sorani (Kurdish)*) - Yamagishi Kazutoshi (ykzts) (*Japanese, Icelandic, Sorani (Kurdish)*)
- Eban (ebanDev) (*French, Esperanto*)
- vjasiegd (*Polish*) - vjasiegd (*Polish*)
- SamitiMed (samiti3d) (*Thai*) - SamitiMed (samiti3d) (*Thai*)
- Nícolas Lavinicki (nclavinicki) (*Portuguese, Brazilian*)
- snatcher (*Portuguese, Brazilian*)
- Rekan Adl (rekan-adl1) (*Sorani (Kurdish)*) - Rekan Adl (rekan-adl1) (*Sorani (Kurdish)*)
- VSx86 (*Russian*)
- umelard (*Hebrew*) - umelard (*Hebrew*)
- Antara2Cinta (Se7enTime) (*Indonesian*) - Antara2Cinta (Se7enTime) (*Indonesian*)
- VSx86 (*Russian*)
- Daniel Dimitrov (danny-dimitrov) (*Bulgarian*)
- parnikkapore (*Thai*) - parnikkapore (*Thai*)
- mynameismonkey (*Welsh*)
- Sherwan Othman (sherwanothman11) (*Sorani (Kurdish)*) - Sherwan Othman (sherwanothman11) (*Sorani (Kurdish)*)
- Yassine Aït-El-Mouden (yaitelmouden) (*Standard Moroccan Tamazight*) - Yassine Aït-El-Mouden (yaitelmouden) (*Standard Moroccan Tamazight*)
- SKELET (*Danish*) - SKELET (*Danish*)
- Mo_der Steven (SakuraPuare) (*Chinese Simplified*)
- Fei Yang (Fei1Yang) (*Chinese Traditional*) - Fei Yang (Fei1Yang) (*Chinese Traditional*)
- ALEM FARID (faridatcemlulaqbayli) (*Kabyle*) - Ğani (freegnu) (*Tatar*)
- Renato "Lond" Cerqueira (renatolond) (*Portuguese, Brazilian*)
- enipra (*Armenian*) - enipra (*Armenian*)
- ALEM FARID (faridatcemlulaqbayli) (*Kabyle*)
- musix (*Persian*) - musix (*Persian*)
- Renato "Lond" Cerqueira (renatolond) (*Portuguese, Brazilian*)
- ギャラ (gyara) (*Japanese, Chinese Simplified*) - ギャラ (gyara) (*Japanese, Chinese Simplified*)
- Hougo (hougo) (*French*) - Hougo (hougo) (*French*)
- ybardapurkar (*Marathi*) - ybardapurkar (*Marathi*)
- 亜緯丹穂 (ayiniho) (*Japanese*)
- Adrián Lattes (haztecaso) (*Spanish*) - Adrián Lattes (haztecaso) (*Spanish*)
- Mordi Sacks (MordiSacks) (*Hebrew*)
- Trinsec (*Dutch*)
- Tigran's Tips (tigrank08) (*Armenian*)
- TracyJacks (*Chinese Simplified*) - TracyJacks (*Chinese Simplified*)
- Szabolcs Gál (galszabolcs810624) (*Hungarian*)
- Vladislav Săcrieriu (vladislavs14) (*Romanian*)
- danreznik (*Hebrew*)
- rasheedgm (*Kannada*) - rasheedgm (*Kannada*)
- GatoOscuro (*Spanish*) - omquylzu (*Latvian*)
- mecqor labi (mecqorlabi) (*Persian*) - c6ristian (*German*)
- Belkacem Mohammed (belkacem77) (*Kabyle*) - Belkacem Mohammed (belkacem77) (*Kabyle*)
- lexxai (*Ukrainian*)
- Navjot Singh (nspeaks) (*Hindi*) - Navjot Singh (nspeaks) (*Hindi*)
- omquylzu (*Latvian*)
- Ozai (*German*) - Ozai (*German*)
- Sahak Petrosyan (petrosyan) (*Armenian*) - Sahak Petrosyan (petrosyan) (*Armenian*)
- siamano (*Thai, Esperanto*) - Oymate (*Bengali*)
- Viorel-Cătălin Răpițeanu (rapiteanu) (*Romanian*) - Viorel-Cătălin Răpițeanu (rapiteanu) (*Romanian*)
- siamano (*Thai, Esperanto*)
- Siddhartha Sarathi Basu (quinoa_biryani) (*Bengali*) - Siddhartha Sarathi Basu (quinoa_biryani) (*Bengali*)
- Pachara Chantawong (pachara2202) (*Thai*) - Pachara Chantawong (pachara2202) (*Thai*)
- mkljczk (*Polish*)
- Skew (noan.perrot) (*French*)
- Zijian Zhao (jobs2512821228) (*Chinese Simplified*) - Zijian Zhao (jobs2512821228) (*Chinese Simplified*)
- turtle836 (*German*) - Skew (noan.perrot) (*French*)
- mkljczk (*Polish*)
- Guru Prasath Anandapadmanaban (guruprasath) (*Tamil*) - Guru Prasath Anandapadmanaban (guruprasath) (*Tamil*)
- Lamin (laminne) (*Japanese*) - turtle836 (*German*)
- Marcepanek_ (thekingmarcepan) (*Polish*) - Marcepanek_ (thekingmarcepan) (*Polish*)
- Feruz Oripov (FeruzOripov) (*Russian*) - Lamin (laminne) (*Japanese*)
- Yann Aguettaz (yann-a) (*French*) - Yann Aguettaz (yann-a) (*French*)
- Feruz Oripov (FeruzOripov) (*Russian*)
- serapolis (*Chinese Simplified, Chinese Traditional*)
- Mick Onio (xgc.redes) (*Asturian*) - Mick Onio (xgc.redes) (*Asturian*)
- Tianqi Zhang (tina.zhang040609) (*Chinese Simplified*)
- Malik Mann (dermalikmann) (*German*) - Malik Mann (dermalikmann) (*German*)
- dadosch (*German*) - dadosch (*German*)
- r3dsp1 (*Chinese Traditional, Hong Kong*) - r3dsp1 (*Chinese Traditional, Hong Kong*)
- padulafacundo (*Spanish*)
- hg6 (*Hindi*) - hg6 (*Hindi*)
- Tianqi Zhang (tina.zhang040609) (*Chinese Simplified*)
- padulafacundo (*Spanish*)
- johannes hove-henriksen (J0hsHH) (*Norwegian*)
- Orlando Murcio (Atos20) (*Spanish, Mexico*) - Orlando Murcio (Atos20) (*Spanish, Mexico*)
- Padraic Calpin (padraic-padraic) (*Slovenian*)
- cenegd (*Chinese Simplified*)
- piupiupiudiu (*Chinese Simplified*) - piupiupiudiu (*Chinese Simplified*)
- shdy (*German*) - shdy (*German*)
- Padraic Calpin (padraic-padraic) (*Slovenian*)
- Ильзира Рахматуллина (rahmatullinailzira53) (*Tatar*) - Ильзира Рахматуллина (rahmatullinailzira53) (*Tatar*)
- cenegd (*Chinese Simplified*)
- Hugh Liu (youloveonlymeh) (*Chinese Simplified*) - Hugh Liu (youloveonlymeh) (*Chinese Simplified*)
- Pixelcode (realpixelcode) (*German*) - Pixelcode (realpixelcode) (*German*)
- Yogesh K S (yogi) (*Kannada*) - Yogesh K S (yogi) (*Kannada*)
- Adithya K (adithyak04) (*Malayalam*)
- Dennis Reimund (reimunddennis7) (*German*)
- Rakino (rakino) (*Chinese Simplified*) - Rakino (rakino) (*Chinese Simplified*)
- Miquel Sabaté Solà (mssola) (*Catalan*) - Michał Sidor (michcioperz) (*Polish*)
- AmazighNM (*Kabyle*) - AmazighNM (*Kabyle*)
- Miquel Sabaté Solà (mssola) (*Catalan*)
- Jothipazhani Nagarajan (jothipazhani.n) (*Tamil*) - Jothipazhani Nagarajan (jothipazhani.n) (*Tamil*)
- Clash Clans (KURD12345) (*Sorani (Kurdish)*)
- hallomaurits (*Dutch*) - hallomaurits (*Dutch*)
- alnd hezh (alndhezh) (*Sorani (Kurdish)*) - alnd hezh (alndhezh) (*Sorani (Kurdish)*)
- Clash Clans (KURD12345) (*Sorani (Kurdish)*)
- Solid Rhino (SolidRhino) (*Dutch*) - Solid Rhino (SolidRhino) (*Dutch*)
- Metehan Özyürek (MetehanOzyurek) (*Turkish*)
- 林水溶 (shuiRong) (*Chinese Simplified*)
- Sébastien Feugère (smonff) (*French*)
- Y.Yamashiro (uist1idrju3i) (*Japanese*)
- Takeshi Umeda (noellabo) (*Japanese*)
- k_taka (peaceroad) (*Japanese*) - k_taka (peaceroad) (*Japanese*)
- Hallo Abdullah (hallo_hamza12) (*Sorani (Kurdish)*)
- hussama (*Portuguese, Brazilian*) - hussama (*Portuguese, Brazilian*)
- Sébastien Feugère (smonff) (*French*) - Hallo Abdullah (hallo_hamza12) (*Sorani (Kurdish)*)
- 林水溶 (shuiRong) (*Chinese Simplified*) - Ashok314 (ashok314) (*Hindi*)
- eichkat3r (*German*)
- OminousCry (*Russian*)
- SnDer (*Dutch*)
- PifyZ (*French*) - PifyZ (*French*)
- OminousCry (*Russian*)
- Robert Yano (throwcalmbobaway) (*Spanish, Mexico*)
- Tom_ (*Czech*) - Tom_ (*Czech*)
- Tagada (Tagadda) (*French*) - Tagada (Tagadda) (*French*)
- shafouz (*Portuguese, Brazilian*) - shafouz (*Portuguese, Brazilian*)
- Yasin İsa YILDIRIM (redsfyre) (*Turkish*)
- eichkat3r (*German*)
- SnDer (*Dutch*)
- Kahina Mess (K_hina) (*Kabyle*) - Kahina Mess (K_hina) (*Kabyle*)
- Nathaël Noguès (NatNgs) (*French*)
- Kk (kishorkumara3) (*Kannada*)
- Swati Sani (swatisani) (*Urdu (Pakistan)*) - Swati Sani (swatisani) (*Urdu (Pakistan)*)
- Kk (kishorkumara3) (*Kannada*)
- Daniel M. (daniconil) (*Catalan*)
- Shrinivasan T (tshrinivasan) (*Tamil*) - Shrinivasan T (tshrinivasan) (*Tamil*)
- さっかりんにーさん (saccharin23) (*Japanese*)
- 夜楓Yoka (Yoka2627) (*Chinese Simplified*) - 夜楓Yoka (Yoka2627) (*Chinese Simplified*)
- Daniel M. (daniconil) (*Catalan*) - Nathaël Noguès (NatNgs) (*French*)
- さっかりんにーさん (saccharin23) (*Japanese*)
- Rex_sa (rex07) (*Arabic*)
- Robin van der Vliet (RobinvanderVliet) (*Esperanto*)
- Vikatakavi (*Kannada*) - Vikatakavi (*Kannada*)
- SusVersiva (*Catalan*)
- Tradjincal (tradjincal) (*French*) - Tradjincal (tradjincal) (*French*)
- pullopen (*Chinese Simplified*) - pullopen (*Chinese Simplified*)
- Robin van der Vliet (RobinvanderVliet) (*Esperanto*) - SusVersiva (*Catalan*)
- Marvin (magicmarvman) (*German*)
- Zinkokooo (*Basque*) - Zinkokooo (*Basque*)
- mmokhi (*Persian*)
- Livingston Samuel (livingston) (*Tamil*) - Livingston Samuel (livingston) (*Tamil*)
- prabhjot (*Hindi*)
- sergioaraujo1 (*Portuguese, Brazilian*)
- CyberAmoeba (pseudoobscura) (*Chinese Simplified*) - CyberAmoeba (pseudoobscura) (*Chinese Simplified*)
- tsundoker (*Malayalam*) - tsundoker (*Malayalam*)
- eorn (*Breton*)
- prabhjot (*Hindi*)
- mmokhi (*Persian*)
- sergioaraujo1 (*Portuguese, Brazilian*)
- Entelekheia-ousia (*Chinese Simplified*)
- Pierre Morvan (Iriep) (*Breton*)
- oscfd (*Spanish*)
- skaaarrr (*German*) - skaaarrr (*German*)
- Ricardo Colin (rysard) (*Spanish*)
- mkljczk (mykylyjczyk) (*Polish*) - mkljczk (mykylyjczyk) (*Polish*)
- Philipp Fischbeck (PFischbeck) (*German*)
- fedot (*Russian*) - fedot (*Russian*)
- Paz Galindo (paz.almendra.g) (*Spanish*) - Paz Galindo (paz.almendra.g) (*Spanish*)
- GaggiX (*Italian*) - Ricardo Colin (rysard) (*Spanish*)
- ralozkolya (*Georgian*) - Philipp Fischbeck (PFischbeck) (*German*)
- Zoé Bőle (zoe1337) (*German*) - Zoé Bőle (zoe1337) (*German*)
- EzigboOmenana (*Cornish*)
- GaggiX (*Italian*)
- Lukas Fülling (lfuelling) (*German*) - Lukas Fülling (lfuelling) (*German*)
- JackXu (Merman-Jack) (*Chinese Simplified*) - JackXu (Merman-Jack) (*Chinese Simplified*)
- Aymeric (AymBroussier) (*French*) - ralozkolya (*Georgian*)
- Apple (blackteaovo) (*Chinese Simplified*)
- asala4544 (*Basque*)
- Xurxo Guerra (xguerrap) (*Galician*)
- qwerty287 (*German*)
- Anoop (anoopp) (*Malayalam*) - Anoop (anoopp) (*Malayalam*)
- pezcurrel (*Italian*) - pezcurrel (*Italian*)
- Samir Tighzert (samir_t7) (*Kabyle*)
- Dremski (*Bulgarian*) - Dremski (*Bulgarian*)
- Xurxo Guerra (xguerrap) (*Galician*) - Dennis Reimund (reimund_dennis) (*German*)
- ru_mactunnag (*Scottish Gaelic*)
- Nocta (*French*)
- Aymeric (AymBroussier) (*French*)
- mashirozx (*Chinese Simplified*) - mashirozx (*Chinese Simplified*)
- Albatroz Jeremias (albjeremias) (*Portuguese*) - Albatroz Jeremias (albjeremias) (*Portuguese*)
- Samir Tighzert (samir_t7) (*Kabyle*) - Matias Lavik (matiaslavik) (*Norwegian Nynorsk*)
- Apple (blackteaovo) (*Chinese Simplified*) - Amith Raj Shetty (amithraj1989) (*Kannada*)
- Nocta (*French*)
- OpenAlgeria (*Arabic*)
- tamaina (*Japanese*)
- abidin toumi (Zet24) (*Arabic*) - abidin toumi (Zet24) (*Arabic*)
- mikel (mikelalas) (*Spanish*)
- OpenAlgeria (*Arabic*)
- random_person (*Spanish*)
- Sais Lakshmanan (Saislakshmanan) (*Tamil*)
- Trond Boksasp (boksasp) (*Norwegian*)
- xpac1985 (xpac) (*German*) - xpac1985 (xpac) (*German*)
- Kaede (kaedech) (*Japanese*) - Zlr- (cZeler) (*French*)
- ÀŘǾŚ PÀŚĦÀÍ (arospashai) (*Sorani (Kurdish)*) - Mohammad Adnan Mahmood (adnanmig) (*Arabic*)
- Matias Lavik (matiaslavik) (*Norwegian Nynorsk*) - mimikun (*Japanese*)
- smedvedev (*Russian*) - smedvedev (*Russian*)
- mikel (mikelalas) (*Spanish*) - asretro (*Chinese Traditional, Hong Kong*)
- tamaina (*Japanese*)
- Aman Alam (aalam) (*Punjabi*)
- ÀŘǾŚ PÀŚĦÀÍ (arospashai) (*Sorani (Kurdish)*)
- Kaede (kaedech) (*Japanese*)
- Doug (douglasalvespe) (*Portuguese, Brazilian*) - Doug (douglasalvespe) (*Portuguese, Brazilian*)
- Trond Boksasp (boksasp) (*Norwegian*)
- Fleva (*Sardinian*) - Fleva (*Sardinian*)
- Mohammad Adnan Mahmood (adnanmig) (*Arabic*) - Abijeet Patro (Abijeet) (*Basque*)
- Sais Lakshmanan (Saislakshmanan) (*Tamil*) - SamOak (*Portuguese, Brazilian*)
- Amith Raj Shetty (amithraj1989) (*Kannada*) - Aries (orlea) (*Japanese*)
- random_person (*Spanish*) - Bartek Fijałkowski (brateq) (*Polish*)
- NeverMine17 (*Russian*)
- Brodi (brodi1) (*Dutch*)
- Ács Zoltán (zoli111) (*Hungarian*)
- capiscuas (*Spanish*)
- Benjamin Cobb (benjamincobb) (*German*)
- djoerd (*Dutch*) - djoerd (*Dutch*)
- waweic (*German*)
- Amir Kurdo (kuraking202) (*Sorani (Kurdish)*)
- dobrado (*Portuguese, Brazilian*)
- Baban Abdulrahman (baban.abdulrehman) (*Sorani (Kurdish)*) - Baban Abdulrahman (baban.abdulrehman) (*Sorani (Kurdish)*)
- ebrezhoneg (*Breton*) - dcapillae (*Spanish*)
- dashty (*Sorani (Kurdish)*) - Azad ahmad (dashty) (*Sorani (Kurdish)*)
- Salh_haji6 (*Sorani (Kurdish)*) - Salh_haji6 (*Sorani (Kurdish)*)
- Amir Kurdo (kuraking202) (*Sorani (Kurdish)*)
- おさ (osapon) (*Japanese*)
- Ranj A Abdulqadir (RanjAhmed) (*Sorani (Kurdish)*) - Ranj A Abdulqadir (RanjAhmed) (*Sorani (Kurdish)*)
- umonaca (*Chinese Simplified*)
- Bartek Fijałkowski (brateq) (*Polish*)
- tateisu (*Japanese*) - tateisu (*Japanese*)
- centumix (*Japanese*)
- Jari Ronkainen (ronchaine) (*Finnish*)
- Savarín Electrográfico Marmota Intergalactica (herrero.maty) (*Spanish*) - Savarín Electrográfico Marmota Intergalactica (herrero.maty) (*Spanish*)
- Torsten Högel (torstenhoegel) (*German*) - ebrezhoneg (*Breton*)
- Abijeet Patro (Abijeet) (*Basque*) - 于晚霞 (xissshawww) (*Chinese Simplified*)
- Ács Zoltán (acszoltan111) (*Hungarian*)
- Benjamin Cobb (benjamincobb) (*German*)
- waweic (*German*)
- Aries (orlea) (*Japanese*)
- silverscat_3 (SilversCat) (*Japanese*) - silverscat_3 (SilversCat) (*Japanese*)
- centumix (*Japanese*)
- umonaca (*Chinese Simplified*)
- Ni Futchi (futchitwo) (*Japanese*)
- おさ (osapon) (*Japanese*)
- kavitha129 (*Tamil*) - 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*) - Hannah (Aniqueper1) (*Chinese Simplified*)
- Jiniux (*Italian*) - Jiniux (*Italian*)
- 于晚霞 (xissshawww) (*Chinese Simplified*) - Jari Ronkainen (ronchaine) (*Finnish*)
- Nithya Mary (nithyamary25) (*Tamil*)

@ -3,6 +3,170 @@ 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.4.1] - 2021-06-03
### Added
- Add new emoji assets from Twemoji 13.1.0 ([Gargron](https://github.com/tootsuite/mastodon/pull/16345))
### Fixed
- Fix some ActivityPub identifiers in server actor outbox ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/16343))
- Fix custom CSS path setting cookies and being uncacheable due to it ([tribela](https://github.com/tootsuite/mastodon/pull/16314))
- Fix unread notification count when polling in web UI ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/16272))
- Fix health check not being accessible through localhost ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/16270))
- Fix some redis locks auto-releasing too fast ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/16276), [ClearlyClaire](https://github.com/tootsuite/mastodon/pull/16291))
- Fix e-mail confirmations API not working correctly ([Gargron](https://github.com/tootsuite/mastodon/pull/16348))
- Fix migration script not being able to run if it fails midway ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/16312))
- Fix account deletion sometimes failing because of optimistic locks ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/16317))
- Fix deprecated slash as division in SASS files ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/16347))
- Fix `tootctl search deploy` compatibility error on Ruby 3 ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/16346))
- Fix mailer jobs for deleted notifications erroring out ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/16294))
## [3.4.0] - 2021-05-16
### Added
- **Add follow recommendations for onboarding** ([Gargron](https://github.com/tootsuite/mastodon/pull/15945), [Gargron](https://github.com/tootsuite/mastodon/pull/16161), [Gargron](https://github.com/tootsuite/mastodon/pull/16060), [Gargron](https://github.com/tootsuite/mastodon/pull/16077), [Gargron](https://github.com/tootsuite/mastodon/pull/16078), [Gargron](https://github.com/tootsuite/mastodon/pull/16160), [Gargron](https://github.com/tootsuite/mastodon/pull/16079), [noellabo](https://github.com/tootsuite/mastodon/pull/16044), [noellabo](https://github.com/tootsuite/mastodon/pull/16045), [Gargron](https://github.com/tootsuite/mastodon/pull/16152), [Gargron](https://github.com/tootsuite/mastodon/pull/16153), [ClearlyClaire](https://github.com/tootsuite/mastodon/pull/16082), [ClearlyClaire](https://github.com/tootsuite/mastodon/pull/16173), [ClearlyClaire](https://github.com/tootsuite/mastodon/pull/16159), [ClearlyClaire](https://github.com/tootsuite/mastodon/pull/16189))
- Tutorial on first web UI launch has been replaced with follow suggestions
- Follow suggestions take user locale into account and are a mix of accounts most followed by currently active local users, and accounts that wrote the most shared/favourited posts in the last 30 days
- Only accounts that have opted-in to being discoverable from their profile settings, and that do not require follow requests, will be suggested
- Moderators can review suggestions for every supported locale and suppress specific suggestions from appearing and admins can ensure certain accounts always show up in suggestions from the settings area
- New users no longer automatically follow admins
- **Add server rules** ([Gargron](https://github.com/tootsuite/mastodon/pull/15769), [ClearlyClaire](https://github.com/tootsuite/mastodon/pull/15778))
- Admins can create and edit itemized server rules
- They are available through the REST API and on the about page
- **Add canonical e-mail blocks for suspended accounts** ([Gargron](https://github.com/tootsuite/mastodon/pull/16049))
- Normally, people can make multiple accounts using the same e-mail address using the `+` trick or by inserting or removing `.` characters from the first part of their address
- Once an account is suspended, it will no longer be possible for the e-mail address used by that account to be used for new sign-ups in any of its forms
- Add management of delivery availability in admin UI ([noellabo](https://github.com/tootsuite/mastodon/pull/15771))
- **Add system checks to dashboard in admin UI** ([Gargron](https://github.com/tootsuite/mastodon/pull/15989), [ClearlyClaire](https://github.com/tootsuite/mastodon/pull/15954), [ClearlyClaire](https://github.com/tootsuite/mastodon/pull/16002))
- The dashboard will now warn you if you some Sidekiq queues are not being processed, if you have not defined any server rules, or if you forgot to run database migrations from the latest Mastodon upgrade
- Add inline description of moderation actions in admin UI ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/15792))
- Add "recommended" label to activity/peers API toggles in admin UI ([Gargron](https://github.com/tootsuite/mastodon/pull/16081))
- Add joined date to profiles in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/16169), [rinsuki](https://github.com/tootsuite/mastodon/pull/16186))
- Add transition to media modal background in web UI ([mkljczk](https://github.com/tootsuite/mastodon/pull/15843))
- Add option to opt-out of unread notification markers in web UI ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/15842))
- Add borders to 📱, 🚲, and 📲 emojis in web UI ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/15794), [ClearlyClaire](https://github.com/tootsuite/mastodon/pull/16035))
- Add dropdown for boost privacy in boost confirmation modal in web UI ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/15704))
- Add support for Ruby 3.0 ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/16046), [ClearlyClaire](https://github.com/tootsuite/mastodon/pull/16174))
- Add `Message-ID` header to outgoing emails ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/16076))
- Some e-mail spam filters penalize e-mails that have a `Message-ID` header that uses a different domain name than the sending e-mail address. Now, the same domain will be used
- Add `af`, `gd` and `si` locales ([Gargron](https://github.com/tootsuite/mastodon/pull/16090))
- Add guard against DNS rebinding attacks ([noellabo](https://github.com/tootsuite/mastodon/pull/16087), [noellabo](https://github.com/tootsuite/mastodon/pull/16095))
- Add HTTP header to explicitly opt-out of FLoC by default ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/16036))
- Add missing push notification title for polls and statuses ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/15929), [mkljczk](https://github.com/tootsuite/mastodon/pull/15564), [ClearlyClaire](https://github.com/tootsuite/mastodon/pull/15931))
- Add `POST /api/v1/emails/confirmations` to REST API ([Gargron](https://github.com/tootsuite/mastodon/pull/15816), [Gargron](https://github.com/tootsuite/mastodon/pull/15949))
- This method allows an app through which a user signed-up to request a new confirmation e-mail to be sent, or to change the e-mail of the account before it is confirmed
- Add `GET /api/v1/accounts/lookup` to REST API ([Gargron](https://github.com/tootsuite/mastodon/pull/15740), [ClearlyClaire](https://github.com/tootsuite/mastodon/pull/15750))
- This method allows to quickly convert a username of a known account to an ID that can be used with the REST API, or to check if a username is available
for sign-up
- Add `policy` param to `POST /api/v1/push/subscriptions` in REST API ([Gargron](https://github.com/tootsuite/mastodon/pull/16040))
- This param allows an app to control from whom notifications should be delivered as push notifications to the app
- Add `details` to error response for `POST /api/v1/accounts` in REST API ([Gargron](https://github.com/tootsuite/mastodon/pull/15803))
- This attribute allows an app to display more helpful information to the user about why the sign-up did not succeed
- Add `SIDEKIQ_REDIS_URL` and related environment variables to optionally use a separate Redis server for Sidekiq ([noellabo](https://github.com/tootsuite/mastodon/pull/16188))
### Changed
- Change trending hashtags to be affected be reblogs ([Gargron](https://github.com/tootsuite/mastodon/pull/16164))
- Previously, only original posts contributed to a hashtag's trending score
- Now, reblogs of posts will also contribute to that hashtag's trending score
- Change e-mail confirmation link to always redirect to web UI ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/16151))
- Change log level of worker lifecycle to WARN in streaming API ([Gargron](https://github.com/tootsuite/mastodon/pull/16110))
- Since running with INFO log level in production is not always desirable, it is easy to miss when a worker is shutdown and a new one is started
- Change the nouns "toot" and "status" to "post" in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/16080), [Gargron](https://github.com/tootsuite/mastodon/pull/16089))
- To be clear, the button still says "Toot!"
- Change order of dropdown menu on posts to be more intuitive in web UI ([ariasuni](https://github.com/tootsuite/mastodon/pull/15647))
- Change description of keyboard shortcuts in web UI ([ariasuni](https://github.com/tootsuite/mastodon/pull/16129))
- Change option labels on edit profile page ([Gargron](https://github.com/tootsuite/mastodon/pull/16041))
- "Lock account" is now "Require follow requests"
- "List this account on the directory" is now "Suggest account to others"
- "Hide your network" is now "Hide your social graph"
- Change newly generated account IDs to not be enumerable ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/15844))
- Change Web Push API deliveries to use request pooling ([Gargron](https://github.com/tootsuite/mastodon/pull/16014))
- Change multiple mentions with same username to render with domain ([Gargron](https://github.com/tootsuite/mastodon/pull/15718), [noellabo](https://github.com/tootsuite/mastodon/pull/16038))
- When a post contains mentions of two or more users who have the same username, but on different domains, render their names with domain to help disambiguate them
- Always render the domain of usernames used in profile metadata
- Change health check endpoint to reveal less information ([Gargron](https://github.com/tootsuite/mastodon/pull/15988))
- Change account counters to use upsert (requires Postgres >= 9.5) ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/15913))
- Change `mastodon:setup` to not call `assets:precompile` in Docker ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/13942))
- **Change max. image dimensions to 1920x1080px (1080p)** ([Gargron](https://github.com/tootsuite/mastodon/pull/15690))
- Previously, this was 1280x1280px
- This is the amount of pixels that original images get downsized to
- Change custom emoji to be animated when hovering container in web UI ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/15637))
- Change streaming API from deprecated ClusterWS/cws to ws ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/15932))
- Change systemd configuration to add sandboxing features ([Izorkin](https://github.com/tootsuite/mastodon/pull/15937), [Izorkin](https://github.com/tootsuite/mastodon/pull/16103), [Izorkin](https://github.com/tootsuite/mastodon/pull/16127))
- Change nginx configuration to make running Onion service easier ([cohosh](https://github.com/tootsuite/mastodon/pull/15498))
- Change Helm configuration ([dunn](https://github.com/tootsuite/mastodon/pull/15722), [dunn](https://github.com/tootsuite/mastodon/pull/15728), [dunn](https://github.com/tootsuite/mastodon/pull/15748), [dunn](https://github.com/tootsuite/mastodon/pull/15749), [dunn](https://github.com/tootsuite/mastodon/pull/15767))
- Change Docker configuration ([SuperSandro2000](https://github.com/tootsuite/mastodon/pull/10823), [mashirozx](https://github.com/tootsuite/mastodon/pull/15978))
### Removed
- Remove PubSubHubbub-related columns from accounts table ([Gargron](https://github.com/tootsuite/mastodon/pull/16170), [ClearlyClaire](https://github.com/tootsuite/mastodon/pull/15857))
- Remove dependency on @babel/plugin-proposal-class-properties ([ykzts](https://github.com/tootsuite/mastodon/pull/16155))
- Remove dependency on pluck_each gem ([Gargron](https://github.com/tootsuite/mastodon/pull/16012))
- Remove spam check and dependency on nilsimsa gem ([Gargron](https://github.com/tootsuite/mastodon/pull/16011))
- Remove MySQL-specific code from Mastodon::MigrationHelpers ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/15924))
- Remove IE11 from supported browsers target ([gol-cha](https://github.com/tootsuite/mastodon/pull/15779))
### Fixed
- Fix "You might be interested in" flashing while searching in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/16162))
- Fix display of posts without text content in web UI ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/15665))
- Fix Google Translate breaking web UI ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/15610), [ClearlyClaire](https://github.com/tootsuite/mastodon/pull/15611))
- Fix web UI crashing when SVG support is disabled ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/15809))
- Fix web UI crash when a status opened in the media modal is deleted ([kaias1jp](https://github.com/tootsuite/mastodon/pull/15701))
- Fix OCR language data failing to load in web UI ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/15519))
- Fix footer links not being clickable in Safari in web UI ([noellabo](https://github.com/tootsuite/mastodon/pull/15496))
- Fix autofocus/autoselection not working on mobile in web UI ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/15555), [ClearlyClaire](https://github.com/tootsuite/mastodon/pull/15985))
- Fix media redownload worker retrying on unexpected response codes ([Gargron](https://github.com/tootsuite/mastodon/pull/16111))
- Fix thread resolve worker retrying when status no longer exists ([Gargron](https://github.com/tootsuite/mastodon/pull/16109))
- Fix n+1 queries when rendering statuses in REST API ([abcang](https://github.com/tootsuite/mastodon/pull/15641))
- Fix n+1 queries when rendering notifications in REST API ([abcang](https://github.com/tootsuite/mastodon/pull/15640))
- Fix delete of local reply to local parent not being forwarded ([Gargron](https://github.com/tootsuite/mastodon/pull/16096))
- Fix remote reporters not receiving suspend/unsuspend activities ([Gargron](https://github.com/tootsuite/mastodon/pull/16050))
- Fix understanding (not fully qualified) `as:Public` and `Public` ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/15948))
- Fix actor update not being distributed on profile picture deletion ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/15461))
- Fix processing of incoming Delete activities ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/16084))
- Fix processing of incoming Block activities ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/15546))
- Fix processing of incoming Update activities of unknown accounts ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/15514))
- Fix URIs of repeat follow requests not being recorded ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/15662))
- Fix error on requests with no `Digest` header ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/15782))
- Fix activity object not requiring signature in secure mode ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/15592))
- Fix database serialization failure returning HTTP 500 ([Gargron](https://github.com/tootsuite/mastodon/pull/16101))
- Fix media processing getting stuck on too much stdin/stderr ([Gargron](https://github.com/tootsuite/mastodon/pull/16136))
- Fix some inefficient array manipulations ([007lva](https://github.com/tootsuite/mastodon/pull/15513), [007lva](https://github.com/tootsuite/mastodon/pull/15527))
- Fix some inefficient regex matching ([007lva](https://github.com/tootsuite/mastodon/pull/15528))
- Fix some inefficient SQL queries ([abcang](https://github.com/tootsuite/mastodon/pull/16104), [abcang](https://github.com/tootsuite/mastodon/pull/16106), [abcang](https://github.com/tootsuite/mastodon/pull/16105))
- Fix trying to fetch key from empty URI when verifying HTTP signature ([Gargron](https://github.com/tootsuite/mastodon/pull/16100))
- Fix `tootctl maintenance fix-duplicates` failures ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/15923), [ClearlyClaire](https://github.com/tootsuite/mastodon/pull/15515))
- Fix error when removing status caused by race condition ([Gargron](https://github.com/tootsuite/mastodon/pull/16099))
- Fix blocking someone not clearing up list feeds ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/16205))
- Fix misspelled URLs character counting ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/15382))
- Fix Sidekiq hanging forever due to a Resolv bug in Ruby 2.7.3 ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/16157))
- Fix edge case where follow limit interferes with accepting a follow ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/16098))
- Fix inconsistent lead text style in admin UI ([Gargron](https://github.com/tootsuite/mastodon/pull/16052), [ClearlyClaire](https://github.com/tootsuite/mastodon/pull/16086))
- Fix reports of already suspended accounts being recorded ([Gargron](https://github.com/tootsuite/mastodon/pull/16047))
- Fix sign-up restrictions based on IP addresses not being enforced ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/15607))
- Fix YouTube embeds failing due to YouTube serving wrong OEmbed URLs ([Gargron](https://github.com/tootsuite/mastodon/pull/15716))
- Fix error when rendering public pages with media without meta ([Gargron](https://github.com/tootsuite/mastodon/pull/16112))
- Fix misaligned logo on follow button on public pages ([noellabo](https://github.com/tootsuite/mastodon/pull/15458))
- Fix video modal not working on public pages ([noellabo](https://github.com/tootsuite/mastodon/pull/15469))
- Fix race conditions on account migration creation ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/15597))
- Fix not being able to change world filter expiration back to “Never” ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/15858))
- Fix `.env.vagrant` not setting `RAILS_ENV` variable ([chandrn7](https://github.com/tootsuite/mastodon/pull/15709))
- Fix error when muting users with `duration` in REST API ([Tak](https://github.com/tootsuite/mastodon/pull/15516))
- Fix border padding on front page in light theme ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/15926))
- Fix wrong URL to custom CSS when `CDN_HOST` is used ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/15927))
- Fix `tootctl accounts unfollow` ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/15639))
- Fix `tootctl emoji import` wasting time on MacOS shadow files ([cortices](https://github.com/tootsuite/mastodon/pull/15430))
- Fix `tootctl emoji import` not treating shortcodes as case-insensitive ([angristan](https://github.com/tootsuite/mastodon/pull/15738))
- Fix some issues with SAML account creation ([Gargron](https://github.com/tootsuite/mastodon/pull/15222), [kaiyou](https://github.com/tootsuite/mastodon/pull/15511))
- Fix MX validation applying for explicitly allowed e-mail domains ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/15930))
- Fix share page not using configured custom mascot ([tribela](https://github.com/tootsuite/mastodon/pull/15687))
- Fix instance actor not being automatically created if it wasn't seeded properly ([ClearlyClaire](https://github.com/tootsuite/mastodon/pull/15693))
- Fix HTTPS enforcement preventing Mastodon from being run as an Onion service ([cohosh](https://github.com/tootsuite/mastodon/pull/15560), [jtracey](https://github.com/tootsuite/mastodon/pull/15741), [ClearlyClaire](https://github.com/tootsuite/mastodon/pull/15712), [cohosh](https://github.com/tootsuite/mastodon/pull/15725))
- Fix app name, website and redirect URIs not having a maximum length ([Gargron](https://github.com/tootsuite/mastodon/pull/16042))
## [3.3.0] - 2020-12-27 ## [3.3.0] - 2020-12-27
### Added ### Added

@ -58,9 +58,17 @@ You can submit translations via [Crowdin](https://crowdin.com/project/mastodon).
## Pull requests ## Pull requests
Please use clean, concise titles for your pull requests. We use commit squashing, so the final commit in the master branch will carry the title of the pull request. **Please use clean, concise titles for your pull requests.** Unless the pull request is about refactoring code, updating dependencies or other internal tasks, assume that the person reading the pull request title is not a programmer or Mastodon developer, but instead a Mastodon user or server administrator, and **try to describe your change or fix from their perspective**. We use commit squashing, so the final commit in the main branch will carry the title of the pull request, and commits from the main branch are fed into the changelog. The changelog is separated into [keepachangelog.com categories](https://keepachangelog.com/en/1.0.0/), and while that spec does not prescribe how the entries ought to be named, for easier sorting, start your pull request titles using one of the verbs "Add", "Change", "Deprecate", "Remove", or "Fix" (present tense).
The smaller the set of changes in the pull request is, the quicker it can be reviewed and merged. Splitting tasks into multiple smaller pull requests is often preferable. Example:
|Not ideal|Better|
|---|----|
|Fixed NoMethodError in RemovalWorker|Fix nil error when removing statuses caused by race condition|
It is not always possible to phrase every change in such a manner, but it is desired.
**The smaller the set of changes in the pull request is, the quicker it can be reviewed and merged.** Splitting tasks into multiple smaller pull requests is often preferable.
**Pull requests that do not pass automated checks may not be reviewed**. In particular, you need to keep in mind: **Pull requests that do not pass automated checks may not be reviewed**. In particular, you need to keep in mind:

@ -1,7 +1,7 @@
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 ["/usr/bin/bash", "-c"] SHELL ["/bin/bash", "-c"]
# Install Node v12 (LTS) # Install Node v12 (LTS)
ENV NODE_VER="12.21.0" ENV NODE_VER="12.21.0"
@ -17,35 +17,19 @@ RUN ARCH= && \
*) echo "unsupported architecture"; exit 1 ;; \ *) echo "unsupported architecture"; exit 1 ;; \
esac && \ esac && \
echo "Etc/UTC" > /etc/localtime && \ echo "Etc/UTC" > /etc/localtime && \
apt update && \ apt-get update && \
apt -y install wget python && \ apt-get install -y --no-install-recommends ca-certificates wget python && \
cd ~ && \ cd ~ && \
wget https://nodejs.org/download/release/v$NODE_VER/node-v$NODE_VER-linux-$ARCH.tar.gz && \ wget -q https://nodejs.org/download/release/v$NODE_VER/node-v$NODE_VER-linux-$ARCH.tar.gz && \
tar xf node-v$NODE_VER-linux-$ARCH.tar.gz && \ tar xf node-v$NODE_VER-linux-$ARCH.tar.gz && \
rm node-v$NODE_VER-linux-$ARCH.tar.gz && \ rm node-v$NODE_VER-linux-$ARCH.tar.gz && \
mv node-v$NODE_VER-linux-$ARCH /opt/node mv node-v$NODE_VER-linux-$ARCH /opt/node
# Install jemalloc
ENV JE_VER="5.2.1"
RUN apt update && \
apt -y install make autoconf gcc g++ && \
cd ~ && \
wget https://github.com/jemalloc/jemalloc/archive/$JE_VER.tar.gz && \
tar xf $JE_VER.tar.gz && \
cd jemalloc-$JE_VER && \
./autogen.sh && \
./configure --prefix=/opt/jemalloc && \
make -j$(nproc) > /dev/null && \
make install_bin install_include install_lib && \
cd .. && rm -rf jemalloc-$JE_VER $JE_VER.tar.gz
# Install Ruby # Install Ruby
ENV RUBY_VER="2.7.2" ENV RUBY_VER="2.7.2"
ENV CPPFLAGS="-I/opt/jemalloc/include" RUN apt-get update && \
ENV LDFLAGS="-L/opt/jemalloc/lib/" apt-get install -y --no-install-recommends build-essential \
RUN apt update && \ bison libyaml-dev libgdbm-dev libreadline-dev libjemalloc-dev \
apt -y install build-essential \
bison libyaml-dev libgdbm-dev libreadline-dev \
libncurses5-dev libffi-dev zlib1g-dev libssl-dev && \ libncurses5-dev libffi-dev zlib1g-dev libssl-dev && \
cd ~ && \ cd ~ && \
wget https://cache.ruby-lang.org/pub/ruby/${RUBY_VER%.*}/ruby-$RUBY_VER.tar.gz && \ wget https://cache.ruby-lang.org/pub/ruby/${RUBY_VER%.*}/ruby-$RUBY_VER.tar.gz && \
@ -55,25 +39,24 @@ RUN apt update && \
--with-jemalloc \ --with-jemalloc \
--with-shared \ --with-shared \
--disable-install-doc && \ --disable-install-doc && \
ln -s /opt/jemalloc/lib/* /usr/lib/ && \ make -j"$(nproc)" > /dev/null && \
make -j$(nproc) > /dev/null && \
make install && \ make install && \
cd .. && rm -rf ruby-$RUBY_VER.tar.gz ruby-$RUBY_VER rm -rf ../ruby-$RUBY_VER.tar.gz ../ruby-$RUBY_VER
ENV PATH="${PATH}:/opt/ruby/bin:/opt/node/bin" ENV PATH="${PATH}:/opt/ruby/bin:/opt/node/bin"
RUN npm install -g yarn && \ RUN npm install -g yarn && \
gem install bundler && \ gem install bundler && \
apt update && \ apt-get update && \
apt -y install git libicu-dev libidn11-dev \ apt-get install -y --no-install-recommends git libicu-dev libidn11-dev \
libpq-dev libprotobuf-dev protobuf-compiler libpq-dev libprotobuf-dev protobuf-compiler shared-mime-info
COPY Gemfile* package.json yarn.lock /opt/mastodon/ COPY Gemfile* package.json yarn.lock /opt/mastodon/
RUN cd /opt/mastodon && \ RUN cd /opt/mastodon && \
bundle config set deployment 'true' && \ bundle config set deployment 'true' && \
bundle config set without 'development test' && \ bundle config set without 'development test' && \
bundle install -j$(nproc) && \ bundle install -j"$(nproc)" && \
yarn install --pure-lockfile yarn install --pure-lockfile
FROM ubuntu:20.04 FROM ubuntu:20.04
@ -81,7 +64,6 @@ FROM ubuntu:20.04
# Copy over all the langs needed for runtime # Copy over all the langs needed for runtime
COPY --from=build-dep /opt/node /opt/node COPY --from=build-dep /opt/node /opt/node
COPY --from=build-dep /opt/ruby /opt/ruby COPY --from=build-dep /opt/ruby /opt/ruby
COPY --from=build-dep /opt/jemalloc /opt/jemalloc
# Add more PATHs to the PATH # Add more PATHs to the PATH
ENV PATH="${PATH}:/opt/ruby/bin:/opt/node/bin:/opt/mastodon/bin" ENV PATH="${PATH}:/opt/ruby/bin:/opt/node/bin:/opt/mastodon/bin"
@ -89,35 +71,26 @@ ENV PATH="${PATH}:/opt/ruby/bin:/opt/node/bin:/opt/mastodon/bin"
# Create the mastodon user # Create the mastodon user
ARG UID=991 ARG UID=991
ARG GID=991 ARG GID=991
RUN apt update && \ SHELL ["/bin/bash", "-o", "pipefail", "-c"]
RUN apt-get update && \
echo "Etc/UTC" > /etc/localtime && \ echo "Etc/UTC" > /etc/localtime && \
ln -s /opt/jemalloc/lib/* /usr/lib/ && \ apt-get install -y --no-install-recommends whois wget && \
apt install -y whois wget && \
addgroup --gid $GID mastodon && \ addgroup --gid $GID mastodon && \
useradd -m -u $UID -g $GID -d /opt/mastodon mastodon && \ useradd -m -u $UID -g $GID -d /opt/mastodon mastodon && \
echo "mastodon:`head /dev/urandom | tr -dc A-Za-z0-9 | head -c 24 | mkpasswd -s -m sha-256`" | chpasswd echo "mastodon:$(head /dev/urandom | tr -dc A-Za-z0-9 | head -c 24 | mkpasswd -s -m sha-256)" | chpasswd && \
rm -rf /var/lib/apt/lists/*
# Install mastodon runtime deps # Install mastodon runtime deps
RUN apt -y --no-install-recommends install \ RUN apt-get update && \
libssl1.1 libpq5 imagemagick ffmpeg \ apt-get -y --no-install-recommends install \
libssl1.1 libpq5 imagemagick ffmpeg libjemalloc2 \
libicu66 libprotobuf17 libidn11 libyaml-0-2 \ libicu66 libprotobuf17 libidn11 libyaml-0-2 \
file ca-certificates tzdata libreadline8 && \ file ca-certificates tzdata libreadline8 gcc tini && \
apt -y install gcc && \
ln -s /opt/mastodon /mastodon && \ ln -s /opt/mastodon /mastodon && \
gem install bundler && \ gem install bundler && \
rm -rf /var/cache && \ rm -rf /var/cache && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
# Add tini
ENV TINI_VERSION="0.19.0"
RUN dpkgArch="$(dpkg --print-architecture)" && \
ARCH=$dpkgArch && \
wget https://github.com/krallin/tini/releases/download/v${TINI_VERSION}/tini-$ARCH \
https://github.com/krallin/tini/releases/download/v${TINI_VERSION}/tini-$ARCH.sha256sum && \
cat tini-$ARCH.sha256sum | sha256sum -c - && \
mv tini-$ARCH /tini && rm tini-$ARCH.sha256sum && \
chmod +x /tini
# Copy over mastodon source, and dependencies from building, and set permissions # Copy over mastodon source, and dependencies from building, and set permissions
COPY --chown=mastodon:mastodon . /opt/mastodon COPY --chown=mastodon:mastodon . /opt/mastodon
COPY --from=build-dep --chown=mastodon:mastodon /opt/mastodon /opt/mastodon COPY --from=build-dep --chown=mastodon:mastodon /opt/mastodon /opt/mastodon
@ -140,5 +113,5 @@ RUN cd ~ && \
# Set the work dir and the container entry point # Set the work dir and the container entry point
WORKDIR /opt/mastodon WORKDIR /opt/mastodon
ENTRYPOINT ["/tini", "--"] ENTRYPOINT ["/usr/bin/tini", "--"]
EXPOSE 3000 4000 EXPOSE 3000 4000

@ -1,12 +1,12 @@
# frozen_string_literal: true # frozen_string_literal: true
source 'https://rubygems.org' source 'https://rubygems.org'
ruby '>= 2.5.0', '< 3.0.0' ruby '>= 2.5.0', '< 3.1.0'
gem 'pkg-config', '~> 1.4' gem 'pkg-config', '~> 1.4'
gem 'puma', '~> 5.2' gem 'puma', '~> 5.3'
gem 'rails', '~> 5.2.4.5' gem 'rails', '~> 6.1.3'
gem 'sprockets', '~> 3.7.2' gem 'sprockets', '~> 3.7.2'
gem 'thor', '~> 1.1' gem 'thor', '~> 1.1'
gem 'rack', '~> 2.2.3' gem 'rack', '~> 2.2.3'
@ -17,12 +17,10 @@ gem 'makara', '~> 0.5'
gem 'pghero', '~> 2.8' gem 'pghero', '~> 2.8'
gem 'dotenv-rails', '~> 2.7' gem 'dotenv-rails', '~> 2.7'
gem 'aws-sdk-s3', '~> 1.91', require: false gem 'aws-sdk-s3', '~> 1.96', 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'
gem 'paperclip-av-transcoder', '~> 0.6'
gem 'streamio-ffmpeg', '~> 3.0'
gem 'blurhash', '~> 0.1' gem 'blurhash', '~> 0.1'
gem 'active_model_serializers', '~> 0.10' gem 'active_model_serializers', '~> 0.10'
@ -32,9 +30,9 @@ gem 'browser'
gem 'charlock_holmes', '~> 0.7.7' gem 'charlock_holmes', '~> 0.7.7'
gem 'iso-639' gem 'iso-639'
gem 'chewy', '~> 5.2' gem 'chewy', '~> 5.2'
gem 'cld3', '~> 3.4.1' gem 'cld3', '~> 3.4.2'
gem 'devise', '~> 4.7' gem 'devise', '~> 4.8'
gem 'devise-two-factor', '~> 3.1' gem 'devise-two-factor', '~> 4.0'
group :pam_authentication, optional: true do group :pam_authentication, optional: true do
gem 'devise_pam_authenticatable2', '~> 9.2' gem 'devise_pam_authenticatable2', '~> 9.2'
@ -54,16 +52,14 @@ gem 'fast_blank', '~> 1.0'
gem 'fastimage' gem 'fastimage'
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 'htmlentities', '~> 4.3' gem 'htmlentities', '~> 4.3'
gem 'http', '~> 4.4' gem 'http', '~> 4.4'
gem 'http_accept_language', '~> 2.1' gem 'http_accept_language', '~> 2.1'
gem 'httplog', '~> 1.4.3' gem 'httplog', '~> 1.5.0'
gem 'idn-ruby', require: 'idn' gem 'idn-ruby', require: 'idn'
gem 'kaminari', '~> 1.2' 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 'nokogiri', '~> 1.11' gem 'nokogiri', '~> 1.11'
gem 'nsa', '~> 0.2' gem 'nsa', '~> 0.2'
gem 'oj', '~> 3.11' gem 'oj', '~> 3.11'
@ -75,19 +71,19 @@ gem 'pundit', '~> 2.1'
gem 'premailer-rails' gem 'premailer-rails'
gem 'rack-attack', '~> 6.5' gem 'rack-attack', '~> 6.5'
gem 'rack-cors', '~> 1.1', require: 'rack/cors' gem 'rack-cors', '~> 1.1', require: 'rack/cors'
gem 'rails-i18n', '~> 5.1' gem 'rails-i18n', '~> 6.0'
gem 'rails-settings-cached', '~> 0.6' gem 'rails-settings-cached', '~> 0.6'
gem 'redis', '~> 4.2', require: ['redis', 'redis/connection/hiredis'] gem 'redis', '~> 4.3', 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.2' gem 'rqrcode', '~> 2.0'
gem 'ruby-progressbar', '~> 1.11' gem 'ruby-progressbar', '~> 1.11'
gem 'sanitize', '~> 5.2' gem 'sanitize', '~> 5.2'
gem 'scenic', '~> 1.5' gem 'scenic', '~> 1.5'
gem 'sidekiq', '~> 6.1' gem 'sidekiq', '~> 6.2'
gem 'sidekiq-scheduler', '~> 3.0' gem 'sidekiq-scheduler', '~> 3.1'
gem 'sidekiq-unique-jobs', '~> 7.0' gem 'sidekiq-unique-jobs', '~> 7.0'
gem 'sidekiq-bulk', '~>0.2.0' gem 'sidekiq-bulk', '~>0.2.0'
gem 'simple-navigation', '~> 4.1' gem 'simple-navigation', '~> 4.3'
gem 'simple_form', '~> 5.1' gem 'simple_form', '~> 5.1'
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'
@ -95,8 +91,8 @@ gem 'strong_migrations', '~> 0.7'
gem 'tty-prompt', '~> 0.23', require: false gem 'tty-prompt', '~> 0.23', require: false
gem 'twitter-text', '~> 3.1.0' gem 'twitter-text', '~> 3.1.0'
gem 'tzinfo-data', '~> 1.2021' gem 'tzinfo-data', '~> 1.2021'
gem 'webpacker', '~> 5.2' gem 'webpacker', '~> 5.4'
gem 'webpush' gem 'webpush', '~> 0.3'
gem 'webauthn', '~> 3.0.0.alpha1' gem 'webauthn', '~> 3.0.0.alpha1'
gem 'json-ld' gem 'json-ld'
@ -106,12 +102,12 @@ gem 'rdf-normalize', '~> 0.4'
gem 'redcarpet', '~> 3.5' gem 'redcarpet', '~> 3.5'
group :development, :test do group :development, :test do
gem 'fabrication', '~> 2.21' gem 'fabrication', '~> 2.22'
gem 'fuubar', '~> 2.5' gem 'fuubar', '~> 2.5'
gem 'i18n-tasks', '~> 0.9', require: false gem 'i18n-tasks', '~> 0.9', require: false
gem 'pry-byebug', '~> 3.9' gem 'pry-byebug', '~> 3.9'
gem 'pry-rails', '~> 0.3' gem 'pry-rails', '~> 0.3'
gem 'rspec-rails', '~> 4.1' gem 'rspec-rails', '~> 5.0'
end end
group :production, :test do group :production, :test do
@ -121,13 +117,13 @@ end
group :test do group :test do
gem 'capybara', '~> 3.35' gem 'capybara', '~> 3.35'
gem 'climate_control', '~> 0.2' gem 'climate_control', '~> 0.2'
gem 'faker', '~> 2.17' gem 'faker', '~> 2.18'
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.21', require: false gem 'simplecov', '~> 0.21', require: false
gem 'webmock', '~> 3.12' gem 'webmock', '~> 3.13'
gem 'parallel_tests', '~> 3.5' gem 'parallel_tests', '~> 3.7'
gem 'rspec_junit_formatter', '~> 0.4' gem 'rspec_junit_formatter', '~> 0.4'
end end
@ -140,10 +136,10 @@ group :development do
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', '~> 1.11', require: false gem 'rubocop', '~> 1.16', require: false
gem 'rubocop-rails', '~> 2.9', require: false gem 'rubocop-rails', '~> 2.10', require: false
gem 'brakeman', '~> 4.10', require: false gem 'brakeman', '~> 5.0', require: false
gem 'bundler-audit', '~> 0.7', require: false gem 'bundler-audit', '~> 0.8', require: false
gem 'capistrano', '~> 3.16' gem 'capistrano', '~> 3.16'
gem 'capistrano-rails', '~> 1.6' gem 'capistrano-rails', '~> 1.6'
@ -155,11 +151,11 @@ end
group :production do group :production do
gem 'lograge', '~> 0.11' gem 'lograge', '~> 0.11'
gem 'redis-rails', '~> 5.0'
end 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 'xorcist', '~> 1.1'
gem 'pluck_each', '~> 0.1.3'
gem 'resolv', '~> 0.1.0'

@ -1,68 +1,71 @@
GIT
remote: https://github.com/ianheggie/health_check
revision: 0b799ead604f900ed50685e9b2d469cd2befba5b
ref: 0b799ead604f900ed50685e9b2d469cd2befba5b
specs:
health_check (4.0.0.pre)
rails (>= 4.0)
GIT
remote: https://github.com/witgo/nilsimsa
revision: fd184883048b922b176939f851338d0a4971a532
ref: fd184883048b922b176939f851338d0a4971a532
specs:
nilsimsa (1.1.2)
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actioncable (5.2.4.5) actioncable (6.1.3.2)
actionpack (= 5.2.4.5) actionpack (= 6.1.3.2)
activesupport (= 6.1.3.2)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
actionmailer (5.2.4.5) actionmailbox (6.1.3.2)
actionpack (= 5.2.4.5) actionpack (= 6.1.3.2)
actionview (= 5.2.4.5) activejob (= 6.1.3.2)
activejob (= 5.2.4.5) activerecord (= 6.1.3.2)
activestorage (= 6.1.3.2)
activesupport (= 6.1.3.2)
mail (>= 2.7.1)
actionmailer (6.1.3.2)
actionpack (= 6.1.3.2)
actionview (= 6.1.3.2)
activejob (= 6.1.3.2)
activesupport (= 6.1.3.2)
mail (~> 2.5, >= 2.5.4) mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
actionpack (5.2.4.5) actionpack (6.1.3.2)
actionview (= 5.2.4.5) actionview (= 6.1.3.2)
activesupport (= 5.2.4.5) activesupport (= 6.1.3.2)
rack (~> 2.0, >= 2.0.8) rack (~> 2.0, >= 2.0.9)
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.0.2) rails-html-sanitizer (~> 1.0, >= 1.2.0)
actionview (5.2.4.5) actiontext (6.1.3.2)
activesupport (= 5.2.4.5) actionpack (= 6.1.3.2)
activerecord (= 6.1.3.2)
activestorage (= 6.1.3.2)
activesupport (= 6.1.3.2)
nokogiri (>= 1.8.5)
actionview (6.1.3.2)
activesupport (= 6.1.3.2)
builder (~> 3.1) builder (~> 3.1)
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.1, >= 1.2.0)
active_model_serializers (0.10.12) active_model_serializers (0.10.12)
actionpack (>= 4.1, < 6.2) actionpack (>= 4.1, < 6.2)
activemodel (>= 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.8) active_record_query_trace (1.8)
activejob (5.2.4.5) activejob (6.1.3.2)
activesupport (= 5.2.4.5) activesupport (= 6.1.3.2)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (5.2.4.5) activemodel (6.1.3.2)
activesupport (= 5.2.4.5) activesupport (= 6.1.3.2)
activerecord (5.2.4.5) activerecord (6.1.3.2)
activemodel (= 5.2.4.5) activemodel (= 6.1.3.2)
activesupport (= 5.2.4.5) activesupport (= 6.1.3.2)
arel (>= 9.0) activestorage (6.1.3.2)
activestorage (5.2.4.5) actionpack (= 6.1.3.2)
actionpack (= 5.2.4.5) activejob (= 6.1.3.2)
activerecord (= 5.2.4.5) activerecord (= 6.1.3.2)
marcel (~> 0.3.1) activesupport (= 6.1.3.2)
activesupport (5.2.4.5) marcel (~> 1.0.0)
mini_mime (~> 1.0.2)
activesupport (6.1.3.2)
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 0.7, < 2) i18n (>= 1.6, < 2)
minitest (~> 5.1) minitest (>= 5.1)
tzinfo (~> 1.1) tzinfo (~> 2.0)
zeitwerk (~> 2.3)
addressable (2.7.0) addressable (2.7.0)
public_suffix (>= 2.0.2, < 5.0) public_suffix (>= 2.0.2, < 5.0)
airbrussh (1.4.0) airbrussh (1.4.0)
@ -71,16 +74,13 @@ GEM
annotate (3.1.1) annotate (3.1.1)
activerecord (>= 3.2, < 7.0) activerecord (>= 3.2, < 7.0)
rake (>= 10.4, < 14.0) rake (>= 10.4, < 14.0)
arel (9.0.0)
ast (2.4.2) ast (2.4.2)
attr_encrypted (3.1.0) attr_encrypted (3.1.0)
encryptor (~> 3.0.0) encryptor (~> 3.0.0)
av (0.9.0)
cocaine (~> 0.5.3)
awrence (1.1.1) awrence (1.1.1)
aws-eventstream (1.1.1) aws-eventstream (1.1.1)
aws-partitions (1.432.0) aws-partitions (1.467.0)
aws-sdk-core (3.113.0) aws-sdk-core (3.114.2)
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)
@ -88,7 +88,7 @@ GEM
aws-sdk-kms (1.43.0) aws-sdk-kms (1.43.0)
aws-sdk-core (~> 3, >= 3.112.0) aws-sdk-core (~> 3, >= 3.112.0)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.91.0) aws-sdk-s3 (1.96.1)
aws-sdk-core (~> 3, >= 3.112.0) aws-sdk-core (~> 3, >= 3.112.0)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.1)
@ -102,22 +102,22 @@ GEM
bindata (2.4.8) bindata (2.4.8)
binding_of_caller (1.0.0) binding_of_caller (1.0.0)
debug_inspector (>= 0.0.1) debug_inspector (>= 0.0.1)
blurhash (0.1.4) blurhash (0.1.5)
ffi (~> 1.10.0) ffi (~> 1.14)
bootsnap (1.6.0) bootsnap (1.6.0)
msgpack (~> 1.0) msgpack (~> 1.0)
brakeman (4.10.1) brakeman (5.0.4)
browser (4.2.0) browser (4.2.0)
brpoplpush-redis_script (0.1.1) brpoplpush-redis_script (0.1.2)
concurrent-ruby (~> 1.0, >= 1.0.5) concurrent-ruby (~> 1.0, >= 1.0.5)
redis (>= 1.0, <= 5.0) redis (>= 1.0, <= 5.0)
builder (3.2.4) builder (3.2.4)
bullet (6.1.4) bullet (6.1.4)
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.8.0)
bundler (>= 1.2.0, < 3) bundler (>= 1.2.0, < 3)
thor (>= 0.18, < 2) thor (~> 1.0)
byebug (11.1.3) byebug (11.1.3)
capistrano (3.16.0) capistrano (3.16.0)
airbrussh (>= 1.0.0) airbrussh (>= 1.0.0)
@ -150,16 +150,14 @@ GEM
activesupport (>= 5.2) activesupport (>= 5.2)
elasticsearch (>= 2.0.0) elasticsearch (>= 2.0.0)
elasticsearch-dsl elasticsearch-dsl
chunky_png (1.3.15) chunky_png (1.4.0)
cld3 (3.4.1) cld3 (3.4.2)
ffi (>= 1.1.0, < 1.15.0) ffi (>= 1.1.0, < 1.16.0)
climate_control (0.2.0) climate_control (0.2.0)
cocaine (0.5.8)
climate_control (>= 0.0.3, < 1.0)
coderay (1.1.3) coderay (1.1.3)
color_diff (0.1) color_diff (0.1)
concurrent-ruby (1.1.8) concurrent-ruby (1.1.9)
connection_pool (2.2.3) connection_pool (2.2.5)
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)
@ -169,18 +167,18 @@ GEM
css_parser (1.7.1) css_parser (1.7.1)
addressable addressable
debug_inspector (1.0.0) debug_inspector (1.0.0)
devise (4.7.3) devise (4.8.0)
bcrypt (~> 3.0) bcrypt (~> 3.0)
orm_adapter (~> 0.1) orm_adapter (~> 0.1)
railties (>= 4.1.0) railties (>= 4.1.0)
responders responders
warden (~> 1.2.3) warden (~> 1.2.3)
devise-two-factor (3.1.0) devise-two-factor (4.0.0)
activesupport (< 6.1) activesupport (< 6.2)
attr_encrypted (>= 1.3, < 4, != 2) attr_encrypted (>= 1.3, < 4, != 2)
devise (~> 4.0) devise (~> 4.0)
railties (< 6.1) railties (< 6.2)
rotp (~> 2.0) rotp (~> 6.0)
devise_pam_authenticatable2 (9.2.0) devise_pam_authenticatable2 (9.2.0)
devise (>= 4.0.0) devise (>= 4.0.0)
rpam2 (~> 4.0) rpam2 (~> 4.0)
@ -190,7 +188,7 @@ GEM
docile (1.3.4) 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.5.0) doorkeeper (5.5.2)
railties (>= 5) railties (>= 5)
dotenv (2.7.6) dotenv (2.7.6)
dotenv-rails (2.7.6) dotenv-rails (2.7.6)
@ -212,8 +210,8 @@ GEM
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.22.0)
faker (2.17.0) faker (2.18.0)
i18n (>= 1.6, < 2) i18n (>= 1.6, < 2)
faraday (1.3.0) faraday (1.3.0)
faraday-net_http (~> 1.0) faraday-net_http (~> 1.0)
@ -221,8 +219,8 @@ GEM
ruby2_keywords ruby2_keywords
faraday-net_http (1.0.1) faraday-net_http (1.0.1)
fast_blank (1.0.0) fast_blank (1.0.0)
fastimage (2.2.3) fastimage (2.2.4)
ffi (1.10.0) ffi (1.15.0)
ffi-compiler (1.0.1) ffi-compiler (1.0.1)
ffi (>= 1.0.0) ffi (>= 1.0.0)
rake rake
@ -239,9 +237,9 @@ GEM
fog-json (>= 1.0) fog-json (>= 1.0)
ipaddress (>= 0.8) ipaddress (>= 0.8)
formatador (0.2.5) formatador (0.2.5)
fugit (1.3.9) fugit (1.4.5)
et-orbi (~> 1.1, >= 1.1.8) et-orbi (~> 1.1, >= 1.1.8)
raabro (~> 1.3) raabro (~> 1.4)
fuubar (2.5.1) fuubar (2.5.1)
rspec-core (~> 3.0) rspec-core (~> 3.0)
ruby-progressbar (~> 1.4) ruby-progressbar (~> 1.4)
@ -275,10 +273,10 @@ GEM
http-parser (1.2.1) http-parser (1.2.1)
ffi-compiler (>= 1.0, < 2.0) ffi-compiler (>= 1.0, < 2.0)
http_accept_language (2.1.1) http_accept_language (2.1.1)
httplog (1.4.3) httplog (1.5.0)
rack (>= 1.0) rack (>= 1.0)
rainbow (>= 2.0.0) rainbow (>= 2.0.0)
i18n (1.8.9) i18n (1.8.10)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
i18n-tasks (0.9.34) i18n-tasks (0.9.34)
activesupport (>= 4.0.2) activesupport (>= 4.0.2)
@ -290,11 +288,11 @@ GEM
rails-i18n rails-i18n
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
terminal-table (>= 1.5.1) terminal-table (>= 1.5.1)
idn-ruby (0.1.0) idn-ruby (0.1.2)
ipaddress (0.8.3) ipaddress (0.8.3)
iso-639 (0.3.5) iso-639 (0.3.5)
jmespath (1.4.0) jmespath (1.4.0)
json (2.3.1) json (2.5.1)
json-canonicalization (0.2.1) json-canonicalization (0.2.1)
json-ld (3.1.9) json-ld (3.1.9)
htmlentities (~> 4.3) htmlentities (~> 4.3)
@ -334,28 +332,29 @@ GEM
activesupport (>= 4) activesupport (>= 4)
railties (>= 4) railties (>= 4)
request_store (~> 1.0) request_store (~> 1.0)
loofah (2.9.0) loofah (2.10.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)
mini_mime (>= 0.1.1) mini_mime (>= 0.1.1)
makara (0.5.0) makara (0.5.1)
activerecord (>= 3.0.0) activerecord (>= 5.2.0)
marcel (0.3.3) marcel (1.0.1)
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 (1.0.0) memory_profiler (1.0.0)
method_source (1.0.0) method_source (1.0.0)
microformats (4.2.1) microformats (4.3.1)
json (~> 2.2) json (~> 2.2)
nokogiri (~> 1.10) nokogiri (~> 1.10)
mime-types (3.3.1) mime-types (3.3.1)
mime-types-data (~> 3.2015) mime-types-data (~> 3.2015)
mime-types-data (3.2020.0512) mime-types-data (3.2020.0512)
mimemagic (0.3.5) mimemagic (0.3.10)
mini_mime (1.0.2) nokogiri (~> 1)
mini_portile2 (2.5.0) rake
mini_mime (1.0.3)
mini_portile2 (2.5.3)
minitest (5.14.4) minitest (5.14.4)
msgpack (1.4.2) msgpack (1.4.2)
multi_json (1.15.0) multi_json (1.15.0)
@ -365,17 +364,17 @@ GEM
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.7) nio4r (2.5.7)
nokogiri (1.11.2) nokogiri (1.11.7)
mini_portile2 (~> 2.5.0) mini_portile2 (~> 2.5.0)
racc (~> 1.4) racc (~> 1.4)
nokogumbo (2.0.4) nokogumbo (2.0.4)
nokogiri (~> 1.8, >= 1.8.4) nokogiri (~> 1.8, >= 1.8.4)
nsa (0.2.7) nsa (0.2.8)
activesupport (>= 4.2, < 6) activesupport (>= 4.2, < 7)
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.11.3) oj (3.11.5)
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)
@ -392,31 +391,25 @@ GEM
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.14.3) ox (2.14.5)
paperclip (6.0.0) paperclip (6.0.0)
activemodel (>= 4.2.0) activemodel (>= 4.2.0)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
mime-types mime-types
mimemagic (~> 0.3.0) mimemagic (~> 0.3.0)
terrapin (~> 0.6.0) terrapin (~> 0.6.0)
paperclip-av-transcoder (0.6.4)
av (~> 0.9.0)
paperclip (>= 2.5.2)
parallel (1.20.1) parallel (1.20.1)
parallel_tests (3.5.2) parallel_tests (3.7.0)
parallel parallel
parser (3.0.0.0) parser (3.0.1.1)
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.8.0) pghero (2.8.1)
activerecord (>= 5) activerecord (>= 5)
pkg-config (1.4.5) pkg-config (1.4.6)
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.14.2) premailer (1.14.2)
addressable addressable
@ -435,11 +428,11 @@ 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 (5.2.2) puma (5.3.2)
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.3) raabro (1.4.0)
racc (1.5.2) racc (1.5.2)
rack (2.2.3) rack (2.2.3)
rack-attack (6.5.0) rack-attack (6.5.0)
@ -450,18 +443,20 @@ GEM
rack rack
rack-test (1.1.0) rack-test (1.1.0)
rack (>= 1.0, < 3) rack (>= 1.0, < 3)
rails (5.2.4.5) rails (6.1.3.2)
actioncable (= 5.2.4.5) actioncable (= 6.1.3.2)
actionmailer (= 5.2.4.5) actionmailbox (= 6.1.3.2)
actionpack (= 5.2.4.5) actionmailer (= 6.1.3.2)
actionview (= 5.2.4.5) actionpack (= 6.1.3.2)
activejob (= 5.2.4.5) actiontext (= 6.1.3.2)
activemodel (= 5.2.4.5) actionview (= 6.1.3.2)
activerecord (= 5.2.4.5) activejob (= 6.1.3.2)
activestorage (= 5.2.4.5) activemodel (= 6.1.3.2)
activesupport (= 5.2.4.5) activerecord (= 6.1.3.2)
bundler (>= 1.3.0) activestorage (= 6.1.3.2)
railties (= 5.2.4.5) activesupport (= 6.1.3.2)
bundler (>= 1.15.0)
railties (= 6.1.3.2)
sprockets-rails (>= 2.0.0) sprockets-rails (>= 2.0.0)
rails-controller-testing (1.0.5) rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1) actionpack (>= 5.0.1.rc1)
@ -472,17 +467,17 @@ GEM
nokogiri (>= 1.6) nokogiri (>= 1.6)
rails-html-sanitizer (1.3.0) rails-html-sanitizer (1.3.0)
loofah (~> 2.3) loofah (~> 2.3)
rails-i18n (5.1.3) rails-i18n (6.0.0)
i18n (>= 0.7, < 2) i18n (>= 0.7, < 2)
railties (>= 5.0, < 6) railties (>= 6.0.0, < 7)
rails-settings-cached (0.6.6) rails-settings-cached (0.6.6)
rails (>= 4.2.0) rails (>= 4.2.0)
railties (5.2.4.5) railties (6.1.3.2)
actionpack (= 5.2.4.5) actionpack (= 6.1.3.2)
activesupport (= 5.2.4.5) activesupport (= 6.1.3.2)
method_source method_source
rake (>= 0.8.7) rake (>= 0.8.7)
thor (>= 0.19.0, < 2.0) thor (~> 1.0)
rainbow (3.0.0) rainbow (3.0.0)
rake (13.0.3) rake (13.0.3)
rdf (3.1.13) rdf (3.1.13)
@ -491,38 +486,23 @@ GEM
rdf-normalize (0.4.0) rdf-normalize (0.4.0)
rdf (~> 3.1) rdf (~> 3.1)
redcarpet (3.5.1) redcarpet (3.5.1)
redis (4.2.5) redis (4.3.1)
redis-actionpack (5.2.0)
actionpack (>= 5, < 7)
redis-rack (>= 2.1.0, < 3)
redis-store (>= 1.1.0, < 2)
redis-activesupport (5.2.0)
activesupport (>= 3, < 7)
redis-store (>= 1.3, < 2)
redis-namespace (1.8.1) redis-namespace (1.8.1)
redis (>= 3.0.4) redis (>= 3.0.4)
redis-rack (2.1.3)
rack (>= 2.0.8, < 3)
redis-store (>= 1.2, < 2)
redis-rails (5.0.2)
redis-actionpack (>= 5.0, < 6)
redis-activesupport (>= 5.0, < 6)
redis-store (>= 1.2, < 2)
redis-store (1.9.0)
redis (>= 4, < 5)
regexp_parser (2.1.1) regexp_parser (2.1.1)
request_store (1.5.0) request_store (1.5.0)
rack (>= 1.4) rack (>= 1.4)
resolv (0.1.0)
responders (3.0.1) responders (3.0.1)
actionpack (>= 5.0) actionpack (>= 5.0)
railties (>= 5.0) railties (>= 5.0)
rexml (3.2.4) rexml (3.2.5)
rotp (2.1.2) rotp (6.2.0)
rpam2 (4.0.2) rpam2 (4.0.2)
rqrcode (1.2.0) rqrcode (2.0.0)
chunky_png (~> 1.0) chunky_png (~> 1.0)
rqrcode_core (~> 0.2) rqrcode_core (~> 1.0)
rqrcode_core (0.2.0) rqrcode_core (1.0.0)
rspec-core (3.10.1) rspec-core (3.10.1)
rspec-support (~> 3.10.0) rspec-support (~> 3.10.0)
rspec-expectations (3.10.1) rspec-expectations (3.10.1)
@ -531,10 +511,10 @@ GEM
rspec-mocks (3.10.2) rspec-mocks (3.10.2)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.10.0) rspec-support (~> 3.10.0)
rspec-rails (4.1.0) rspec-rails (5.0.1)
actionpack (>= 4.2) actionpack (>= 5.2)
activesupport (>= 4.2) activesupport (>= 5.2)
railties (>= 4.2) railties (>= 5.2)
rspec-core (~> 3.10) rspec-core (~> 3.10)
rspec-expectations (~> 3.10) rspec-expectations (~> 3.10)
rspec-mocks (~> 3.10) rspec-mocks (~> 3.10)
@ -545,26 +525,26 @@ GEM
rspec-support (3.10.2) rspec-support (3.10.2)
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 (1.11.0) rubocop (1.16.1)
parallel (~> 1.10) parallel (~> 1.10)
parser (>= 3.0.0.0) parser (>= 3.0.0.0)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0) regexp_parser (>= 1.8, < 3.0)
rexml rexml
rubocop-ast (>= 1.2.0, < 2.0) rubocop-ast (>= 1.7.0, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 3.0) unicode-display_width (>= 1.4.0, < 3.0)
rubocop-ast (1.4.1) rubocop-ast (1.7.0)
parser (>= 2.7.1.5) parser (>= 3.0.1.1)
rubocop-rails (2.9.1) rubocop-rails (2.10.1)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
rack (>= 1.1) rack (>= 1.1)
rubocop (>= 0.90.0, < 2.0) rubocop (>= 1.7.0, < 2.0)
ruby-progressbar (1.11.0) ruby-progressbar (1.11.0)
ruby-saml (1.11.0) ruby-saml (1.11.0)
nokogiri (>= 1.5.10) nokogiri (>= 1.5.10)
ruby2_keywords (0.0.4) ruby2_keywords (0.0.4)
rufus-scheduler (3.6.0) rufus-scheduler (3.7.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)
@ -576,26 +556,26 @@ GEM
activerecord (>= 4.0.0) activerecord (>= 4.0.0)
railties (>= 4.0.0) railties (>= 4.0.0)
securecompare (1.0.0) securecompare (1.0.0)
semantic_range (2.3.0) semantic_range (3.0.0)
sidekiq (6.1.3) sidekiq (6.2.1)
connection_pool (>= 2.2.2) connection_pool (>= 2.2.2)
rack (~> 2.0) rack (~> 2.0)
redis (>= 4.2.0) redis (>= 4.2.0)
sidekiq-bulk (0.2.0) sidekiq-bulk (0.2.0)
sidekiq sidekiq
sidekiq-scheduler (3.0.1) sidekiq-scheduler (3.1.0)
e2mmap e2mmap
redis (>= 3, < 5) redis (>= 3, < 5)
rufus-scheduler (~> 3.2) rufus-scheduler (~> 3.2)
sidekiq (>= 3) sidekiq (>= 3)
thwait thwait
tilt (>= 1.4.0) tilt (>= 1.4.0)
sidekiq-unique-jobs (7.0.4) sidekiq-unique-jobs (7.0.12)
brpoplpush-redis_script (> 0.0.0, <= 2.0.0) brpoplpush-redis_script (> 0.1.1, <= 2.0.0)
concurrent-ruby (~> 1.0, >= 1.0.5) concurrent-ruby (~> 1.0, >= 1.0.5)
sidekiq (>= 5.0, < 7.0) sidekiq (>= 5.0, < 7.0)
thor (>= 0.20, < 2.0) thor (>= 0.20, < 2.0)
simple-navigation (4.1.0) simple-navigation (4.3.0)
activesupport (>= 2.3.2) activesupport (>= 2.3.2)
simple_form (5.1.0) simple_form (5.1.0)
actionpack (>= 5.2) actionpack (>= 5.2)
@ -616,12 +596,10 @@ GEM
sshkit (1.21.2) sshkit (1.21.2)
net-scp (>= 1.1.2) net-scp (>= 1.1.2)
net-ssh (>= 2.8.0) net-ssh (>= 2.8.0)
stackprof (0.2.16) stackprof (0.2.17)
statsd-ruby (1.4.0) statsd-ruby (1.5.0)
stoplight (2.2.1) stoplight (2.2.1)
streamio-ffmpeg (3.0.2) strong_migrations (0.7.7)
multi_json (~> 1.8)
strong_migrations (0.7.6)
activerecord (>= 5) activerecord (>= 5)
temple (0.8.2) temple (0.8.2)
terminal-table (3.0.0) terminal-table (3.0.0)
@ -629,7 +607,6 @@ GEM
terrapin (0.6.0) terrapin (0.6.0)
climate_control (>= 0.0.3, < 1.0) climate_control (>= 0.0.3, < 1.0)
thor (1.1.0) thor (1.1.0)
thread_safe (0.3.6)
thwait (0.2.0) thwait (0.2.0)
e2mmap e2mmap
tilt (2.0.10) tilt (2.0.10)
@ -638,7 +615,7 @@ GEM
openssl-signature_algorithm (~> 0.4.0) openssl-signature_algorithm (~> 0.4.0)
tty-color (0.6.0) tty-color (0.6.0)
tty-cursor (0.7.1) tty-cursor (0.7.1)
tty-prompt (0.23.0) tty-prompt (0.23.1)
pastel (~> 0.8) pastel (~> 0.8)
tty-reader (~> 0.8) tty-reader (~> 0.8)
tty-reader (0.9.0) tty-reader (0.9.0)
@ -649,8 +626,8 @@ GEM
twitter-text (3.1.0) twitter-text (3.1.0)
idn-ruby idn-ruby
unf (~> 0.1.0) unf (~> 0.1.0)
tzinfo (1.2.9) tzinfo (2.0.4)
thread_safe (~> 0.1) concurrent-ruby (~> 1.0)
tzinfo-data (1.2021.1) tzinfo-data (1.2021.1)
tzinfo (>= 1.0.0) tzinfo (>= 1.0.0)
unf (0.1.4) unf (0.1.4)
@ -670,11 +647,11 @@ 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.12.1) webmock (3.13.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)
webpacker (5.2.1) webpacker (5.4.0)
activesupport (>= 5.2) activesupport (>= 5.2)
rack-proxy (>= 0.6.1) rack-proxy (>= 0.6.1)
railties (>= 5.2) railties (>= 5.2)
@ -689,6 +666,7 @@ GEM
xorcist (1.1.2) xorcist (1.1.2)
xpath (3.2.0) xpath (3.2.0)
nokogiri (~> 1.8) nokogiri (~> 1.8)
zeitwerk (2.4.2)
PLATFORMS PLATFORMS
ruby ruby
@ -698,15 +676,15 @@ DEPENDENCIES
active_record_query_trace (~> 1.8) active_record_query_trace (~> 1.8)
addressable (~> 2.7) addressable (~> 2.7)
annotate (~> 3.1) annotate (~> 3.1)
aws-sdk-s3 (~> 1.91) aws-sdk-s3 (~> 1.96)
better_errors (~> 2.9) better_errors (~> 2.9)
binding_of_caller (~> 1.0) binding_of_caller (~> 1.0)
blurhash (~> 0.1) blurhash (~> 0.1)
bootsnap (~> 1.6.0) bootsnap (~> 1.6.0)
brakeman (~> 4.10) brakeman (~> 5.0)
browser browser
bullet (~> 6.1) bullet (~> 6.1)
bundler-audit (~> 0.7) bundler-audit (~> 0.8)
capistrano (~> 3.16) capistrano (~> 3.16)
capistrano-rails (~> 1.6) capistrano-rails (~> 1.6)
capistrano-rbenv (~> 2.2) capistrano-rbenv (~> 2.2)
@ -714,32 +692,31 @@ DEPENDENCIES
capybara (~> 3.35) capybara (~> 3.35)
charlock_holmes (~> 0.7.7) charlock_holmes (~> 0.7.7)
chewy (~> 5.2) chewy (~> 5.2)
cld3 (~> 3.4.1) cld3 (~> 3.4.2)
climate_control (~> 0.2) climate_control (~> 0.2)
color_diff (~> 0.1) color_diff (~> 0.1)
concurrent-ruby concurrent-ruby
connection_pool connection_pool
devise (~> 4.7) devise (~> 4.8)
devise-two-factor (~> 3.1) devise-two-factor (~> 4.0)
devise_pam_authenticatable2 (~> 9.2) devise_pam_authenticatable2 (~> 9.2)
discard (~> 1.2) discard (~> 1.2)
doorkeeper (~> 5.5) doorkeeper (~> 5.5)
dotenv-rails (~> 2.7) dotenv-rails (~> 2.7)
ed25519 (~> 1.2) ed25519 (~> 1.2)
fabrication (~> 2.21) fabrication (~> 2.22)
faker (~> 2.17) faker (~> 2.18)
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)
hamlit-rails (~> 0.2) hamlit-rails (~> 0.2)
health_check!
hiredis (~> 0.6) hiredis (~> 0.6)
htmlentities (~> 4.3) htmlentities (~> 4.3)
http (~> 4.4) http (~> 4.4)
http_accept_language (~> 2.1) http_accept_language (~> 2.1)
httplog (~> 1.4.3) httplog (~> 1.5.0)
i18n-tasks (~> 0.9) i18n-tasks (~> 0.9)
idn-ruby idn-ruby
iso-639 iso-639
@ -756,7 +733,6 @@ DEPENDENCIES
microformats (~> 4.2) microformats (~> 4.2)
mime-types (~> 3.3.1) mime-types (~> 3.3.1)
net-ldap (~> 0.17) net-ldap (~> 0.17)
nilsimsa!
nokogiri (~> 1.11) nokogiri (~> 1.11)
nsa (~> 0.2) nsa (~> 0.2)
oj (~> 3.11) oj (~> 3.11)
@ -766,61 +742,58 @@ DEPENDENCIES
omniauth-saml (~> 1.10) omniauth-saml (~> 1.10)
ox (~> 2.14) ox (~> 2.14)
paperclip (~> 6.0) paperclip (~> 6.0)
paperclip-av-transcoder (~> 0.6)
parallel (~> 1.20) parallel (~> 1.20)
parallel_tests (~> 3.5) parallel_tests (~> 3.7)
parslet parslet
pg (~> 1.2) pg (~> 1.2)
pghero (~> 2.8) pghero (~> 2.8)
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 (~> 5.2) puma (~> 5.3)
pundit (~> 2.1) pundit (~> 2.1)
rack (~> 2.2.3) rack (~> 2.2.3)
rack-attack (~> 6.5) rack-attack (~> 6.5)
rack-cors (~> 1.1) rack-cors (~> 1.1)
rails (~> 5.2.4.5) rails (~> 6.1.3)
rails-controller-testing (~> 1.0) rails-controller-testing (~> 1.0)
rails-i18n (~> 5.1) rails-i18n (~> 6.0)
rails-settings-cached (~> 0.6) rails-settings-cached (~> 0.6)
rdf-normalize (~> 0.4) rdf-normalize (~> 0.4)
redcarpet (~> 3.5) redcarpet (~> 3.5)
redis (~> 4.2) redis (~> 4.3)
redis-namespace (~> 1.8) redis-namespace (~> 1.8)
redis-rails (~> 5.0) resolv (~> 0.1.0)
rqrcode (~> 1.2) rqrcode (~> 2.0)
rspec-rails (~> 4.1) rspec-rails (~> 5.0)
rspec-sidekiq (~> 3.1) rspec-sidekiq (~> 3.1)
rspec_junit_formatter (~> 0.4) rspec_junit_formatter (~> 0.4)
rubocop (~> 1.11) rubocop (~> 1.16)
rubocop-rails (~> 2.9) rubocop-rails (~> 2.10)
ruby-progressbar (~> 1.11) ruby-progressbar (~> 1.11)
sanitize (~> 5.2) sanitize (~> 5.2)
scenic (~> 1.5) scenic (~> 1.5)
sidekiq (~> 6.1) sidekiq (~> 6.2)
sidekiq-bulk (~> 0.2.0) sidekiq-bulk (~> 0.2.0)
sidekiq-scheduler (~> 3.0) sidekiq-scheduler (~> 3.1)
sidekiq-unique-jobs (~> 7.0) sidekiq-unique-jobs (~> 7.0)
simple-navigation (~> 4.1) simple-navigation (~> 4.3)
simple_form (~> 5.1) simple_form (~> 5.1)
simplecov (~> 0.21) simplecov (~> 0.21)
sprockets (~> 3.7.2) sprockets (~> 3.7.2)
sprockets-rails (~> 3.2) sprockets-rails (~> 3.2)
stackprof stackprof
stoplight (~> 2.2.1) stoplight (~> 2.2.1)
streamio-ffmpeg (~> 3.0)
strong_migrations (~> 0.7) strong_migrations (~> 0.7)
thor (~> 1.1) thor (~> 1.1)
tty-prompt (~> 0.23) tty-prompt (~> 0.23)
twitter-text (~> 3.1.0) twitter-text (~> 3.1.0)
tzinfo-data (~> 1.2021) tzinfo-data (~> 1.2021)
webauthn (~> 3.0.0.alpha1) webauthn (~> 3.0.0.alpha1)
webmock (~> 3.12) webmock (~> 3.13)
webpacker (~> 5.2) webpacker (~> 5.4)
webpush webpush (~> 0.3)
xorcist (~> 1.1) xorcist (~> 1.1)

@ -4,8 +4,9 @@
| Version | Supported | | Version | Supported |
| ------- | ------------------ | | ------- | ------------------ |
| 3.1.x | :white_check_mark: | | 3.4.x | :white_check_mark: |
| < 3.1 | :x: | | 3.3.x | :white_check_mark: |
| < 3.3 | :x: |
## Reporting a Vulnerability ## Reporting a Vulnerability

2
Vagrantfile vendored

@ -12,7 +12,7 @@ curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
sudo apt-add-repository 'deb https://dl.yarnpkg.com/debian/ stable main' sudo apt-add-repository 'deb https://dl.yarnpkg.com/debian/ stable main'
# Add repo for NodeJS # Add repo for NodeJS
curl -sL https://deb.nodesource.com/setup_10.x | sudo bash - curl -sL https://deb.nodesource.com/setup_12.x | sudo bash -
# Add firewall rule to redirect 80 to PORT and save # Add firewall rule to redirect 80 to PORT and save
sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port #{ENV["PORT"]} sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port #{ENV["PORT"]}

@ -78,11 +78,7 @@ class AccountsController < ApplicationController
end end
def only_media_scope def only_media_scope
Status.where(id: account_media_status_ids) Status.joins(:media_attachments).merge(@account.media_attachments.reorder(nil)).group(:id)
end
def account_media_status_ids
@account.media_attachments.attached.reorder(nil).select(:status_id).group(:status_id)
end end
def no_replies_scope def no_replies_scope

@ -20,7 +20,7 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController
def outbox_presenter def outbox_presenter
if page_requested? if page_requested?
ActivityPub::CollectionPresenter.new( ActivityPub::CollectionPresenter.new(
id: outbox_url(page_params), id: outbox_url(**page_params),
type: :ordered, type: :ordered,
part_of: outbox_url, part_of: outbox_url,
prev: prev_page, prev: prev_page,
@ -29,7 +29,7 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController
) )
else else
ActivityPub::CollectionPresenter.new( ActivityPub::CollectionPresenter.new(
id: account_outbox_url(@account), id: outbox_url,
type: :ordered, type: :ordered,
size: @account.statuses_count, size: @account.statuses_count,
first: outbox_url(page: true), first: outbox_url(page: true),
@ -47,11 +47,11 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController
end end
def next_page def next_page
account_outbox_url(@account, page: true, max_id: @statuses.last.id) if @statuses.size == LIMIT outbox_url(page: true, max_id: @statuses.last.id) if @statuses.size == LIMIT
end end
def prev_page def prev_page
account_outbox_url(@account, page: true, min_id: @statuses.first.id) unless @statuses.empty? outbox_url(page: true, min_id: @statuses.first.id) unless @statuses.empty?
end end
def set_statuses def set_statuses

@ -4,6 +4,7 @@ require 'sidekiq/api'
module Admin module Admin
class DashboardController < BaseController class DashboardController < BaseController
def index def index
@system_checks = Admin::SystemCheck.perform
@users_count = User.count @users_count = User.count
@pending_users_count = User.pending.count @pending_users_count = User.pending.count
@registrations_week = Redis.current.get("activity:accounts:local:#{current_week}") || 0 @registrations_week = Redis.current.get("activity:accounts:local:#{current_week}") || 0
@ -35,7 +36,6 @@ module Admin
@profile_directory = Setting.profile_directory @profile_directory = Setting.profile_directory
@timeline_preview = Setting.timeline_preview @timeline_preview = Setting.timeline_preview
@keybase_integration = Setting.enable_keybase @keybase_integration = Setting.enable_keybase
@spam_check_enabled = Setting.spam_check_enabled
@trends_enabled = Setting.trends @trends_enabled = Setting.trends
end end

@ -22,7 +22,7 @@ module Admin
if existing_domain_block.present? && !@domain_block.stricter_than?(existing_domain_block) if existing_domain_block.present? && !@domain_block.stricter_than?(existing_domain_block)
@domain_block.save @domain_block.save
flash.now[:alert] = I18n.t('admin.domain_blocks.existing_domain_block_html', name: existing_domain_block.domain, unblock_url: admin_domain_block_path(existing_domain_block)).html_safe # rubocop:disable Rails/OutputSafety flash.now[:alert] = I18n.t('admin.domain_blocks.existing_domain_block_html', name: existing_domain_block.domain, unblock_url: admin_domain_block_path(existing_domain_block)).html_safe # rubocop:disable Rails/OutputSafety
@domain_block.errors[:domain].clear @domain_block.errors.delete(:domain)
render :new render :new
else else
if existing_domain_block.present? if existing_domain_block.present?

@ -0,0 +1,53 @@
# frozen_string_literal: true
module Admin
class FollowRecommendationsController < BaseController
before_action :set_language
def show
authorize :follow_recommendation, :show?
@form = Form::AccountBatch.new
@accounts = filtered_follow_recommendations
end
def update
@form = Form::AccountBatch.new(form_account_batch_params.merge(current_account: current_account, action: action_from_button))
@form.save
rescue ActionController::ParameterMissing
# Do nothing
ensure
redirect_to admin_follow_recommendations_path(filter_params)
end
private
def set_language
@language = follow_recommendation_filter.language
end
def filtered_follow_recommendations
follow_recommendation_filter.results
end
def follow_recommendation_filter
@follow_recommendation_filter ||= FollowRecommendationFilter.new(filter_params)
end
def form_account_batch_params
params.require(:form_account_batch).permit(:action, account_ids: [])
end
def filter_params
params.slice(*FollowRecommendationFilter::KEYS).permit(*FollowRecommendationFilter::KEYS)
end
def action_from_button
if params[:suppress]
'suppress_follow_recommendation'
elsif params[:unsuppress]
'unsuppress_follow_recommendation'
end
end
end
end

@ -3,7 +3,8 @@
module Admin module Admin
class InstancesController < BaseController class InstancesController < BaseController
before_action :set_instances, only: :index before_action :set_instances, only: :index
before_action :set_instance, only: :show before_action :set_instance, except: :index
before_action :set_exhausted_deliveries_days, only: :show
def index def index
authorize :instance, :index? authorize :instance, :index?
@ -13,14 +14,55 @@ module Admin
authorize :instance, :show? authorize :instance, :show?
end end
def clear_delivery_errors
authorize :delivery, :clear_delivery_errors?
@instance.delivery_failure_tracker.clear_failures!
redirect_to admin_instance_path(@instance.domain)
end
def restart_delivery
authorize :delivery, :restart_delivery?
last_unavailable_domain = unavailable_domain
if last_unavailable_domain.present?
@instance.delivery_failure_tracker.track_success!
log_action :destroy, last_unavailable_domain
end
redirect_to admin_instance_path(@instance.domain)
end
def stop_delivery
authorize :delivery, :stop_delivery?
UnavailableDomain.create(domain: @instance.domain)
log_action :create, unavailable_domain
redirect_to admin_instance_path(@instance.domain)
end
private private
def set_instance def set_instance
@instance = Instance.find(params[:id]) @instance = Instance.find(params[:id])
end end
def set_exhausted_deliveries_days
@exhausted_deliveries_days = @instance.delivery_failure_tracker.exhausted_deliveries_days
end
def set_instances def set_instances
@instances = filtered_instances.page(params[:page]) @instances = filtered_instances.page(params[:page])
warning_domains_map = DeliveryFailureTracker.warning_domains_map
@instances.each do |instance|
instance.failure_days = warning_domains_map[instance.domain]
end
end
def unavailable_domain
UnavailableDomain.find_by(domain: @instance.domain)
end end
def filtered_instances def filtered_instances

@ -14,8 +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).group(:status_id) @statuses.merge!(Status.joins(:media_attachments).merge(@account.media_attachments.reorder(nil)).group(:id))
@statuses.merge!(Status.where(id: account_media_status_ids))
end end
@statuses = @statuses.preload(:media_attachments, :mentions).page(params[:page]).per(PER_PAGE) @statuses = @statuses.preload(:media_attachments, :mentions).page(params[:page]).per(PER_PAGE)

@ -59,8 +59,8 @@ module Admin
.where(Status.arel_table[:id].gteq(Mastodon::Snowflake.id_at(Time.now.utc.beginning_of_day))) .where(Status.arel_table[:id].gteq(Mastodon::Snowflake.id_at(Time.now.utc.beginning_of_day)))
.joins(:account) .joins(:account)
.group('accounts.domain') .group('accounts.domain')
.reorder('statuses_count desc') .reorder(statuses_count: :desc)
.pluck('accounts.domain, count(*) AS statuses_count') .pluck(Arel.sql('accounts.domain, count(*) AS statuses_count'))
end end
def set_counters def set_counters

@ -35,7 +35,7 @@ class Api::V1::AccountsController < Api::BaseController
follow = FollowService.new.call(current_user.account, @account, reblogs: params.key?(:reblogs) ? truthy_param?(:reblogs) : nil, notify: params.key?(:notify) ? truthy_param?(:notify) : nil, with_rate_limit: true) follow = FollowService.new.call(current_user.account, @account, reblogs: params.key?(:reblogs) ? truthy_param?(:reblogs) : nil, notify: params.key?(:notify) ? truthy_param?(:notify) : nil, with_rate_limit: true)
options = @account.locked? || current_user.account.silenced? ? {} : { following_map: { @account.id => { reblogs: follow.show_reblogs?, notify: follow.notify? } }, requested_map: { @account.id => false } } options = @account.locked? || current_user.account.silenced? ? {} : { following_map: { @account.id => { reblogs: follow.show_reblogs?, notify: follow.notify? } }, requested_map: { @account.id => false } }
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships(options) render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships(**options)
end end
def block def block
@ -70,7 +70,7 @@ class Api::V1::AccountsController < Api::BaseController
end end
def relationships(**options) def relationships(**options)
AccountRelationshipsPresenter.new([@account.id], current_user.account_id, options) AccountRelationshipsPresenter.new([@account.id], current_user.account_id, **options)
end end
def account_params def account_params

@ -3,9 +3,12 @@
class Api::V1::Emails::ConfirmationsController < Api::BaseController class Api::V1::Emails::ConfirmationsController < Api::BaseController
before_action :doorkeeper_authorize! before_action :doorkeeper_authorize!
before_action :require_user_owned_by_application! before_action :require_user_owned_by_application!
before_action :require_user_not_confirmed!
def create def create
current_user.resend_confirmation_instructions if current_user.unconfirmed_email.present? current_user.update!(email: params[:email]) if params.key?(:email)
current_user.resend_confirmation_instructions
render_empty render_empty
end end
@ -14,4 +17,8 @@ class Api::V1::Emails::ConfirmationsController < Api::BaseController
def require_user_owned_by_application! def require_user_owned_by_application!
render json: { error: 'This method is only available to the application the user originally signed-up with' }, status: :forbidden unless current_user && current_user.created_by_application_id == doorkeeper_token.application_id render json: { error: 'This method is only available to the application the user originally signed-up with' }, status: :forbidden unless current_user && current_user.created_by_application_id == doorkeeper_token.application_id
end end
def require_user_not_confirmed!
render json: { error: 'This method is only available while the e-mail is awaiting confirmation' }, status: :forbidden if current_user.confirmed? || current_user.unconfirmed_email.blank?
end
end end

@ -29,7 +29,7 @@ class Api::V1::FollowRequestsController < Api::BaseController
end end
def relationships(**options) def relationships(**options)
AccountRelationshipsPresenter.new([params[:id]], current_user.account_id, options) AccountRelationshipsPresenter.new([params[:id]], current_user.account_id, **options)
end end
def load_accounts def load_accounts

@ -3,13 +3,13 @@
class Api::V1::Push::SubscriptionsController < Api::BaseController class Api::V1::Push::SubscriptionsController < Api::BaseController
before_action -> { doorkeeper_authorize! :push } before_action -> { doorkeeper_authorize! :push }
before_action :require_user! before_action :require_user!
before_action :set_web_push_subscription before_action :set_push_subscription
before_action :check_web_push_subscription, only: [:show, :update] before_action :check_push_subscription, only: [:show, :update]
def create def create
@web_subscription&.destroy! @push_subscription&.destroy!
@web_subscription = ::Web::PushSubscription.create!( @push_subscription = Web::PushSubscription.create!(
endpoint: subscription_params[:endpoint], endpoint: subscription_params[:endpoint],
key_p256dh: subscription_params[:keys][:p256dh], key_p256dh: subscription_params[:keys][:p256dh],
key_auth: subscription_params[:keys][:auth], key_auth: subscription_params[:keys][:auth],
@ -18,31 +18,31 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController
access_token_id: doorkeeper_token.id access_token_id: doorkeeper_token.id
) )
render json: @web_subscription, serializer: REST::WebPushSubscriptionSerializer render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer
end end
def show def show
render json: @web_subscription, serializer: REST::WebPushSubscriptionSerializer render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer
end end
def update def update
@web_subscription.update!(data: data_params) @push_subscription.update!(data: data_params)
render json: @web_subscription, serializer: REST::WebPushSubscriptionSerializer render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer
end end
def destroy def destroy
@web_subscription&.destroy! @push_subscription&.destroy!
render_empty render_empty
end end
private private
def set_web_push_subscription def set_push_subscription
@web_subscription = ::Web::PushSubscription.find_by(access_token_id: doorkeeper_token.id) @push_subscription = Web::PushSubscription.find_by(access_token_id: doorkeeper_token.id)
end end
def check_web_push_subscription def check_push_subscription
not_found if @web_subscription.nil? not_found if @push_subscription.nil?
end end
def subscription_params def subscription_params
@ -52,6 +52,6 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController
def data_params def data_params
return {} if params[:data].blank? return {} if params[:data].blank?
params.require(:data).permit(alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll, :status]) params.require(:data).permit(:policy, alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll, :status])
end end
end end

@ -5,20 +5,20 @@ class Api::V1::SuggestionsController < Api::BaseController
before_action -> { doorkeeper_authorize! :read } before_action -> { doorkeeper_authorize! :read }
before_action :require_user! before_action :require_user!
before_action :set_accounts
def index def index
render json: @accounts, each_serializer: REST::AccountSerializer suggestions = suggestions_source.get(current_account, limit: limit_param(DEFAULT_ACCOUNTS_LIMIT))
render json: suggestions.map(&:account), each_serializer: REST::AccountSerializer
end end
def destroy def destroy
PotentialFriendshipTracker.remove(current_account.id, params[:id]) suggestions_source.remove(current_account, params[:id])
render_empty render_empty
end end
private private
def set_accounts def suggestions_source
@accounts = PotentialFriendshipTracker.get(current_account.id, limit: limit_param(DEFAULT_ACCOUNTS_LIMIT)) AccountSuggestions::PastInteractionsSource.new
end end
end end

@ -0,0 +1,19 @@
# frozen_string_literal: true
class Api::V2::SuggestionsController < Api::BaseController
include Authorization
before_action -> { doorkeeper_authorize! :read }
before_action :require_user!
before_action :set_suggestions
def index
render json: @suggestions, each_serializer: REST::SuggestionSerializer
end
private
def set_suggestions
@suggestions = AccountSuggestions.get(current_account, limit_param(DEFAULT_ACCOUNTS_LIMIT))
end
end

@ -2,6 +2,7 @@
class Api::Web::PushSubscriptionsController < Api::Web::BaseController class Api::Web::PushSubscriptionsController < Api::Web::BaseController
before_action :require_user! before_action :require_user!
before_action :set_push_subscription, only: :update
def create def create
active_session = current_session active_session = current_session
@ -15,9 +16,11 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
alerts_enabled = active_session.detection.device.mobile? || active_session.detection.device.tablet? alerts_enabled = active_session.detection.device.mobile? || active_session.detection.device.tablet?
data = { data = {
policy: 'all',
alerts: { alerts: {
follow: alerts_enabled, follow: alerts_enabled,
follow_request: false, follow_request: alerts_enabled,
favourite: alerts_enabled, favourite: alerts_enabled,
reblog: alerts_enabled, reblog: alerts_enabled,
mention: alerts_enabled, mention: alerts_enabled,
@ -28,7 +31,7 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
data.deep_merge!(data_params) if params[:data] data.deep_merge!(data_params) if params[:data]
web_subscription = ::Web::PushSubscription.create!( push_subscription = ::Web::PushSubscription.create!(
endpoint: subscription_params[:endpoint], endpoint: subscription_params[:endpoint],
key_p256dh: subscription_params[:keys][:p256dh], key_p256dh: subscription_params[:keys][:p256dh],
key_auth: subscription_params[:keys][:auth], key_auth: subscription_params[:keys][:auth],
@ -37,27 +40,27 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
access_token_id: active_session.access_token_id access_token_id: active_session.access_token_id
) )
active_session.update!(web_push_subscription: web_subscription) active_session.update!(web_push_subscription: push_subscription)
render json: web_subscription, serializer: REST::WebPushSubscriptionSerializer render json: push_subscription, serializer: REST::WebPushSubscriptionSerializer
end end
def update def update
params.require([:id]) @push_subscription.update!(data: data_params)
render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer
web_subscription = ::Web::PushSubscription.find(params[:id])
web_subscription.update!(data: data_params)
render json: web_subscription, serializer: REST::WebPushSubscriptionSerializer
end end
private private
def set_push_subscription
@push_subscription = ::Web::PushSubscription.find(params[:id])
end
def subscription_params def subscription_params
@subscription_params ||= params.require(:subscription).permit(:endpoint, keys: [:auth, :p256dh]) @subscription_params ||= params.require(:subscription).permit(:endpoint, keys: [:auth, :p256dh])
end end
def data_params def data_params
@data_params ||= params.require(:data).permit(alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll, :status]) @data_params ||= params.require(:data).permit(:policy, alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll, :status])
end end
end end

@ -5,8 +5,6 @@ class ApplicationController < ActionController::Base
# For APIs, you may want to use :null_session instead. # For APIs, you may want to use :null_session instead.
protect_from_forgery with: :exception protect_from_forgery with: :exception
force_ssl if: :https_enabled?
include Localized include Localized
include UserTrackingConcern include UserTrackingConcern
include SessionTrackingConcern include SessionTrackingConcern
@ -21,17 +19,16 @@ class ApplicationController < ActionController::Base
helper_method :use_seamless_external_login? helper_method :use_seamless_external_login?
helper_method :whitelist_mode? helper_method :whitelist_mode?
rescue_from ActionController::RoutingError, with: :not_found rescue_from ActionController::ParameterMissing, Paperclip::AdapterRegistry::NoHandlerError, with: :bad_request
rescue_from ActionController::InvalidAuthenticityToken, with: :unprocessable_entity
rescue_from ActionController::UnknownFormat, with: :not_acceptable
rescue_from ActionController::ParameterMissing, with: :bad_request
rescue_from Paperclip::AdapterRegistry::NoHandlerError, with: :bad_request
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 ActionController::RoutingError, ActiveRecord::RecordNotFound, with: :not_found
rescue_from Mastodon::RaceConditionError, Seahorse::Client::NetworkingError, Stoplight::Error::RedLight, with: :service_unavailable rescue_from ActionController::UnknownFormat, with: :not_acceptable
rescue_from ActionController::InvalidAuthenticityToken, with: :unprocessable_entity
rescue_from Mastodon::RateLimitExceededError, with: :too_many_requests rescue_from Mastodon::RateLimitExceededError, with: :too_many_requests
rescue_from HTTP::Error, OpenSSL::SSL::SSLError, with: :internal_server_error
rescue_from Mastodon::RaceConditionError, Seahorse::Client::NetworkingError, Stoplight::Error::RedLight, ActiveRecord::SerializationFailure, with: :service_unavailable
before_action :store_current_location, except: :raise_not_found, unless: :devise_controller? before_action :store_current_location, except: :raise_not_found, unless: :devise_controller?
before_action :require_functional!, if: :user_signed_in? before_action :require_functional!, if: :user_signed_in?
@ -43,10 +40,6 @@ class ApplicationController < ActionController::Base
private private
def https_enabled?
Rails.env.production? && !request.path.start_with?('/health') && !request.headers["Host"].end_with?(".onion")
end
def authorized_fetch_mode? def authorized_fetch_mode?
ENV['AUTHORIZED_FETCH'] == 'true' || Rails.configuration.x.whitelist_mode ENV['AUTHORIZED_FETCH'] == 'true' || Rails.configuration.x.whitelist_mode
end end

@ -22,7 +22,9 @@ class Auth::ConfirmationsController < Devise::ConfirmationsController
end end
def require_unconfirmed! def require_unconfirmed!
redirect_to edit_user_registration_path if user_signed_in? && current_user.confirmed? && current_user.unconfirmed_email.blank? if user_signed_in? && current_user.confirmed? && current_user.unconfirmed_email.blank?
redirect_to(current_user.approved? ? root_path : edit_user_registration_path)
end
end end
def set_body_classes def set_body_classes

@ -31,7 +31,9 @@ module CacheConcern
def cache_collection(raw, klass) def cache_collection(raw, klass)
return raw unless klass.respond_to?(:with_includes) return raw unless klass.respond_to?(:with_includes)
raw = raw.cache_ids.to_a if raw.is_a?(ActiveRecord::Relation) raw = raw.cache_ids.to_a if raw.is_a?(ActiveRecord::Relation)
return [] if raw.empty?
cached_keys_with_value = Rails.cache.read_multi(*raw).transform_keys(&:id) cached_keys_with_value = Rails.cache.read_multi(*raw).transform_keys(&:id)
uncached_ids = raw.map(&:id) - cached_keys_with_value.keys uncached_ids = raw.map(&:id) - cached_keys_with_value.keys

@ -3,11 +3,16 @@
class CustomCssController < ApplicationController class CustomCssController < ApplicationController
skip_before_action :store_current_location skip_before_action :store_current_location
skip_before_action :require_functional! skip_before_action :require_functional!
skip_before_action :update_user_sign_in
skip_before_action :set_session_activity
skip_around_action :set_locale
before_action :set_cache_headers before_action :set_cache_headers
def show def show
expires_in 3.minutes, public: true expires_in 3.minutes, public: true
request.session_options[:skip] = true
render plain: Setting.custom_css || '', content_type: 'text/css' render plain: Setting.custom_css || '', content_type: 'text/css'
end end
end end

@ -6,7 +6,6 @@ class DirectoriesController < ApplicationController
before_action :authenticate_user!, if: :whitelist_mode? before_action :authenticate_user!, if: :whitelist_mode?
before_action :require_enabled! before_action :require_enabled!
before_action :set_instance_presenter before_action :set_instance_presenter
before_action :set_tag, only: :show
before_action :set_accounts before_action :set_accounts
before_action :set_pack before_action :set_pack
@ -16,10 +15,6 @@ class DirectoriesController < ApplicationController
render :index render :index
end end
def show
render :index
end
private private
def set_pack def set_pack
@ -30,13 +25,8 @@ class DirectoriesController < ApplicationController
return not_found unless Setting.profile_directory return not_found unless Setting.profile_directory
end end
def set_tag
@tag = Tag.discoverable.find_normalized!(params[:id])
end
def set_accounts def set_accounts
@accounts = Account.local.discoverable.by_recent_status.page(params[:page]).per(20).tap do |query| @accounts = Account.local.discoverable.by_recent_status.page(params[:page]).per(20).tap do |query|
query.merge!(Account.tagged_with(@tag.id)) if @tag
query.merge!(Account.not_excluded_by_account(current_account)) if current_account query.merge!(Account.not_excluded_by_account(current_account)) if current_account
end end
end end

@ -0,0 +1,7 @@
# frozen_string_literal: true
class HealthController < ActionController::Base
def show
render plain: 'OK'
end
end

@ -45,7 +45,7 @@ class MediaProxyController < ApplicationController
end end
def lock_options def lock_options
{ redis: Redis.current, key: "media_download:#{params[:id]}" } { redis: Redis.current, key: "media_download:#{params[:id]}", autorelease: 15.minutes.seconds }
end end
def reject_media? def reject_media?

@ -16,7 +16,6 @@ class StatusesController < ApplicationController
before_action :set_referrer_policy_header, only: :show before_action :set_referrer_policy_header, only: :show
before_action :set_cache_headers before_action :set_cache_headers
before_action :set_body_classes before_action :set_body_classes
before_action :set_autoplay, only: :embed
skip_around_action :set_locale, if: -> { request.format == :json } skip_around_action :set_locale, if: -> { request.format == :json }
skip_before_action :require_functional!, only: [:show, :embed], unless: :whitelist_mode? skip_before_action :require_functional!, only: [:show, :embed], unless: :whitelist_mode?
@ -85,8 +84,4 @@ class StatusesController < ApplicationController
def set_referrer_policy_header def set_referrer_policy_header
response.headers['Referrer-Policy'] = 'origin' unless @status.distributable? response.headers['Referrer-Policy'] = 'origin' unless @status.distributable?
end end
def set_autoplay
@autoplay = truthy_param?(:autoplay)
end
end end

@ -21,7 +21,7 @@ module Admin::ActionLogsHelper
record.shortcode record.shortcode
when 'Report' when 'Report'
link_to "##{record.id}", admin_report_path(record) link_to "##{record.id}", admin_report_path(record)
when 'DomainBlock', 'DomainAllow', 'EmailDomainBlock' when 'DomainBlock', 'DomainAllow', 'EmailDomainBlock', 'UnavailableDomain'
link_to record.domain, "https://#{record.domain}" link_to record.domain, "https://#{record.domain}"
when 'Status' when 'Status'
link_to record.account.acct, ActivityPub::TagManager.instance.url_for(record) link_to record.account.acct, ActivityPub::TagManager.instance.url_for(record)
@ -38,7 +38,7 @@ module Admin::ActionLogsHelper
case type case type
when 'CustomEmoji' when 'CustomEmoji'
attributes['shortcode'] attributes['shortcode']
when 'DomainBlock', 'DomainAllow', 'EmailDomainBlock' when 'DomainBlock', 'DomainAllow', 'EmailDomainBlock', 'UnavailableDomain'
link_to attributes['domain'], "https://#{attributes['domain']}" link_to attributes['domain'], "https://#{attributes['domain']}"
when 'Status' when 'Status'
tmp_status = Status.new(attributes.except('reblogs_count', 'favourites_count')) tmp_status = Status.new(attributes.except('reblogs_count', 'favourites_count'))

@ -0,0 +1,18 @@
# frozen_string_literal: true
module EmailHelper
def self.included(base)
base.extend(self)
end
def email_to_canonical_email(str)
username, domain = str.downcase.split('@', 2)
username, = username.gsub('.', '').split('+', 2)
"#{username}@#{domain}"
end
def email_to_canonical_email_hash(str)
Digest::SHA2.new(256).hexdigest(email_to_canonical_email(str))
end
end

@ -67,7 +67,7 @@ module JsonLdHelper
unless id unless id
json = fetch_resource_without_id_validation(uri, on_behalf_of) json = fetch_resource_without_id_validation(uri, on_behalf_of)
return unless json return if !json.is_a?(Hash) || unsupported_uri_scheme?(json['id'])
uri = json['id'] uri = json['id']
end end

@ -2,6 +2,7 @@
module SettingsHelper module SettingsHelper
HUMAN_LOCALES = { HUMAN_LOCALES = {
af: 'Afrikaans',
ar: 'العربية', ar: 'العربية',
ast: 'Asturianu', ast: 'Asturianu',
bg: 'Български', bg: 'Български',
@ -17,6 +18,7 @@ module SettingsHelper
en: 'English', en: 'English',
eo: 'Esperanto', eo: 'Esperanto',
'es-AR': 'Español (Argentina)', 'es-AR': 'Español (Argentina)',
'es-MX': 'Español (México)',
es: 'Español', es: 'Español',
et: 'Eesti', et: 'Eesti',
eu: 'Euskara', eu: 'Euskara',
@ -24,6 +26,7 @@ module SettingsHelper
fi: 'Suomi', fi: 'Suomi',
fr: 'Français', fr: 'Français',
ga: 'Gaeilge', ga: 'Gaeilge',
gd: 'Gàidhlig',
gl: 'Galego', gl: 'Galego',
he: 'עברית', he: 'עברית',
hi: 'हि', hi: 'हि',
@ -59,6 +62,7 @@ module SettingsHelper
ru: 'Русский', ru: 'Русский',
sa: 'सतम', sa: 'सतम',
sc: 'Sardu', sc: 'Sardu',
si: 'සහල',
sk: 'Slovenčina', sk: 'Slovenčina',
sl: 'Slovenščina', sl: 'Slovenščina',
sq: 'Shqip', sq: 'Shqip',

@ -130,4 +130,84 @@ module StatusesHelper
def embedded_view? def embedded_view?
params[:controller] == EMBEDDED_CONTROLLER && params[:action] == EMBEDDED_ACTION params[:controller] == EMBEDDED_CONTROLLER && params[:action] == EMBEDDED_ACTION
end end
def render_video_component(status, **options)
video = status.media_attachments.first
meta = video.file.meta || {}
component_params = {
sensitive: sensitized?(status, current_account),
src: full_asset_url(video.file.url(:original)),
preview: full_asset_url(video.thumbnail.present? ? video.thumbnail.url : video.file.url(:small)),
alt: video.description,
blurhash: video.blurhash,
frameRate: meta.dig('original', 'frame_rate'),
inline: true,
media: [
ActiveModelSerializers::SerializableResource.new(video, serializer: REST::MediaAttachmentSerializer),
].as_json,
}.merge(**options)
react_component :video, component_params do
render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
end
end
def render_audio_component(status, **options)
audio = status.media_attachments.first
meta = audio.file.meta || {}
component_params = {
src: full_asset_url(audio.file.url(:original)),
poster: full_asset_url(audio.thumbnail.present? ? audio.thumbnail.url : status.account.avatar_static_url),
alt: audio.description,
backgroundColor: meta.dig('colors', 'background'),
foregroundColor: meta.dig('colors', 'foreground'),
accentColor: meta.dig('colors', 'accent'),
duration: meta.dig('original', 'duration'),
}.merge(**options)
react_component :audio, component_params do
render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
end
end
def render_media_gallery_component(status, **options)
component_params = {
sensitive: sensitized?(status, current_account),
autoplay: prefers_autoplay?,
media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json },
}.merge(**options)
react_component :media_gallery, component_params do
render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
end
end
def render_card_component(status, **options)
component_params = {
sensitive: sensitized?(status, current_account),
maxDescription: 160,
card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json,
}.merge(**options)
react_component :card, component_params
end
def render_poll_component(status, **options)
component_params = {
disabled: true,
poll: ActiveModelSerializers::SerializableResource.new(status.preloadable_poll, serializer: REST::PollSerializer, scope: current_user, scope_name: :current_user).as_json,
}.merge(**options)
react_component :poll, component_params do
render partial: 'statuses/poll', locals: { status: status, poll: status.preloadable_poll, autoplay: prefers_autoplay? }
end
end
def prefers_autoplay?
ActiveModel::Type::Boolean.new.cast(params[:autoplay]) || current_user&.setting_auto_play_gif
end
end end

@ -24,6 +24,7 @@ export function normalizeAccount(account) {
account.display_name_html = emojify(escapeTextContentForBrowser(displayName), emojiMap); account.display_name_html = emojify(escapeTextContentForBrowser(displayName), emojiMap);
account.note_emojified = emojify(account.note, emojiMap); account.note_emojified = emojify(account.note, emojiMap);
account.note_plain = unescapeHTML(account.note);
if (account.fields) { if (account.fields) {
account.fields = account.fields.map(pair => ({ account.fields = account.fields.map(pair => ({

@ -32,6 +32,7 @@ export function submitSearch() {
const value = getState().getIn(['search', 'value']); const value = getState().getIn(['search', 'value']);
if (value.length === 0) { if (value.length === 0) {
dispatch(fetchSearchSuccess({ accounts: [], statuses: [], hashtags: [] }, ''));
return; return;
} }

@ -1,6 +1,7 @@
import { Iterable, fromJS } from 'immutable'; import { Iterable, fromJS } from 'immutable';
import { hydrateCompose } from './compose'; import { hydrateCompose } from './compose';
import { importFetchedAccounts } from './importer'; import { importFetchedAccounts } from './importer';
import { saveSettings } from './settings';
export const STORE_HYDRATE = 'STORE_HYDRATE'; export const STORE_HYDRATE = 'STORE_HYDRATE';
export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY'; export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY';
@ -9,9 +10,22 @@ const convertState = rawState =>
fromJS(rawState, (k, v) => fromJS(rawState, (k, v) =>
Iterable.isIndexed(v) ? v.toList() : v.toMap()); Iterable.isIndexed(v) ? v.toList() : v.toMap());
const applyMigrations = (state) => {
return state.withMutations(state => {
// Migrate glitch-soc local-only “Show unread marker” setting to Mastodon's setting
if (state.getIn(['local_settings', 'notifications', 'show_unread']) !== undefined) {
// Only change if the Mastodon setting does not deviate from default
if (state.getIn(['settings', 'notifications', 'showUnread']) !== false) {
state.setIn(['settings', 'notifications', 'showUnread'], state.getIn(['local_settings', 'notifications', 'show_unread']));
}
state.removeIn(['local_settings', 'notifications', 'show_unread'])
}
});
};
export function hydrateStore(rawState) { export function hydrateStore(rawState) {
return dispatch => { return dispatch => {
const state = convertState(rawState); const state = applyMigrations(convertState(rawState));
dispatch({ dispatch({
type: STORE_HYDRATE, type: STORE_HYDRATE,
@ -20,5 +34,6 @@ export function hydrateStore(rawState) {
dispatch(hydrateCompose()); dispatch(hydrateCompose());
dispatch(importFetchedAccounts(Object.values(rawState.accounts))); dispatch(importFetchedAccounts(Object.values(rawState.accounts)));
dispatch(saveSettings());
}; };
}; };

@ -1,5 +1,6 @@
import api from 'flavours/glitch/util/api'; import api from 'flavours/glitch/util/api';
import { importFetchedAccounts } from './importer'; import { importFetchedAccounts } from './importer';
import { fetchRelationships } from './accounts';
export const SUGGESTIONS_FETCH_REQUEST = 'SUGGESTIONS_FETCH_REQUEST'; export const SUGGESTIONS_FETCH_REQUEST = 'SUGGESTIONS_FETCH_REQUEST';
export const SUGGESTIONS_FETCH_SUCCESS = 'SUGGESTIONS_FETCH_SUCCESS'; export const SUGGESTIONS_FETCH_SUCCESS = 'SUGGESTIONS_FETCH_SUCCESS';
@ -7,13 +8,17 @@ export const SUGGESTIONS_FETCH_FAIL = 'SUGGESTIONS_FETCH_FAIL';
export const SUGGESTIONS_DISMISS = 'SUGGESTIONS_DISMISS'; export const SUGGESTIONS_DISMISS = 'SUGGESTIONS_DISMISS';
export function fetchSuggestions() { export function fetchSuggestions(withRelationships = false) {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch(fetchSuggestionsRequest()); dispatch(fetchSuggestionsRequest());
api(getState).get('/api/v1/suggestions').then(response => { api(getState).get('/api/v2/suggestions', { params: { limit: 20 } }).then(response => {
dispatch(importFetchedAccounts(response.data)); dispatch(importFetchedAccounts(response.data.map(x => x.account)));
dispatch(fetchSuggestionsSuccess(response.data)); dispatch(fetchSuggestionsSuccess(response.data));
if (withRelationships) {
dispatch(fetchRelationships(response.data.map(item => item.account.id)));
}
}).catch(error => dispatch(fetchSuggestionsFail(error))); }).catch(error => dispatch(fetchSuggestionsFail(error)));
}; };
}; };
@ -25,10 +30,10 @@ export function fetchSuggestionsRequest() {
}; };
}; };
export function fetchSuggestionsSuccess(accounts) { export function fetchSuggestionsSuccess(suggestions) {
return { return {
type: SUGGESTIONS_FETCH_SUCCESS, type: SUGGESTIONS_FETCH_SUCCESS,
accounts, suggestions,
skipLoading: true, skipLoading: true,
}; };
}; };
@ -48,5 +53,12 @@ export const dismissSuggestion = accountId => (dispatch, getState) => {
id: accountId, id: accountId,
}); });
api(getState).delete(`/api/v1/suggestions/${accountId}`); api(getState).delete(`/api/v1/suggestions/${accountId}`).then(() => {
dispatch(fetchSuggestionsRequest());
api(getState).get('/api/v2/suggestions').then(response => {
dispatch(importFetchedAccounts(response.data.map(x => x.account)));
dispatch(fetchSuggestionsSuccess(response.data));
}).catch(error => dispatch(fetchSuggestionsFail(error)));
}).catch(() => {});
}; };

@ -20,6 +20,8 @@ export const TIMELINE_LOAD_PENDING = 'TIMELINE_LOAD_PENDING';
export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT'; export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT';
export const TIMELINE_CONNECT = 'TIMELINE_CONNECT'; export const TIMELINE_CONNECT = 'TIMELINE_CONNECT';
export const TIMELINE_MARK_AS_PARTIAL = 'TIMELINE_MARK_AS_PARTIAL';
export const loadPending = timeline => ({ export const loadPending = timeline => ({
type: TIMELINE_LOAD_PENDING, type: TIMELINE_LOAD_PENDING,
timeline, timeline,
@ -31,6 +33,13 @@ export function updateTimeline(timeline, status, accept) {
return; return;
} }
if (getState().getIn(['timelines', timeline, 'isPartial'])) {
// Prevent new items from being added to a partial timeline,
// since it will be reloaded anyway
return;
}
const filters = getFiltersRegex(getState(), { contextType: timeline }); const filters = getFiltersRegex(getState(), { contextType: timeline });
const dropRegex = filters[0]; const dropRegex = filters[0];
const regex = filters[1]; const regex = filters[1];
@ -198,3 +207,8 @@ export const disconnectTimeline = timeline => ({
timeline, timeline,
usePendingItems: preferPendingItems, usePendingItems: preferPendingItems,
}); });
export const markAsPartial = timeline => ({
type: TIMELINE_MARK_AS_PARTIAL,
timeline,
});

@ -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)));
};

@ -87,8 +87,10 @@ class Account extends ImmutablePureComponent {
let buttons; let buttons;
if (onActionClick && actionIcon) { if (onActionClick) {
buttons = <IconButton icon={actionIcon} title={actionTitle} onClick={this.handleAction} />; if (actionIcon) {
buttons = <IconButton icon={actionIcon} title={actionTitle} onClick={this.handleAction} />;
}
} else if (account.get('id') !== me && !small && account.get('relationship', null) !== null) { } else if (account.get('id') !== me && !small && account.get('relationship', null) !== null) {
const following = account.getIn(['relationship', 'following']); const following = account.getIn(['relationship', 'following']);
const requested = account.getIn(['relationship', 'requested']); const requested = account.getIn(['relationship', 'requested']);

@ -0,0 +1,9 @@
import React from 'react';
const Logo = () => (
<svg viewBox='0 0 216.4144 232.00976' className='logo'>
<use xlinkHref='#mastodon-svg-logo' />
</svg>
);
export default Logo;

@ -24,7 +24,7 @@ const messages = defineMessages({
id: 'status.sensitive_toggle', id: 'status.sensitive_toggle',
}, },
toggle_visible: { toggle_visible: {
defaultMessage: 'Hide {number, plural, one {image} other {images}}', defaultMessage: '{number, plural, one {Hide image} other {Hide images}}',
id: 'media_gallery.toggle_visible', id: 'media_gallery.toggle_visible',
}, },
warning: { warning: {

@ -2,6 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import 'wicg-inert'; import 'wicg-inert';
import { createBrowserHistory } from 'history'; import { createBrowserHistory } from 'history';
import { multiply } from 'color-blend';
export default class ModalRoot extends React.PureComponent { export default class ModalRoot extends React.PureComponent {
static contextTypes = { static contextTypes = {
@ -11,6 +12,11 @@ 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,
}),
noEsc: PropTypes.bool, noEsc: PropTypes.bool,
}; };
@ -68,9 +74,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) => { }).catch(console.error);
console.error(error);
});
this.handleModalClose(); this.handleModalClose();
} }
@ -120,10 +124,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}> <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>

@ -2,7 +2,7 @@ import React from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import illustration from 'flavours/glitch/images/elephant_ui_working.svg'; import illustration from 'flavours/glitch/images/elephant_ui_working.svg';
const MissingIndicator = () => ( const RegenerationIndicator = () => (
<div className='regeneration-indicator'> <div className='regeneration-indicator'>
<div className='regeneration-indicator__figure'> <div className='regeneration-indicator__figure'>
<img src={illustration} alt='' /> <img src={illustration} alt='' />
@ -15,4 +15,4 @@ const MissingIndicator = () => (
</div> </div>
); );
export default MissingIndicator; export default RegenerationIndicator;

@ -378,22 +378,26 @@ class Status extends ImmutablePureComponent {
} }
}; };
handleOpenVideo = (media, options) => { handleOpenVideo = (options) => {
this.props.onOpenVideo(media, options); const { status } = this.props;
this.props.onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), options);
}
handleOpenMedia = (media, index) => {
this.props.onOpenMedia(this.props.status.get('id'), media, index);
} }
handleHotkeyOpenMedia = e => { handleHotkeyOpenMedia = e => {
const { status, onOpenMedia, onOpenVideo } = this.props; const { status, onOpenMedia, onOpenVideo } = this.props;
const statusId = status.get('id');
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') { if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
// TODO: toggle play/paused? onOpenVideo(statusId, status.getIn(['media_attachments', 0]), { startTime: 0 });
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
onOpenVideo(status.getIn(['media_attachments', 0]), { startTime: 0 });
} else { } else {
onOpenMedia(status.get('media_attachments'), 0); onOpenMedia(statusId, status.get('media_attachments'), 0);
} }
} }
} }
@ -657,7 +661,7 @@ class Status extends ImmutablePureComponent {
letterbox={settings.getIn(['media', 'letterbox'])} letterbox={settings.getIn(['media', 'letterbox'])}
fullwidth={settings.getIn(['media', 'fullwidth'])} fullwidth={settings.getIn(['media', 'fullwidth'])}
hidden={isCollapsed || !isExpanded} hidden={isCollapsed || !isExpanded}
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}
@ -675,7 +679,7 @@ class Status extends ImmutablePureComponent {
} else if (status.get('card') && settings.get('inline_preview_cards')) { } else if (status.get('card') && settings.get('inline_preview_cards')) {
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}

@ -2,7 +2,6 @@ import React from 'react';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import configureStore from 'flavours/glitch/store/configureStore'; import configureStore from 'flavours/glitch/store/configureStore';
import { showOnboardingOnce } from 'flavours/glitch/actions/onboarding';
import { BrowserRouter, Route } from 'react-router-dom'; import { BrowserRouter, Route } from 'react-router-dom';
import { ScrollContext } from 'react-router-scroll-4'; import { ScrollContext } from 'react-router-scroll-4';
import UI from 'flavours/glitch/features/ui'; import UI from 'flavours/glitch/features/ui';
@ -32,7 +31,6 @@ export default class Mastodon extends React.PureComponent {
componentDidMount() { componentDidMount() {
this.disconnect = store.dispatch(connectUserStream()); this.disconnect = store.dispatch(connectUserStream());
store.dispatch(showOnboardingOnce());
} }
componentWillUnmount () { componentWillUnmount () {

@ -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 'flavours/glitch/util/scrollbar'; import { getScrollbarWidth } from 'flavours/glitch/util/scrollbar';
import MediaGallery from 'flavours/glitch/components/media_gallery'; import MediaGallery from 'flavours/glitch/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) => { handleOpenVideo = (options) => {
const media = ImmutableList([video]); 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>

@ -177,12 +177,12 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
dispatch(mentionCompose(account, router)); dispatch(mentionCompose(account, router));
}, },
onOpenMedia (media, index) { onOpenMedia (statusId, media, index) {
dispatch(openModal('MEDIA', { media, index })); dispatch(openModal('MEDIA', { statusId, media, index }));
}, },
onOpenVideo (media, options) { onOpenVideo (statusId, media, options) {
dispatch(openModal('VIDEO', { media, options })); dispatch(openModal('VIDEO', { statusId, media, options }));
}, },
onBlock (status) { onBlock (status) {

@ -328,6 +328,8 @@ class Header extends ImmutablePureComponent {
)} )}
{account.get('note').length > 0 && account.get('note') !== '<p></p>' && <div className='account__header__content translate' dangerouslySetInnerHTML={content} />} {account.get('note').length > 0 && account.get('note') !== '<p></p>' && <div className='account__header__content translate' dangerouslySetInnerHTML={content} />}
<div className='account__header__joined'><FormattedMessage id='account.joined' defaultMessage='Joined {date}' values={{ date: intl.formatDate(account.get('created_at'), { year: 'numeric', month: 'short', day: '2-digit' }) }} /></div>
</div> </div>
</div> </div>
)} )}

@ -114,15 +114,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 }));
} }
} }

@ -199,6 +199,14 @@ class ComposeForm extends ImmutablePureComponent {
} }
} }
componentDidMount () {
this._updateFocusAndSelection({ });
}
componentDidUpdate (prevProps) {
this._updateFocusAndSelection(prevProps);
}
// This statement does several things: // This statement does several things:
// - If we're beginning a reply, and, // - If we're beginning a reply, and,
// - Replying to zero or one users, places the cursor at the end // - Replying to zero or one users, places the cursor at the end
@ -206,7 +214,7 @@ class ComposeForm extends ImmutablePureComponent {
// - Replying to more than one user, selects any usernames past // - Replying to more than one user, selects any usernames past
// the first; this provides a convenient shortcut to drop // the first; this provides a convenient shortcut to drop
// everyone else from the conversation. // everyone else from the conversation.
componentDidUpdate (prevProps) { _updateFocusAndSelection = (prevProps) => {
const { const {
textarea, textarea,
spoilerText, spoilerText,

@ -47,7 +47,7 @@ class Publisher extends ImmutablePureComponent {
const diff = maxChars - length(countText || ''); const diff = maxChars - length(countText || '');
const computedClass = classNames('composer--publisher', { const computedClass = classNames('composer--publisher', {
disabled: disabled || diff < 0, disabled: disabled,
over: diff < 0, over: diff < 0,
}); });
@ -56,7 +56,7 @@ class Publisher extends ImmutablePureComponent {
{sideArm && sideArm !== 'none' ? ( {sideArm && sideArm !== 'none' ? (
<Button <Button
className='side_arm' className='side_arm'
disabled={disabled || diff < 0} disabled={disabled}
onClick={onSecondarySubmit} onClick={onSecondarySubmit}
style={{ padding: null }} style={{ padding: null }}
text={ text={
@ -110,7 +110,7 @@ class Publisher extends ImmutablePureComponent {
}()} }()}
title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${privacy}.short` })}`} title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${privacy}.short` })}`}
onClick={this.handleSubmit} onClick={this.handleSubmit}
disabled={disabled || diff < 0} disabled={disabled}
/> />
</div> </div>
); );

@ -33,6 +33,12 @@ class SearchResults extends ImmutablePureComponent {
} }
} }
componentDidUpdate () {
if (this.props.searchTerm === '') {
this.props.fetchSuggestions();
}
}
handleLoadMoreAccounts = () => this.props.expandSearch('accounts'); handleLoadMoreAccounts = () => this.props.expandSearch('accounts');
handleLoadMoreStatuses = () => this.props.expandSearch('statuses'); handleLoadMoreStatuses = () => this.props.expandSearch('statuses');
@ -42,7 +48,7 @@ class SearchResults extends ImmutablePureComponent {
render () { render () {
const { intl, results, suggestions, dismissSuggestion, searchTerm } = this.props; const { intl, results, suggestions, dismissSuggestion, searchTerm } = this.props;
if (results.isEmpty() && !suggestions.isEmpty()) { if (searchTerm === '' && !suggestions.isEmpty()) {
return ( return (
<div className='drawer--results'> <div className='drawer--results'>
<div className='trends'> <div className='trends'>
@ -51,12 +57,12 @@ class SearchResults extends ImmutablePureComponent {
<FormattedMessage id='suggestions.header' defaultMessage='You might be interested in…' /> <FormattedMessage id='suggestions.header' defaultMessage='You might be interested in…' />
</div> </div>
{suggestions && suggestions.map(accountId => ( {suggestions && suggestions.map(suggestion => (
<AccountContainer <AccountContainer
key={accountId} key={suggestion.get('account')}
id={accountId} id={suggestion.get('account')}
actionIcon='times' actionIcon={suggestion.get('source') === 'past_interaction' ? 'times' : null}
actionTitle={intl.formatMessage(messages.dismissSuggestion)} actionTitle={suggestion.get('source') === 'past_interaction' ? intl.formatMessage(messages.dismissSuggestion) : null}
onActionClick={dismissSuggestion} onActionClick={dismissSuggestion}
/> />
))} ))}

@ -5,7 +5,7 @@ import { Map as ImmutableMap } from 'immutable';
import { useEmoji } from 'flavours/glitch/actions/emojis'; import { useEmoji } from 'flavours/glitch/actions/emojis';
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { EmojiPicker as EmojiPickerAsync } from 'flavours/glitch/util/async-components'; import { EmojiPicker as EmojiPickerAsync } from 'flavours/glitch/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';
@ -18,7 +18,6 @@ import { assetHost } from 'flavours/glitch/util/config';
const messages = defineMessages({ const messages = defineMessages({
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }, emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search...' }, emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search...' },
emoji_not_found: { id: 'emoji_button.not_found', defaultMessage: 'No emojos!! (╯°□°)╯︵ ┻━┻' },
custom: { id: 'emoji_button.custom', defaultMessage: 'Custom' }, custom: { id: 'emoji_button.custom', defaultMessage: 'Custom' },
recent: { id: 'emoji_button.recent', defaultMessage: 'Frequently used' }, recent: { id: 'emoji_button.recent', defaultMessage: 'Frequently used' },
search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' }, search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' },
@ -108,9 +107,26 @@ const mapDispatchToProps = (dispatch, { onPickEmoji }) => ({
let EmojiPicker, Emoji; // load asynchronously let EmojiPicker, Emoji; // load asynchronously
const backgroundImageFn = () => `${assetHost}/emoji/sheet_10.png`;
const listenerOptions = supportsPassiveEvents ? { passive: true } : false; const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
const backgroundImageFn = () => `${assetHost}/emoji/sheet_13.png`;
const notFoundFn = () => (
<div className='emoji-mart-no-results'>
<Emoji
emoji='sleuth_or_spy'
set='twitter'
size={32}
sheetSize={32}
backgroundImageFn={backgroundImageFn}
/>
<div className='emoji-mart-no-results-label'>
<FormattedMessage id='emoji_button.not_found' defaultMessage='No matching emojis found' />
</div>
</div>
);
class ModifierPickerMenu extends React.PureComponent { class ModifierPickerMenu extends React.PureComponent {
static propTypes = { static propTypes = {
@ -262,7 +278,6 @@ class EmojiPickerMenu extends React.PureComponent {
return { return {
search: intl.formatMessage(messages.emoji_search), search: intl.formatMessage(messages.emoji_search),
notfound: intl.formatMessage(messages.emoji_not_found),
categories: { categories: {
search: intl.formatMessage(messages.search_results), search: intl.formatMessage(messages.search_results),
recent: intl.formatMessage(messages.recent), recent: intl.formatMessage(messages.recent),
@ -343,7 +358,9 @@ class EmojiPickerMenu extends React.PureComponent {
recent={frequentlyUsedEmojis} recent={frequentlyUsedEmojis}
skin={skinTone} skin={skinTone}
showPreview={false} showPreview={false}
showSkinTones={false}
backgroundImageFn={backgroundImageFn} backgroundImageFn={backgroundImageFn}
notFound={notFoundFn}
autoFocus autoFocus
emojiTooltip emojiTooltip
native={useSystemEmojiFont} native={useSystemEmojiFont}

@ -0,0 +1,85 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { makeGetAccount } from 'flavours/glitch/selectors';
import Avatar from 'flavours/glitch/components/avatar';
import DisplayName from 'flavours/glitch/components/display_name';
import Permalink from 'flavours/glitch/components/permalink';
import IconButton from 'flavours/glitch/components/icon_button';
import { injectIntl, defineMessages } from 'react-intl';
import { followAccount, unfollowAccount } from 'flavours/glitch/actions/accounts';
const messages = defineMessages({
follow: { id: 'account.follow', defaultMessage: 'Follow' },
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
});
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, props) => ({
account: getAccount(state, props.id),
});
return mapStateToProps;
};
const getFirstSentence = str => {
const arr = str.split(/(([\.\?!]+\s)|[.。?!\n•])/);
return arr[0];
};
export default @connect(makeMapStateToProps)
@injectIntl
class Account extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
intl: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
};
handleFollow = () => {
const { account, dispatch } = this.props;
if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
dispatch(unfollowAccount(account.get('id')));
} else {
dispatch(followAccount(account.get('id')));
}
}
render () {
const { account, intl } = this.props;
let button;
if (account.getIn(['relationship', 'following'])) {
button = <IconButton icon='check' title={intl.formatMessage(messages.unfollow)} active onClick={this.handleFollow} />;
} else {
button = <IconButton icon='plus' title={intl.formatMessage(messages.follow)} onClick={this.handleFollow} />;
}
return (
<div className='account follow-recommendations-account'>
<div className='account__wrapper'>
<Permalink className='account__display-name account__display-name--with-note' title={account.get('acct')} href={account.get('url')} to={`/accounts/${account.get('id')}`}>
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
<DisplayName account={account} />
<div className='account__note'>{getFirstSentence(account.get('note_plain'))}</div>
</Permalink>
<div className='account__relationship'>
{button}
</div>
</div>
</div>
);
}
}

@ -0,0 +1,109 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePureComponent from 'react-immutable-pure-component';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import { FormattedMessage } from 'react-intl';
import { fetchSuggestions } from 'flavours/glitch/actions/suggestions';
import { changeSetting, saveSettings } from 'flavours/glitch/actions/settings';
import { requestBrowserPermission } from 'flavours/glitch/actions/notifications';
import { markAsPartial } from 'flavours/glitch/actions/timelines';
import Column from 'flavours/glitch/features/ui/components/column';
import Account from './components/account';
import Logo from 'flavours/glitch/components/logo';
import imageGreeting from 'mastodon/../images/elephant_ui_greeting.svg';
import Button from 'flavours/glitch/components/button';
const mapStateToProps = state => ({
suggestions: state.getIn(['suggestions', 'items']),
isLoading: state.getIn(['suggestions', 'isLoading']),
});
export default @connect(mapStateToProps)
class FollowRecommendations extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object.isRequired,
};
static propTypes = {
dispatch: PropTypes.func.isRequired,
suggestions: ImmutablePropTypes.list,
isLoading: PropTypes.bool,
};
componentDidMount () {
const { dispatch, suggestions } = this.props;
// Don't re-fetch if we're e.g. navigating backwards to this page,
// since we don't want followed accounts to disappear from the list
if (suggestions.size === 0) {
dispatch(fetchSuggestions(true));
}
}
componentWillUnmount () {
const { dispatch } = this.props;
// Force the home timeline to be reloaded when the user navigates
// to it; if the user is new, it would've been empty before
dispatch(markAsPartial('home'));
}
handleDone = () => {
const { dispatch } = this.props;
const { router } = this.context;
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());
}
}));
router.history.push('/timelines/home');
}
render () {
const { suggestions, isLoading } = this.props;
return (
<Column>
<div className='scrollable follow-recommendations-container'>
<div className='column-title'>
<Logo />
<h3><FormattedMessage id='follow_recommendations.heading' defaultMessage="Follow people you'd like to see posts from! Here are some suggestions." /></h3>
<p><FormattedMessage id='follow_recommendations.lead' defaultMessage="Posts from people you follow will show up in chronological order on your home feed. Don't be afraid to make mistakes, you can unfollow people just as easily any time!" /></p>
</div>
{!isLoading && (
<React.Fragment>
<div className='column-list'>
{suggestions.size > 0 ? suggestions.map(suggestion => (
<Account key={suggestion.get('account')} id={suggestion.get('account')} />
)) : (
<div className='column-list__empty-message'>
<FormattedMessage id='empty_column.follow_recommendations' defaultMessage='Looks like no suggestions could be generated for you. You can try using search to look for people you might know or explore trending hashtags.' />
</div>
)}
</div>
<div className='column-actions'>
<img src={imageGreeting} alt='' className='column-actions__background' />
<Button onClick={this.handleDone}><FormattedMessage id='follow_recommendations.done' defaultMessage='Done' /></Button>
</div>
</React.Fragment>
)}
</div>
</Column>
);
}
}

@ -59,7 +59,7 @@ class ColumnSettings extends React.PureComponent {
{this.modeLabel(mode)} {this.modeLabel(mode)}
</span> </span>
<NonceProvider nonce={document.querySelector('meta[name=style-nonce]').content}> <NonceProvider nonce={document.querySelector('meta[name=style-nonce]').content} cacheKey='tags'>
<AsyncSelect <AsyncSelect
isMulti isMulti
autoFocus autoFocus

@ -72,7 +72,7 @@ class HomeTimeline extends React.PureComponent {
} }
componentDidMount () { componentDidMount () {
this.props.dispatch(fetchAnnouncements()); setTimeout(() => this.props.dispatch(fetchAnnouncements()), 700);
this._checkIfReloadNeeded(false, this.props.isPartial); this._checkIfReloadNeeded(false, this.props.isPartial);
} }
@ -152,7 +152,7 @@ class HomeTimeline extends React.PureComponent {
scrollKey={`home_timeline-${columnId}`} scrollKey={`home_timeline-${columnId}`}
onLoadMore={this.handleLoadMore} onLoadMore={this.handleLoadMore}
timelineId='home' timelineId='home'
emptyMessage={<FormattedMessage id='empty_column.home' defaultMessage='Your home timeline is empty! Visit {public} or use search to get started and meet other users.' values={{ public: <Link to='/timelines/public'><FormattedMessage id='empty_column.home.public_timeline' defaultMessage='the public timeline' /></Link> }} />} emptyMessage={<FormattedMessage id='empty_column.home' defaultMessage='Your home timeline is empty! Follow more people to fill it up. {suggestions}' values={{ suggestions: <Link to='/start'><FormattedMessage id='empty_column.home.suggestions' defaultMessage='See some suggestions' /></Link> }} />}
bindToDocument={!multiColumn} bindToDocument={!multiColumn}
/> />
</Column> </Column>

@ -113,14 +113,6 @@ class LocalSettingsPage extends React.PureComponent {
<FormattedMessage id='settings.notifications.favicon_badge' defaultMessage='Unread notifications favicon badge' /> <FormattedMessage id='settings.notifications.favicon_badge' defaultMessage='Unread notifications favicon badge' />
<span className='hint'><FormattedMessage id='settings.notifications.favicon_badge.hint' defaultMessage="Add a badge for unread notifications to the favicon" /></span> <span className='hint'><FormattedMessage id='settings.notifications.favicon_badge.hint' defaultMessage="Add a badge for unread notifications to the favicon" /></span>
</LocalSettingsPageItem> </LocalSettingsPageItem>
<LocalSettingsPageItem
settings={settings}
item={['notifications', 'show_unread']}
id='mastodon-settings--notifications-show_unread'
onChange={onChange}
>
<FormattedMessage id='settings.notifications.show_unread' defaultMessage='Show unread marker' />
</LocalSettingsPageItem>
</section> </section>
<section> <section>
<h2><FormattedMessage id='settings.layout_opts' defaultMessage='Layout options' /></h2> <h2><FormattedMessage id='settings.layout_opts' defaultMessage='Layout options' /></h2>

@ -5,6 +5,7 @@ import { FormattedMessage } from 'react-intl';
import ClearColumnButton from './clear_column_button'; import ClearColumnButton from './clear_column_button';
import GrantPermissionButton from './grant_permission_button'; import GrantPermissionButton from './grant_permission_button';
import SettingToggle from './setting_toggle'; import SettingToggle from './setting_toggle';
import PillBarButton from './pill_bar_button';
export default class ColumnSettings extends React.PureComponent { export default class ColumnSettings extends React.PureComponent {
@ -34,7 +35,6 @@ export default class ColumnSettings extends React.PureComponent {
const showPushSettings = pushSettings.get('browserSupport') && pushSettings.get('isSubscribed'); const showPushSettings = pushSettings.get('browserSupport') && pushSettings.get('isSubscribed');
const pushStr = showPushSettings && <FormattedMessage id='notifications.column_settings.push' defaultMessage='Push notifications' />; const pushStr = showPushSettings && <FormattedMessage id='notifications.column_settings.push' defaultMessage='Push notifications' />;
const pushMeta = showPushSettings && <FormattedMessage id='notifications.column_settings.push_meta' defaultMessage='This device' />;
return ( return (
<div> <div>
@ -56,6 +56,16 @@ export default class ColumnSettings extends React.PureComponent {
<ClearColumnButton onClick={onClear} /> <ClearColumnButton onClick={onClear} />
</div> </div>
<div role='group' aria-labelledby='notifications-unread-markers'>
<span id='notifications-unread-markers' className='column-settings__section'>
<FormattedMessage id='notifications.column_settings.unread_markers.category' defaultMessage='Unread notification markers' />
</span>
<div className='column-settings__row'>
<SettingToggle id='unread-notification-markers' prefix='notifications' settings={settings} settingPath={['showUnread']} onChange={onChange} label={filterShowStr} />
</div>
</div>
<div role='group' aria-labelledby='notifications-filter-bar'> <div role='group' aria-labelledby='notifications-filter-bar'>
<span id='notifications-filter-bar' className='column-settings__section'> <span id='notifications-filter-bar' className='column-settings__section'>
<FormattedMessage id='notifications.column_settings.filter_bar.category' defaultMessage='Quick filter bar' /> <FormattedMessage id='notifications.column_settings.filter_bar.category' defaultMessage='Quick filter bar' />
@ -70,77 +80,77 @@ export default class ColumnSettings extends React.PureComponent {
<div role='group' aria-labelledby='notifications-follow'> <div role='group' aria-labelledby='notifications-follow'>
<span id='notifications-follow' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span> <span id='notifications-follow' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span>
<div className='column-settings__row'> <div className='column-settings__pillbar'>
<SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow']} onChange={onChange} label={alertStr} /> <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow']} onChange={onChange} label={alertStr} />
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />} {showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow']} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'follow']} onChange={onChange} label={showStr} /> <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'follow']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'follow']} onChange={onChange} label={soundStr} /> <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'follow']} onChange={onChange} label={soundStr} />
</div> </div>
</div> </div>
<div role='group' aria-labelledby='notifications-follow-request'> <div role='group' aria-labelledby='notifications-follow-request'>
<span id='notifications-follow-request' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow_request' defaultMessage='New follow requests:' /></span> <span id='notifications-follow-request' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow_request' defaultMessage='New follow requests:' /></span>
<div className='column-settings__row'> <div className='column-settings__pillbar'>
<SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow_request']} onChange={onChange} label={alertStr} /> <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow_request']} onChange={onChange} label={alertStr} />
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow_request']} onChange={this.onPushChange} label={pushStr} />} {showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow_request']} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'follow_request']} onChange={onChange} label={showStr} /> <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'follow_request']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'follow_request']} onChange={onChange} label={soundStr} /> <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'follow_request']} onChange={onChange} label={soundStr} />
</div> </div>
</div> </div>
<div role='group' aria-labelledby='notifications-favourite'> <div role='group' aria-labelledby='notifications-favourite'>
<span id='notifications-favourite' className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span> <span id='notifications-favourite' className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span>
<div className='column-settings__row'> <div className='column-settings__pillbar'>
<SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'favourite']} onChange={onChange} label={alertStr} /> <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'favourite']} onChange={onChange} label={alertStr} />
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'favourite']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />} {showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'favourite']} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'favourite']} onChange={onChange} label={showStr} /> <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'favourite']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'favourite']} onChange={onChange} label={soundStr} /> <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'favourite']} onChange={onChange} label={soundStr} />
</div> </div>
</div> </div>
<div role='group' aria-labelledby='notifications-mention'> <div role='group' aria-labelledby='notifications-mention'>
<span id='notifications-mention' className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span> <span id='notifications-mention' className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span>
<div className='column-settings__row'> <div className='column-settings__pillbar'>
<SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'mention']} onChange={onChange} label={alertStr} /> <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'mention']} onChange={onChange} label={alertStr} />
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'mention']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />} {showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'mention']} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'mention']} onChange={onChange} label={showStr} /> <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'mention']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'mention']} onChange={onChange} label={soundStr} /> <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'mention']} onChange={onChange} label={soundStr} />
</div> </div>
</div> </div>
<div role='group' aria-labelledby='notifications-reblog'> <div role='group' aria-labelledby='notifications-reblog'>
<span id='notifications-reblog' className='column-settings__section'><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span> <span id='notifications-reblog' className='column-settings__section'><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span>
<div className='column-settings__row'> <div className='column-settings__pillbar'>
<SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'reblog']} onChange={onChange} label={alertStr} /> <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'reblog']} onChange={onChange} label={alertStr} />
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'reblog']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />} {showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'reblog']} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'reblog']} onChange={onChange} label={showStr} /> <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'reblog']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'reblog']} onChange={onChange} label={soundStr} /> <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'reblog']} onChange={onChange} label={soundStr} />
</div> </div>
</div> </div>
<div role='group' aria-labelledby='notifications-poll'> <div role='group' aria-labelledby='notifications-poll'>
<span id='notifications-poll' className='column-settings__section'><FormattedMessage id='notifications.column_settings.poll' defaultMessage='Poll results:' /></span> <span id='notifications-poll' className='column-settings__section'><FormattedMessage id='notifications.column_settings.poll' defaultMessage='Poll results:' /></span>
<div className='column-settings__row'> <div className='column-settings__pillbar'>
<SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'poll']} onChange={onChange} label={alertStr} /> <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'poll']} onChange={onChange} label={alertStr} />
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'poll']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />} {showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'poll']} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'poll']} onChange={onChange} label={showStr} /> <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'poll']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'poll']} onChange={onChange} label={soundStr} /> <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'poll']} onChange={onChange} label={soundStr} />
</div> </div>
</div> </div>
<div role='group' aria-labelledby='notifications-status'> <div role='group' aria-labelledby='notifications-status'>
<span id='notifications-status' className='column-settings__section'><FormattedMessage id='notifications.column_settings.status' defaultMessage='New toots:' /></span> <span id='notifications-status' className='column-settings__section'><FormattedMessage id='notifications.column_settings.status' defaultMessage='New toots:' /></span>
<div className='column-settings__row'> <div className='column-settings__pillbar'>
<SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'status']} onChange={onChange} label={alertStr} /> <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'status']} onChange={onChange} label={alertStr} />
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'status']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />} {showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'status']} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'status']} onChange={onChange} label={showStr} /> <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'status']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'status']} onChange={onChange} label={soundStr} /> <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'status']} onChange={onChange} label={soundStr} />
</div> </div>
</div> </div>
</div> </div>

@ -0,0 +1,41 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import classNames from 'classnames'
export default class PillBarButton extends React.PureComponent {
static propTypes = {
prefix: PropTypes.string,
settings: ImmutablePropTypes.map.isRequired,
settingPath: PropTypes.array.isRequired,
label: PropTypes.node.isRequired,
onChange: PropTypes.func.isRequired,
disabled: PropTypes.bool,
}
onChange = () => {
const { settings, settingPath } = this.props;
this.props.onChange(settingPath, !settings.getIn(settingPath));
}
render () {
const { prefix, settings, settingPath, label, disabled } = this.props;
const id = ['setting-pillbar-button', prefix, ...settingPath].filter(Boolean).join('-');
const active = settings.getIn(settingPath);
return (
<button
key={id}
id={id}
className={classNames('pillbar-button', { active })}
disabled={disabled}
onClick={this.onChange}
aria-pressed={active}
>
{label}
</button>
);
}
}

@ -67,8 +67,8 @@ const mapStateToProps = state => ({
hasMore: state.getIn(['notifications', 'hasMore']), hasMore: state.getIn(['notifications', 'hasMore']),
numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size, numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size,
notifCleaningActive: state.getIn(['notifications', 'cleaningMode']), notifCleaningActive: state.getIn(['notifications', 'cleaningMode']),
lastReadId: state.getIn(['local_settings', 'notifications', 'show_unread']) ? state.getIn(['notifications', 'readMarkerId']) : '0', lastReadId: state.getIn(['settings', 'notifications', 'showUnread']) ? state.getIn(['notifications', 'readMarkerId']) : '0',
canMarkAsRead: state.getIn(['local_settings', 'notifications', 'show_unread']) && state.getIn(['notifications', 'readMarkerId']) !== '0' && getNotifications(state).some(item => item !== null && compareId(item.get('id'), state.getIn(['notifications', 'readMarkerId'])) > 0), canMarkAsRead: state.getIn(['settings', 'notifications', 'showUnread']) && state.getIn(['notifications', 'readMarkerId']) !== '0' && getNotifications(state).some(item => item !== null && compareId(item.get('id'), state.getIn(['notifications', 'readMarkerId'])) > 0),
needsNotificationPermission: state.getIn(['settings', 'notifications', 'alerts']).includes(true) && state.getIn(['notifications', 'browserSupport']) && state.getIn(['notifications', 'browserPermission']) === 'default' && !state.getIn(['settings', 'notifications', 'dismissPermissionBanner']), needsNotificationPermission: state.getIn(['settings', 'notifications', 'alerts']).includes(true) && state.getIn(['notifications', 'browserSupport']) && state.getIn(['notifications', 'browserPermission']) === 'default' && !state.getIn(['settings', 'notifications', 'dismissPermissionBanner']),
}); });
@ -224,7 +224,7 @@ class Notifications extends React.PureComponent {
const { notifCleaning, notifCleaningActive } = this.props; const { notifCleaning, notifCleaningActive } = this.props;
const { animatingNCD } = this.state; const { animatingNCD } = this.state;
const pinned = !!columnId; const pinned = !!columnId;
const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />; const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. When other people interact with you, you will see it here." />;
let scrollableContent = null; let scrollableContent = null;

@ -23,6 +23,7 @@ const messages = defineMessages({
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
open: { id: 'status.open', defaultMessage: 'Expand this status' },
}); });
const makeMapStateToProps = () => { const makeMapStateToProps = () => {
@ -52,11 +53,19 @@ class Footer extends ImmutablePureComponent {
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
askReplyConfirmation: PropTypes.bool, askReplyConfirmation: PropTypes.bool,
showReplyCount: PropTypes.bool, showReplyCount: PropTypes.bool,
withOpenButton: PropTypes.bool,
onClose: PropTypes.func,
}; };
_performReply = () => { _performReply = () => {
const { dispatch, status } = this.props; const { dispatch, status, onClose } = this.props;
dispatch(replyCompose(status, this.context.router.history)); const { router } = this.context;
if (onClose) {
onClose();
}
dispatch(replyCompose(status, router.history));
}; };
handleReplyClick = () => { handleReplyClick = () => {
@ -100,8 +109,20 @@ class Footer extends ImmutablePureComponent {
} }
}; };
handleOpenClick = e => {
const { router } = this.context;
if (e.button !== 0 || !router) {
return;
}
const { status } = this.props;
router.history.push(`/statuses/${status.get('id')}`);
}
render () { render () {
const { status, intl, showReplyCount } = this.props; const { status, intl, showReplyCount, withOpenButton } = this.props;
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private'; const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private';
@ -156,6 +177,7 @@ class Footer extends ImmutablePureComponent {
{replyButton} {replyButton}
<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={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='star' onClick={this.handleFavouriteClick} counter={status.get('favourites_count')} /> <IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={status.get('favourites_count')} />
{withOpenButton && <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.open)} icon='external-link' onClick={this.handleOpenClick} />}
</div> </div>
); );
} }

@ -68,8 +68,8 @@ export default class DetailedStatus extends ImmutablePureComponent {
e.stopPropagation(); e.stopPropagation();
} }
handleOpenVideo = (media, options) => { handleOpenVideo = (options) => {
this.props.onOpenVideo(media, options); this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), options);
} }
_measureHeight (heightJustChanged) { _measureHeight (heightJustChanged) {

@ -316,11 +316,11 @@ class Status extends ImmutablePureComponent {
} }
handleOpenMedia = (media, index) => { handleOpenMedia = (media, index) => {
this.props.dispatch(openModal('MEDIA', { media, index })); this.props.dispatch(openModal('MEDIA', { statusId: this.props.status.get('id'), media, index }));
} }
handleOpenVideo = (media, options) => { handleOpenVideo = (media, options) => {
this.props.dispatch(openModal('VIDEO', { media, options })); this.props.dispatch(openModal('VIDEO', { statusId: this.props.status.get('id'), media, options }));
} }
handleHotkeyOpenMedia = e => { handleHotkeyOpenMedia = e => {
@ -329,9 +329,7 @@ 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') { if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
// TODO: toggle play/paused?
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
this.handleOpenVideo(status.getIn(['media_attachments', 0]), { startTime: 0 }); this.handleOpenVideo(status.getIn(['media_attachments', 0]), { startTime: 0 });
} else { } else {
this.handleOpenMedia(status.get('media_attachments'), 0); this.handleOpenMedia(status.get('media_attachments'), 0);

@ -4,12 +4,10 @@ import PropTypes from 'prop-types';
import Audio from 'flavours/glitch/features/audio'; import Audio from 'flavours/glitch/features/audio';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { FormattedMessage } from 'react-intl'; import Footer from 'flavours/glitch/features/picture_in_picture/components/footer';
import classNames from 'classnames';
import Icon from 'flavours/glitch/components/icon';
const mapStateToProps = (state, { status }) => ({ const mapStateToProps = (state, { statusId }) => ({
account: state.getIn(['accounts', status.get('account')]), accountStaticAvatar: state.getIn(['accounts', state.getIn(['statuses', statusId, 'account']), 'avatar_static']),
}); });
export default @connect(mapStateToProps) export default @connect(mapStateToProps)
@ -17,27 +15,21 @@ class AudioModal extends ImmutablePureComponent {
static propTypes = { static propTypes = {
media: ImmutablePropTypes.map.isRequired, media: ImmutablePropTypes.map.isRequired,
status: ImmutablePropTypes.map, statusId: PropTypes.string.isRequired,
accountStaticAvatar: PropTypes.string.isRequired,
options: PropTypes.shape({ options: PropTypes.shape({
autoPlay: PropTypes.bool, autoPlay: PropTypes.bool,
}), }),
account: ImmutablePropTypes.map,
onClose: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired,
onChangeBackgroundColor: PropTypes.func.isRequired,
}; };
static contextTypes = { static contextTypes = {
router: PropTypes.object, router: PropTypes.object,
}; };
handleStatusClick = e => {
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
}
}
render () { render () {
const { media, status, account } = this.props; const { media, accountStaticAvatar, statusId, onClose } = this.props;
const options = this.props.options || {}; const options = this.props.options || {};
return ( return (
@ -48,7 +40,7 @@ class AudioModal extends ImmutablePureComponent {
alt={media.get('description')} alt={media.get('description')}
duration={media.getIn(['meta', 'original', 'duration'], 0)} duration={media.getIn(['meta', 'original', 'duration'], 0)}
height={150} height={150}
poster={media.get('preview_url') || account.get('avatar_static')} poster={media.get('preview_url') || accountStaticAvatar}
backgroundColor={media.getIn(['meta', 'colors', 'background'])} backgroundColor={media.getIn(['meta', 'colors', 'background'])}
foregroundColor={media.getIn(['meta', 'colors', 'foreground'])} foregroundColor={media.getIn(['meta', 'colors', 'foreground'])}
accentColor={media.getIn(['meta', 'colors', 'accent'])} accentColor={media.getIn(['meta', 'colors', 'accent'])}
@ -56,11 +48,9 @@ class AudioModal extends ImmutablePureComponent {
/> />
</div> </div>
{status && ( <div className='media-modal__overlay'>
<div className={classNames('media-modal__meta')}> {statusId && <Footer statusId={statusId} withOpenButton onClose={onClose} />}
<a href={status.get('url')} onClick={this.handleStatusClick}><Icon id='comments' /> <FormattedMessage id='lightbox.view_context' defaultMessage='View context' /></a> </div>
</div>
)}
</div> </div>
); );
} }

@ -47,7 +47,7 @@ const componentMap = {
'DIRECTORY': Directory, 'DIRECTORY': Directory,
}; };
const shouldHideFAB = path => path.match(/^\/statuses\/|^\/search|^\/getting-started/); const shouldHideFAB = path => path.match(/^\/statuses\/|^\/search|^\/getting-started|^\/start/);
const messages = defineMessages({ const messages = defineMessages({
publish: { id: 'compose_form.publish', defaultMessage: 'Toot' }, publish: { id: 'compose_form.publish', defaultMessage: 'Toot' },
@ -70,8 +70,12 @@ class ColumnsArea extends ImmutablePureComponent {
openSettings: PropTypes.func, openSettings: PropTypes.func,
}; };
// Corresponds to (max-width: 600px + (285px * 1) + (10px * 1)) in SCSS
mediaQuery = 'matchMedia' in window && window.matchMedia('(max-width: 895px)');
state = { state = {
shouldAnimate: false, shouldAnimate: false,
renderComposePanel: !(this.mediaQuery && this.mediaQuery.matches),
} }
componentWillReceiveProps() { componentWillReceiveProps() {
@ -85,6 +89,15 @@ class ColumnsArea extends ImmutablePureComponent {
this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false); this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
} }
if (this.mediaQuery) {
if (this.mediaQuery.addEventListener) {
this.mediaQuery.addEventListener('change', this.handleLayoutChange);
} else {
this.mediaQuery.addListener(this.handleLayoutChange);
}
this.setState({ renderComposePanel: !this.mediaQuery.matches });
}
this.lastIndex = getIndex(this.context.router.history.location.pathname); this.lastIndex = getIndex(this.context.router.history.location.pathname);
this.isRtlLayout = document.getElementsByTagName('body')[0].classList.contains('rtl'); this.isRtlLayout = document.getElementsByTagName('body')[0].classList.contains('rtl');
@ -114,6 +127,14 @@ class ColumnsArea extends ImmutablePureComponent {
if (!this.props.singleColumn) { if (!this.props.singleColumn) {
this.node.removeEventListener('wheel', this.handleWheel); this.node.removeEventListener('wheel', this.handleWheel);
} }
if (this.mediaQuery) {
if (this.mediaQuery.removeEventListener) {
this.mediaQuery.removeEventListener('change', this.handleLayoutChange);
} else {
this.mediaQuery.removeListener(this.handleLayouteChange);
}
}
} }
handleChildrenContentChange() { handleChildrenContentChange() {
@ -123,6 +144,10 @@ class ColumnsArea extends ImmutablePureComponent {
} }
} }
handleLayoutChange = (e) => {
this.setState({ renderComposePanel: !e.matches });
}
handleSwipe = (index) => { handleSwipe = (index) => {
this.pendingIndex = index; this.pendingIndex = index;
@ -186,7 +211,7 @@ class ColumnsArea extends ImmutablePureComponent {
render () { render () {
const { columns, children, singleColumn, swipeToChangeColumns, intl, navbarUnder, openSettings } = this.props; const { columns, children, singleColumn, swipeToChangeColumns, intl, navbarUnder, openSettings } = this.props;
const { shouldAnimate } = this.state; const { shouldAnimate, renderComposePanel } = this.state;
const columnIndex = getIndex(this.context.router.history.location.pathname); const columnIndex = getIndex(this.context.router.history.location.pathname);
@ -205,7 +230,7 @@ class ColumnsArea extends ImmutablePureComponent {
<div className='columns-area__panels'> <div className='columns-area__panels'>
<div className='columns-area__panels__pane columns-area__panels__pane--compositional'> <div className='columns-area__panels__pane columns-area__panels__pane--compositional'>
<div className='columns-area__panels__pane__inner'> <div className='columns-area__panels__pane__inner'>
<ComposePanel /> {renderComposePanel && <ComposePanel />}
</div> </div>
</div> </div>

@ -219,6 +219,10 @@ class FocalPointModal extends ImmutablePureComponent {
} }
handleTextDetection = () => { handleTextDetection = () => {
this._detectText();
}
_detectText = (refreshCache = false) => {
const { media } = this.props; const { media } = this.props;
this.setState({ detecting: true }); this.setState({ detecting: true });
@ -235,6 +239,7 @@ class FocalPointModal extends ImmutablePureComponent {
this.setState({ ocrStatus: 'preparing', progress }); this.setState({ ocrStatus: 'preparing', progress });
} }
}, },
cacheMethod: refreshCache ? 'refresh' : 'write',
}); });
let media_url = media.get('url'); let media_url = media.get('url');
@ -247,14 +252,20 @@ class FocalPointModal extends ImmutablePureComponent {
} }
} }
(async () => { return (async () => {
await worker.load(); await worker.load();
await worker.loadLanguage('eng'); await worker.loadLanguage('eng');
await worker.initialize('eng'); await worker.initialize('eng');
const { data: { text } } = await worker.recognize(media_url); const { data: { text } } = await worker.recognize(media_url);
this.setState({ description: removeExtraLineBreaks(text), dirty: true, detecting: false }); this.setState({ description: removeExtraLineBreaks(text), dirty: true, detecting: false });
await worker.terminate(); await worker.terminate();
})(); })().catch((e) => {
if (refreshCache) {
throw e;
} else {
this._detectText(true);
}
});
}).catch((e) => { }).catch((e) => {
console.error(e); console.error(e);
this.setState({ detecting: false }); this.setState({ detecting: false });
@ -309,7 +320,7 @@ class FocalPointModal extends ImmutablePureComponent {
return ( return (
<div className='modal-root__modal report-modal' style={{ maxWidth: 960 }}> <div className='modal-root__modal report-modal' style={{ maxWidth: 960 }}>
<div className='report-modal__target'> <div className='report-modal__target'>
<IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} /> <IconButton className='report-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={20} />
<FormattedMessage id='upload_modal.edit_media' defaultMessage='Edit media' /> <FormattedMessage id='upload_modal.edit_media' defaultMessage='Edit media' />
</div> </div>

@ -4,12 +4,14 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import Video from 'flavours/glitch/features/video'; import Video from 'flavours/glitch/features/video';
import classNames from 'classnames'; import classNames from 'classnames';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl } from 'react-intl';
import IconButton from 'flavours/glitch/components/icon_button'; import IconButton from 'flavours/glitch/components/icon_button';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import ImageLoader from './image_loader'; import ImageLoader from './image_loader';
import Icon from 'flavours/glitch/components/icon'; import Icon from 'flavours/glitch/components/icon';
import GIFV from 'flavours/glitch/components/gifv'; import GIFV from 'flavours/glitch/components/gifv';
import Footer from 'flavours/glitch/features/picture_in_picture/components/footer';
import { getAverageFromBlurhash } from 'flavours/glitch/blurhash';
const messages = defineMessages({ const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' }, close: { id: 'lightbox.close', defaultMessage: 'Close' },
@ -26,10 +28,14 @@ class MediaModal extends ImmutablePureComponent {
static propTypes = { static propTypes = {
media: ImmutablePropTypes.list.isRequired, media: ImmutablePropTypes.list.isRequired,
status: ImmutablePropTypes.map, statusId: PropTypes.string,
index: PropTypes.number.isRequired, index: PropTypes.number.isRequired,
onClose: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
onChangeBackgroundColor: PropTypes.func.isRequired,
currentTime: PropTypes.number,
autoPlay: PropTypes.bool,
volume: PropTypes.number,
}; };
state = { state = {
@ -64,6 +70,7 @@ class MediaModal extends ImmutablePureComponent {
handleChangeIndex = (e) => { handleChangeIndex = (e) => {
const index = Number(e.currentTarget.getAttribute('data-index')); const index = Number(e.currentTarget.getAttribute('data-index'));
this.setState({ this.setState({
index: index % this.props.media.size, index: index % this.props.media.size,
zoomButtonHidden: true, zoomButtonHidden: true,
@ -87,10 +94,12 @@ class MediaModal extends ImmutablePureComponent {
componentDidMount () { componentDidMount () {
window.addEventListener('keydown', this.handleKeyDown, false); window.addEventListener('keydown', this.handleKeyDown, false);
this._sendBackgroundColor();
} }
componentWillUnmount () { componentWillUnmount () {
window.removeEventListener('keydown', this.handleKeyDown); window.removeEventListener('keydown', this.handleKeyDown);
this.props.onChangeBackgroundColor(null);
} }
getIndex () { getIndex () {
@ -106,30 +115,38 @@ class MediaModal extends ImmutablePureComponent {
handleStatusClick = e => { handleStatusClick = e => {
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault(); e.preventDefault();
this.context.router.history.push(`/statuses/${this.props.status.get('id')}`); this.context.router.history.push(`/statuses/${this.props.statusId}`);
}
this._sendBackgroundColor();
}
componentDidUpdate (prevProps, prevState) {
if (prevState.index !== this.state.index) {
this._sendBackgroundColor();
}
}
_sendBackgroundColor () {
const { media, onChangeBackgroundColor } = this.props;
const index = this.getIndex();
const blurhash = media.getIn([index, 'blurhash']);
if (blurhash) {
const backgroundColor = getAverageFromBlurhash(blurhash);
onChangeBackgroundColor(backgroundColor);
} }
} }
render () { render () {
const { media, status, intl, onClose } = this.props; const { media, statusId, intl, onClose } = this.props;
const { navigationHidden } = this.state; const { navigationHidden } = this.state;
const index = this.getIndex(); const index = this.getIndex();
let pagination = [];
const leftNav = media.size > 1 && <button tabIndex='0' className='media-modal__nav media-modal__nav--left' onClick={this.handlePrevClick} aria-label={intl.formatMessage(messages.previous)}><Icon id='chevron-left' fixedWidth /></button>; const leftNav = media.size > 1 && <button tabIndex='0' className='media-modal__nav media-modal__nav--left' onClick={this.handlePrevClick} aria-label={intl.formatMessage(messages.previous)}><Icon id='chevron-left' fixedWidth /></button>;
const rightNav = media.size > 1 && <button tabIndex='0' className='media-modal__nav media-modal__nav--right' onClick={this.handleNextClick} aria-label={intl.formatMessage(messages.next)}><Icon id='chevron-right' fixedWidth /></button>; const rightNav = media.size > 1 && <button tabIndex='0' className='media-modal__nav media-modal__nav--right' onClick={this.handleNextClick} aria-label={intl.formatMessage(messages.next)}><Icon id='chevron-right' fixedWidth /></button>;
if (media.size > 1) {
pagination = media.map((item, i) => {
const classes = ['media-modal__button'];
if (i === index) {
classes.push('media-modal__button--active');
}
return (<li className='media-modal__page-dot' key={i}><button tabIndex='0' className={classes.join(' ')} onClick={this.handleChangeIndex} data-index={i}>{i + 1}</button></li>);
});
}
const content = media.map((image) => { const content = media.map((image) => {
const width = image.getIn(['meta', 'original', 'width']) || null; const width = image.getIn(['meta', 'original', 'width']) || null;
const height = image.getIn(['meta', 'original', 'height']) || null; const height = image.getIn(['meta', 'original', 'height']) || null;
@ -148,7 +165,7 @@ class MediaModal extends ImmutablePureComponent {
/> />
); );
} else if (image.get('type') === 'video') { } else if (image.get('type') === 'video') {
const { time } = this.props; const { currentTime, autoPlay, volume } = this.props;
return ( return (
<Video <Video
@ -157,7 +174,10 @@ class MediaModal extends ImmutablePureComponent {
src={image.get('url')} src={image.get('url')}
width={image.get('width')} width={image.get('width')}
height={image.get('height')} height={image.get('height')}
currentTime={time || 0} frameRate={image.getIn(['meta', 'original', 'frame_rate'])}
currentTime={currentTime || 0}
autoPlay={autoPlay || false}
volume={volume || 1}
onCloseVideo={onClose} onCloseVideo={onClose}
detailed detailed
alt={image.get('description')} alt={image.get('description')}
@ -197,13 +217,19 @@ class MediaModal extends ImmutablePureComponent {
'media-modal__navigation--hidden': navigationHidden, 'media-modal__navigation--hidden': navigationHidden,
}); });
let pagination;
if (media.size > 1) {
pagination = media.map((item, i) => (
<button key={i} className={classNames('media-modal__page-dot', { active: i === index })} data-index={i} onClick={this.handleChangeIndex}>
{i + 1}
</button>
));
}
return ( return (
<div className='modal-root__modal media-modal'> <div className='modal-root__modal media-modal'>
<div <div className='media-modal__closer' role='presentation' onClick={onClose} >
className='media-modal__closer'
role='presentation'
onClick={onClose}
>
<ReactSwipeableViews <ReactSwipeableViews
style={swipeableViewsStyle} style={swipeableViewsStyle}
containerStyle={containerStyle} containerStyle={containerStyle}
@ -221,15 +247,10 @@ class MediaModal extends ImmutablePureComponent {
{leftNav} {leftNav}
{rightNav} {rightNav}
{status && ( <div className='media-modal__overlay'>
<div className={classNames('media-modal__meta', { 'media-modal__meta--shifted': media.size > 1 })}> {pagination && <ul className='media-modal__pagination'>{pagination}</ul>}
<a href={status.get('url')} onClick={this.handleStatusClick}><Icon id='comments' /> <FormattedMessage id='lightbox.view_context' defaultMessage='View context' /></a> {statusId && <Footer statusId={statusId} withOpenButton onClose={onClose} />}
</div> </div>
)}
<ul className='media-modal__pagination'>
{pagination}
</ul>
</div> </div>
</div> </div>
); );

@ -57,6 +57,10 @@ export default class ModalRoot extends React.PureComponent {
onClose: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired,
}; };
state = {
backgroundColor: null,
};
getSnapshotBeforeUpdate () { getSnapshotBeforeUpdate () {
return { visible: !!this.props.type }; return { visible: !!this.props.type };
} }
@ -71,6 +75,10 @@ export default class ModalRoot extends React.PureComponent {
} }
} }
setBackgroundColor = color => {
this.setState({ backgroundColor: color });
}
renderLoading = modalId => () => { renderLoading = modalId => () => {
return ['MEDIA', 'VIDEO', 'BOOST', 'FAVOURITE', 'DOODLE', 'GIPHY', 'CONFIRM', 'ACTIONS'].indexOf(modalId) === -1 ? <ModalLoading /> : null; return ['MEDIA', 'VIDEO', 'BOOST', 'FAVOURITE', 'DOODLE', 'GIPHY', 'CONFIRM', 'ACTIONS'].indexOf(modalId) === -1 ? <ModalLoading /> : null;
} }
@ -83,13 +91,14 @@ export default class ModalRoot extends React.PureComponent {
render () { render () {
const { type, props, onClose } = this.props; const { type, props, onClose } = this.props;
const { backgroundColor } = this.state;
const visible = !!type; const visible = !!type;
return ( return (
<Base onClose={onClose} noEsc={props ? props.noEsc : false}> <Base backgroundColor={backgroundColor} onClose={onClose} noEsc={props ? props.noEsc : false}>
{visible && ( {visible && (
<BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}> <BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}>
{(SpecificComponent) => <SpecificComponent {...props} onClose={onClose} />} {(SpecificComponent) => <SpecificComponent {...props} onChangeBackgroundColor={this.setBackgroundColor} onClose={onClose} />}
</BundleContainer> </BundleContainer>
)} )}
</Base> </Base>

@ -91,7 +91,7 @@ class ReportModal extends ImmutablePureComponent {
return ( return (
<div className='modal-root__modal report-modal'> <div className='modal-root__modal report-modal'>
<div className='report-modal__target'> <div className='report-modal__target'>
<IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} /> <IconButton className='report-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={20} />
<FormattedMessage id='report.target' defaultMessage='Report {target}' values={{ target: <strong>{account.get('acct')}</strong> }} /> <FormattedMessage id='report.target' defaultMessage='Report {target}' values={{ target: <strong>{account.get('acct')}</strong> }} />
</div> </div>

@ -3,9 +3,8 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import Video from 'flavours/glitch/features/video'; import Video from 'flavours/glitch/features/video';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { FormattedMessage } from 'react-intl'; import Footer from 'flavours/glitch/features/picture_in_picture/components/footer';
import classNames from 'classnames'; import { getAverageFromBlurhash } from 'flavours/glitch/blurhash';
import Icon from 'flavours/glitch/components/icon';
export default class VideoModal extends ImmutablePureComponent { export default class VideoModal extends ImmutablePureComponent {
@ -15,24 +14,28 @@ export default class VideoModal extends ImmutablePureComponent {
static propTypes = { static propTypes = {
media: ImmutablePropTypes.map.isRequired, media: ImmutablePropTypes.map.isRequired,
status: ImmutablePropTypes.map, statusId: PropTypes.string,
options: PropTypes.shape({ options: PropTypes.shape({
startTime: PropTypes.number, startTime: PropTypes.number,
autoPlay: PropTypes.bool, autoPlay: PropTypes.bool,
defaultVolume: PropTypes.number, defaultVolume: PropTypes.number,
}), }),
onClose: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired,
onChangeBackgroundColor: PropTypes.func.isRequired,
}; };
handleStatusClick = e => { componentDidMount () {
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { const { media, onChangeBackgroundColor, onClose } = this.props;
e.preventDefault();
this.context.router.history.push(`/statuses/${this.props.status.get('id')}`); const backgroundColor = getAverageFromBlurhash(media.get('blurhash'));
if (backgroundColor) {
onChangeBackgroundColor(backgroundColor);
} }
} }
render () { render () {
const { media, status, onClose } = this.props; const { media, statusId, onClose } = this.props;
const options = this.props.options || {}; const options = this.props.options || {};
return ( return (
@ -52,11 +55,9 @@ export default class VideoModal extends ImmutablePureComponent {
/> />
</div> </div>
{status && ( <div className='media-modal__overlay'>
<div className={classNames('media-modal__meta')}> {statusId && <Footer statusId={statusId} withOpenButton onClose={onClose} />}
<a href={status.get('url')} onClick={this.handleStatusClick}><Icon id='comments' /> <FormattedMessage id='lightbox.view_context' defaultMessage='View context' /></a> </div>
</div>
)}
</div> </div>
); );
} }

@ -50,9 +50,11 @@ import {
Search, Search,
GettingStartedMisc, GettingStartedMisc,
Directory, Directory,
FollowRecommendations,
} from 'flavours/glitch/util/async-components'; } from 'flavours/glitch/util/async-components';
import { HotKeys } from 'react-hotkeys'; import { HotKeys } from 'react-hotkeys';
import { me } from 'flavours/glitch/util/initial_state'; import { me } from 'flavours/glitch/util/initial_state';
import { closeOnboarding, INTRODUCTION_VERSION } from 'flavours/glitch/actions/onboarding';
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
// Dummy import, to make sure that <Status /> ends up in the application bundle. // Dummy import, to make sure that <Status /> ends up in the application bundle.
@ -75,6 +77,7 @@ const mapStateToProps = state => ({
showFaviconBadge: state.getIn(['local_settings', 'notifications', 'favicon_badge']), showFaviconBadge: state.getIn(['local_settings', 'notifications', 'favicon_badge']),
hicolorPrivacyIcons: state.getIn(['local_settings', 'hicolor_privacy_icons']), hicolorPrivacyIcons: state.getIn(['local_settings', 'hicolor_privacy_icons']),
moved: state.getIn(['accounts', me, 'moved']) && state.getIn(['accounts', state.getIn(['accounts', me, 'moved'])]), moved: state.getIn(['accounts', me, 'moved']) && state.getIn(['accounts', state.getIn(['accounts', me, 'moved'])]),
firstLaunch: state.getIn(['settings', 'introductionVersion'], 0) < INTRODUCTION_VERSION,
}); });
const keyMap = { const keyMap = {
@ -207,6 +210,7 @@ class SwitchingColumnsArea extends React.PureComponent {
<WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} /> <WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} />
<WrappedRoute path='/pinned' component={PinnedStatuses} content={children} /> <WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
<WrappedRoute path='/start' component={FollowRecommendations} content={children} />
<WrappedRoute path='/search' component={Search} content={children} /> <WrappedRoute path='/search' component={Search} content={children} />
<WrappedRoute path='/directory' component={Directory} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} /> <WrappedRoute path='/directory' component={Directory} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
@ -260,6 +264,7 @@ class UI extends React.Component {
unreadNotifications: PropTypes.number, unreadNotifications: PropTypes.number,
showFaviconBadge: PropTypes.bool, showFaviconBadge: PropTypes.bool,
moved: PropTypes.map, moved: PropTypes.map,
firstLaunch: PropTypes.bool,
}; };
state = { state = {
@ -378,6 +383,12 @@ class UI extends React.Component {
this.favicon = new Favico({ animation:"none" }); this.favicon = new Favico({ animation:"none" });
// On first launch, redirect to the follow recommendations page
if (this.props.firstLaunch) {
this.context.router.history.replace('/start');
this.props.dispatch(closeOnboarding());
}
this.props.dispatch(fetchMarkers()); this.props.dispatch(fetchMarkers());
this.props.dispatch(expandHomeTimeline()); this.props.dispatch(expandHomeTimeline());
this.props.dispatch(expandNotifications()); this.props.dispatch(expandNotifications());

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { fromJS, is } from 'immutable'; import { is } from 'immutable';
import { throttle, debounce } from 'lodash'; import { throttle, debounce } from 'lodash';
import classNames from 'classnames'; import classNames from 'classnames';
import { isFullscreen, requestFullscreen, exitFullscreen } from 'flavours/glitch/util/fullscreen'; import { isFullscreen, requestFullscreen, exitFullscreen } from 'flavours/glitch/util/fullscreen';
@ -120,10 +120,10 @@ class Video extends React.PureComponent {
deployPictureInPicture: PropTypes.func, deployPictureInPicture: PropTypes.func,
preventPlayback: PropTypes.bool, preventPlayback: PropTypes.bool,
blurhash: PropTypes.string, blurhash: PropTypes.string,
link: PropTypes.node,
autoPlay: PropTypes.bool, autoPlay: PropTypes.bool,
volume: PropTypes.number, volume: PropTypes.number,
muted: PropTypes.bool, muted: PropTypes.bool,
componetIndex: PropTypes.number,
}; };
static defaultProps = { static defaultProps = {
@ -510,25 +510,14 @@ class Video extends React.PureComponent {
} }
handleOpenVideo = () => { handleOpenVideo = () => {
const { src, preview, width, height, alt } = this.props; this.video.pause();
const media = fromJS({
type: 'video',
url: src,
preview_url: preview,
description: alt,
width,
height,
});
const options = { this.props.onOpenVideo({
startTime: this.video.currentTime, startTime: this.video.currentTime,
autoPlay: !this.state.paused, autoPlay: !this.state.paused,
defaultVolume: this.state.volume, defaultVolume: this.state.volume,
}; componetIndex: this.props.componetIndex,
});
this.video.pause();
this.props.onOpenVideo(media, options);
} }
handleCloseVideo = () => { handleCloseVideo = () => {
@ -548,7 +537,7 @@ class Video extends React.PureComponent {
} }
render () { render () {
const { preview, src, inline, onOpenVideo, onCloseVideo, intl, alt, letterbox, fullwidth, detailed, sensitive, link, editable, blurhash } = this.props; const { preview, src, inline, onOpenVideo, onCloseVideo, intl, alt, letterbox, fullwidth, detailed, sensitive, editable, blurhash } = this.props;
const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state; const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
const progress = Math.min((currentTime / duration) * 100, 100); const progress = Math.min((currentTime / duration) * 100, 100);
const playerStyle = {}; const playerStyle = {};
@ -666,8 +655,6 @@ class Video extends React.PureComponent {
<span className='video-player__time-total'>{formatTime(Math.floor(duration))}</span> <span className='video-player__time-total'>{formatTime(Math.floor(duration))}</span>
</span> </span>
)} )}
{link && <span className='video-player__link'>{link}</span>}
</div> </div>
<div className='video-player__buttons right'> <div className='video-player__buttons right'>

@ -385,7 +385,7 @@ export default function compose(state = initialState, action) {
map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy'))); map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy')));
map.update( map.update(
'advanced_options', 'advanced_options',
map => map.merge(new ImmutableMap({ do_not_federate: action.status.get('local_only') })) map => map.merge(new ImmutableMap({ do_not_federate: !!action.status.get('local_only') }))
); );
map.set('focusDate', new Date()); map.set('focusDate', new Date());
map.set('caretPosition', null); map.set('caretPosition', null);
@ -505,7 +505,7 @@ export default function compose(state = initialState, action) {
case COMPOSE_GIPHY_SET: case COMPOSE_GIPHY_SET:
return state.mergeIn(['giphy'], action.options); return state.mergeIn(['giphy'], action.options);
case REDRAFT: case REDRAFT:
const do_not_federate = action.status.get('local_only', false); const do_not_federate = !!action.status.get('local_only');
let text = action.raw_text || unescapeHTML(expandMentions(action.status)); let text = action.raw_text || unescapeHTML(expandMentions(action.status));
if (do_not_federate) text = text.replace(/ ?👁\ufe0f?\u200b?$/, ''); if (do_not_federate) text = text.replace(/ ?👁\ufe0f?\u200b?$/, '');
return state.withMutations(map => { return state.withMutations(map => {

@ -55,7 +55,6 @@ const initialState = ImmutableMap({
notifications : ImmutableMap({ notifications : ImmutableMap({
favicon_badge : false, favicon_badge : false,
tab_badge : true, tab_badge : true,
show_unread : true,
}), }),
}); });

@ -112,7 +112,7 @@ const expandNormalizedNotifications = (state, notifications, next, isLoadingRece
} }
if (shouldCountUnreadNotifications(state)) { if (shouldCountUnreadNotifications(state)) {
mutable.update('unread', unread => unread + items.count(item => compareId(item.get('id'), lastReadId) > 0)); mutable.set('unread', mutable.get('pendingItems').count(item => item !== null) + mutable.get('items').count(item => item && compareId(item.get('id'), lastReadId) > 0));
} else { } else {
const mostRecent = items.find(item => item !== null); const mostRecent = items.find(item => item !== null);
if (mostRecent && compareId(lastReadId, mostRecent.get('id')) < 0) { if (mostRecent && compareId(lastReadId, mostRecent.get('id')) < 0) {

@ -49,6 +49,7 @@ const initialState = ImmutableMap({
}), }),
dismissPermissionBanner: false, dismissPermissionBanner: false,
showUnread: true,
shows: ImmutableMap({ shows: ImmutableMap({
follow: true, follow: true,

@ -19,18 +19,18 @@ export default function suggestionsReducer(state = initialState, action) {
return state.set('isLoading', true); return state.set('isLoading', true);
case SUGGESTIONS_FETCH_SUCCESS: case SUGGESTIONS_FETCH_SUCCESS:
return state.withMutations(map => { return state.withMutations(map => {
map.set('items', fromJS(action.accounts.map(x => x.id))); map.set('items', fromJS(action.suggestions.map(x => ({ ...x, account: x.account.id }))));
map.set('isLoading', false); map.set('isLoading', false);
}); });
case SUGGESTIONS_FETCH_FAIL: case SUGGESTIONS_FETCH_FAIL:
return state.set('isLoading', false); return state.set('isLoading', false);
case SUGGESTIONS_DISMISS: case SUGGESTIONS_DISMISS:
return state.update('items', list => list.filterNot(id => id === action.id)); return state.update('items', list => list.filterNot(x => x.account === action.id));
case ACCOUNT_BLOCK_SUCCESS: case ACCOUNT_BLOCK_SUCCESS:
case ACCOUNT_MUTE_SUCCESS: case ACCOUNT_MUTE_SUCCESS:
return state.update('items', list => list.filterNot(id => id === action.relationship.id)); return state.update('items', list => list.filterNot(x => x.account === action.relationship.id));
case DOMAIN_BLOCK_SUCCESS: case DOMAIN_BLOCK_SUCCESS:
return state.update('items', list => list.filterNot(id => action.accounts.includes(id))); return state.update('items', list => list.filterNot(x => action.accounts.includes(x.account)));
default: default:
return state; return state;
} }

@ -9,6 +9,7 @@ import {
TIMELINE_CONNECT, TIMELINE_CONNECT,
TIMELINE_DISCONNECT, TIMELINE_DISCONNECT,
TIMELINE_LOAD_PENDING, TIMELINE_LOAD_PENDING,
TIMELINE_MARK_AS_PARTIAL,
} from 'flavours/glitch/actions/timelines'; } from 'flavours/glitch/actions/timelines';
import { import {
ACCOUNT_BLOCK_SUCCESS, ACCOUNT_BLOCK_SUCCESS,
@ -173,6 +174,12 @@ export default function timelines(state = initialState, action) {
initialTimeline, initialTimeline,
map => map.set('online', false).update(action.usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(null) : items), map => map.set('online', false).update(action.usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(null) : items),
); );
case TIMELINE_MARK_AS_PARTIAL:
return state.update(
action.timeline,
initialTimeline,
map => map.set('isPartial', true).set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('unread', 0),
);
default: default:
return state; return state;
} }

@ -324,7 +324,6 @@ $small-breakpoint: 960px;
font-family: $font-sans-serif, sans-serif; font-family: $font-sans-serif, sans-serif;
font-size: 16px; font-size: 16px;
font-weight: 400; font-weight: 400;
font-size: 16px;
line-height: 30px; line-height: 30px;
margin-bottom: 12px; margin-bottom: 12px;
color: $darker-text-color; color: $darker-text-color;

@ -11,6 +11,19 @@
overflow: hidden; overflow: hidden;
text-decoration: none; text-decoration: none;
font-size: 14px; font-size: 14px;
&--with-note {
strong {
display: inline;
}
}
}
&__note {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: $ui-secondary-color;
} }
&.small { &.small {
@ -26,6 +39,16 @@
} }
} }
.follow-recommendations-account {
.icon-button {
color: $ui-primary-color;
&.active {
color: $valid-value-color;
}
}
}
.account__wrapper { .account__wrapper {
display: flex; display: flex;
} }
@ -555,6 +578,17 @@
color: $primary-text-color; color: $primary-text-color;
} }
.account__header__joined {
font-size: 14px;
padding: 5px 15px;
color: $darker-text-color;
.columns-area--mobile & {
padding-left: 20px;
padding-right: 20px;
}
}
.account__header__fields { .account__header__fields {
margin: 0; margin: 0;
border-top: 1px solid lighten($ui-base-color, 12%); border-top: 1px solid lighten($ui-base-color, 12%);

File diff suppressed because one or more lines are too long

@ -483,6 +483,73 @@
margin-right: 5px; margin-right: 5px;
} }
.column-settings__pillbar {
display: flex;
overflow: hidden;
background-color: transparent;
border: 0;
border-radius: 4px;
margin-bottom: 10px;
align-items: stretch;
}
.pillbar-button {
border: 0;
color: #fafafa;
padding: 2px;
margin: 0;
margin-left: 2px;
font-size: inherit;
flex: auto;
background-color: $ui-base-color;
transition-property: background-color, box-shadow;
transition: all 0.2s ease;
&[disabled] {
cursor: not-allowed;
opacity: 0.5;
}
&:not([disabled]) {
&:hover {
background-color: darken($ui-base-color, 10%);
}
&:focus {
background-color: darken($ui-base-color, 15%);
}
&:active {
background-color: darken($ui-base-color, 20%);
}
&.active {
background-color: $ui-highlight-color;
box-shadow: inset 0 5px darken($ui-highlight-color, 20%);
&:hover {
background-color: lighten($ui-highlight-color, 10%);
box-shadow: inset 0 5px darken($ui-highlight-color, 10%);
}
&:focus {
background-color: lighten($ui-highlight-color, 15%);
box-shadow: inset 0 5px darken($ui-highlight-color, 5%);
}
&:active {
background-color: lighten($ui-highlight-color, 20%);
box-shadow: inset 0 5px $ui-highlight-color;
}
}
}
/* TODO: check RTL? */
&:first-child {
margin-left: 0;
}
}
.empty-column-indicator, .empty-column-indicator,
.error-column, .error-column,
.follow_requests-unlocked_explanation { .follow_requests-unlocked_explanation {
@ -729,3 +796,69 @@
text-align: center; text-align: center;
} }
} }
.column-title {
text-align: center;
padding: 40px;
.logo {
fill: $primary-text-color;
width: 50px;
margin: 0 auto;
margin-bottom: 40px;
}
h3 {
font-size: 24px;
line-height: 1.5;
font-weight: 700;
margin-bottom: 10px;
}
p {
font-size: 16px;
line-height: 24px;
font-weight: 400;
color: $darker-text-color;
}
}
.follow-recommendations-container {
display: flex;
flex-direction: column;
}
.column-actions {
display: flex;
align-items: start;
justify-content: center;
padding: 40px;
padding-top: 40px;
padding-bottom: 200px;
flex-grow: 1;
position: relative;
&__background {
position: absolute;
left: 0;
bottom: 0;
height: 220px;
width: auto;
}
}
.column-list {
margin: 0 20px;
border: 1px solid lighten($ui-base-color, 8%);
background: darken($ui-base-color, 2%);
border-radius: 4px;
&__empty-message {
padding: 40px;
text-align: center;
font-size: 16px;
line-height: 24px;
font-weight: 400;
color: $darker-text-color;
}
}

@ -48,6 +48,8 @@
overflow: hidden; overflow: hidden;
transition: color .1s ease-out; transition: color .1s ease-out;
cursor: pointer; cursor: pointer;
background: transparent;
border: 0;
&:hover { &:hover {
color: darken($lighter-text-color, 4%); color: darken($lighter-text-color, 4%);
@ -106,11 +108,13 @@
padding: 10px; padding: 10px;
padding-right: 45px; padding-right: 45px;
background: $simple-background-color; background: $simple-background-color;
position: relative;
input { input {
font-size: 14px; font-size: 14px;
font-weight: 400; font-weight: 400;
padding: 7px 9px; padding: 7px 9px;
padding-right: 25px;
font-family: inherit; font-family: inherit;
display: block; display: block;
width: 100%; width: 100%;
@ -131,6 +135,30 @@
} }
} }
.emoji-mart-search-icon {
position: absolute;
top: 18px;
right: 45px + 5px;
z-index: 2;
padding: 2px 5px 1px;
border: 0;
background: none;
transition: all 100ms linear;
transition-property: opacity;
pointer-events: auto;
opacity: 0.7;
&:disabled {
cursor: default;
pointer-events: none;
opacity: 0.3;
}
svg {
fill: $action-button-color;
}
}
.emoji-mart-category .emoji-mart-emoji { .emoji-mart-category .emoji-mart-emoji {
cursor: pointer; cursor: pointer;
@ -169,9 +197,36 @@
} }
} }
/* For screenreaders only, via https://stackoverflow.com/a/19758620 */
.emoji-mart-sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.emoji-mart-category-list {
margin: 0;
padding: 0;
}
.emoji-mart-category-list li {
list-style: none;
margin: 0;
padding: 0;
display: inline-block;
}
.emoji-mart-emoji { .emoji-mart-emoji {
position: relative; position: relative;
display: inline-block; display: inline-block;
background: transparent;
border: 0;
padding: 0;
font-size: 0; font-size: 0;
span { span {
@ -182,19 +237,17 @@
.emoji-mart-no-results { .emoji-mart-no-results {
font-size: 14px; font-size: 14px;
color: $light-text-color;
text-align: center; text-align: center;
padding: 5px 6px;
padding-top: 70px; padding-top: 70px;
color: $light-text-color;
.emoji-mart-category-label {
display: none;
}
.emoji-mart-no-results-label { .emoji-mart-no-results-label {
margin-top: .2em; margin-top: .2em;
} }
.emoji-mart-emoji:hover::before { .emoji-mart-emoji:hover::before {
cursor: default;
content: none; content: none;
} }
} }

@ -334,11 +334,11 @@
} }
} }
.star-icon.active { .icon-button.star-icon.active {
color: $gold-star; color: $gold-star;
} }
.bookmark-icon.active { .icon-button.bookmark-icon.active {
color: $red-bookmark; color: $red-bookmark;
} }
@ -1240,7 +1240,6 @@ button.icon-button.active i.fa-retweet {
span { span {
display: block; display: block;
float: left; float: left;
margin-left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
margin: 82px 0 0 50%; margin: 82px 0 0 50%;
white-space: nowrap; white-space: nowrap;

@ -187,16 +187,19 @@
height: 100%; height: 100%;
position: relative; position: relative;
.extended-video-player { &__close,
width: 100%; &__zoom-button {
height: 100%; color: rgba($white, 0.7);
display: flex;
align-items: center;
justify-content: center;
video { &:hover,
max-width: $media-modal-media-max-width; &:focus,
max-height: $media-modal-media-max-height; &:active {
color: $white;
background-color: rgba($white, 0.15);
}
&:focus {
background-color: rgba($white, 0.3);
} }
} }
} }
@ -233,10 +236,10 @@
} }
.media-modal__nav { .media-modal__nav {
background: rgba($base-overlay-background, 0.5); background: transparent;
box-sizing: border-box; box-sizing: border-box;
border: 0; border: 0;
color: $primary-text-color; color: rgba($primary-text-color, 0.7);
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
@ -247,6 +250,12 @@
position: absolute; position: absolute;
top: 0; top: 0;
bottom: 0; bottom: 0;
&:hover,
&:focus,
&:active {
color: $primary-text-color;
}
} }
.media-modal__nav--left { .media-modal__nav--left {
@ -257,58 +266,86 @@
right: 0; right: 0;
} }
.media-modal__pagination { .media-modal__overlay {
width: 100%; max-width: 600px;
text-align: center;
position: absolute; position: absolute;
left: 0; left: 0;
bottom: 20px; right: 0;
pointer-events: none; bottom: 0;
} margin: 0 auto;
.media-modal__meta { .picture-in-picture__footer {
text-align: center; border-radius: 0;
position: absolute; background: transparent;
left: 0; padding: 20px 0;
bottom: 20px;
width: 100%;
pointer-events: none;
&--shifted { .icon-button {
bottom: 62px; color: $white;
}
a { &:hover,
pointer-events: auto; &:focus,
text-decoration: none; &:active {
font-weight: 500; color: $white;
color: $ui-secondary-color; background-color: rgba($white, 0.15);
}
&:hover, &:focus {
&:focus, background-color: rgba($white, 0.3);
&:active { }
text-decoration: underline;
&.active {
color: $highlight-text-color;
&:hover,
&:focus,
&:active {
background: rgba($highlight-text-color, 0.15);
}
&:focus {
background: rgba($highlight-text-color, 0.3);
}
}
&.star-icon.active {
color: $gold-star;
&:hover,
&:focus,
&:active {
background: rgba($gold-star, 0.15);
}
&:focus {
background: rgba($gold-star, 0.3);
}
}
} }
} }
} }
.media-modal__page-dot { .media-modal__pagination {
display: inline-block; display: flex;
justify-content: center;
margin-bottom: 20px;
} }
.media-modal__button { .media-modal__page-dot {
flex: 0 0 auto;
background-color: $white; background-color: $white;
height: 12px; opacity: 0.4;
width: 12px; height: 6px;
border-radius: 6px; width: 6px;
margin: 10px; border-radius: 50%;
margin: 0 4px;
padding: 0; padding: 0;
border: 0; border: 0;
font-size: 0; font-size: 0;
} transition: opacity .2s ease-in-out;
.media-modal__button--active { &.active {
background-color: $ui-highlight-color; opacity: 1;
}
} }
.media-modal__close { .media-modal__close {

@ -14,6 +14,7 @@
right: 0; right: 0;
bottom: 0; bottom: 0;
background: rgba($base-overlay-background, 0.7); background: rgba($base-overlay-background, 0.7);
transition: background 0.5s;
} }
.modal-root__container { .modal-root__container {
@ -846,9 +847,10 @@
.report-modal__target { .report-modal__target {
padding: 15px; padding: 15px;
.media-modal__close { .report-modal__close {
top: 14px; position: absolute;
right: 15px; top: 10px;
right: 10px;
} }
} }

@ -1122,21 +1122,6 @@ a.status-card.compact:hover {
.audio-player { .audio-player {
border-radius: 0; border-radius: 0;
} }
@media screen and (max-width: 415px) {
width: 210px;
bottom: 10px;
right: 10px;
&__footer {
display: none;
}
.video-player,
.audio-player {
border-radius: 0 0 4px 4px;
}
}
} }
.picture-in-picture-placeholder { .picture-in-picture-placeholder {

@ -595,6 +595,12 @@ code {
color: $valid-value-color; color: $valid-value-color;
} }
&.warning {
border: 1px solid rgba($gold-star, 0.5);
background: rgba($gold-star, 0.25);
color: $gold-star;
}
&.alert { &.alert {
border: 1px solid rgba($error-value-color, 0.5); border: 1px solid rgba($error-value-color, 0.5);
background: rgba($error-value-color, 0.1); background: rgba($error-value-color, 0.1);
@ -616,6 +622,19 @@ code {
} }
} }
&.warning a {
font-weight: 700;
color: inherit;
text-decoration: underline;
&:hover,
&:focus,
&:active {
text-decoration: none;
color: inherit;
}
}
p { p {
margin-bottom: 15px; margin-bottom: 15px;
} }
@ -672,6 +691,29 @@ code {
} }
} }
.flash-message-stack {
margin-bottom: 30px;
.flash-message {
border-radius: 0;
margin-bottom: 0;
border-top-width: 0;
&:first-child {
border-radius: 4px 4px 0 0;
border-top-width: 1px;
}
&:last-child {
border-radius: 0 0 4px 4px;
&:first-child {
border-radius: 4px;
}
}
}
}
.form-footer { .form-footer {
margin-top: 30px; margin-top: 30px;
text-align: center; text-align: center;

@ -1,3 +1,5 @@
@use "sass:math";
.hero-widget { .hero-widget {
margin-bottom: 10px; margin-bottom: 10px;
box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
@ -489,10 +491,10 @@ $fluid-breakpoint: $maximum-width + 20px;
} }
&__item { &__item {
width: (960px - 20px) / 3; width: math.div(960px - 20px, 3);
@media screen and (max-width: $fluid-breakpoint) { @media screen and (max-width: $fluid-breakpoint) {
width: (940px - 20px) / 3; width: math.div(940px - 20px, 3);
} }
@media screen and (max-width: 640px) { @media screen and (max-width: 640px) {
@ -584,7 +586,6 @@ $fluid-breakpoint: $maximum-width + 20px;
display: block; display: block;
font-weight: 500; font-weight: 500;
padding: 15px; padding: 15px;
overflow: hidden;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;

@ -169,3 +169,7 @@ export function Tesseract () {
export function Directory () { export function Directory () {
return import(/* webpackChunkName: "features/glitch/async/directory" */'flavours/glitch/features/directory'); return import(/* webpackChunkName: "features/glitch/async/directory" */'flavours/glitch/features/directory');
} }
export function FollowRecommendations () {
return import(/* webpackChunkName: "features/glitch/async/follow_recommendations" */'flavours/glitch/features/follow_recommendations');
}

@ -7,30 +7,38 @@
const { unicodeToFilename } = require('./unicode_to_filename'); const { unicodeToFilename } = require('./unicode_to_filename');
const { unicodeToUnifiedName } = require('./unicode_to_unified_name'); const { unicodeToUnifiedName } = require('./unicode_to_unified_name');
const emojiMap = require('./emoji_map.json'); const emojiMap = require('./emoji_map.json');
const { emojiIndex } = require('emoji-mart'); const { emojiIndex } = require('emoji-mart');
const { uncompress: emojiMartUncompress } = require('emoji-mart/dist/utils/data'); const { uncompress: emojiMartUncompress } = require('emoji-mart/dist/utils/data');
let data = require('emoji-mart/data/all.json'); let data = require('emoji-mart/data/all.json');
if(data.compressed) { if(data.compressed) {
data = emojiMartUncompress(data); data = emojiMartUncompress(data);
} }
const emojiMartData = data;
const emojiMartData = data;
const excluded = ['®', '©', '™']; const excluded = ['®', '©', '™'];
const skins = ['🏻', '🏼', '🏽', '🏾', '🏿']; const skinTones = ['🏻', '🏼', '🏽', '🏾', '🏿'];
const shortcodeMap = {}; const shortcodeMap = {};
const shortCodesToEmojiData = {}; const shortCodesToEmojiData = {};
const emojisWithoutShortCodes = []; const emojisWithoutShortCodes = [];
Object.keys(emojiIndex.emojis).forEach(key => { Object.keys(emojiIndex.emojis).forEach(key => {
shortcodeMap[emojiIndex.emojis[key].native] = emojiIndex.emojis[key].id; let emoji = emojiIndex.emojis[key];
// Emojis with skin tone modifiers are stored like this
if (Object.prototype.hasOwnProperty.call(emoji, '1')) {
emoji = emoji['1'];
}
shortcodeMap[emoji.native] = emoji.id;
}); });
const stripModifiers = unicode => { const stripModifiers = unicode => {
skins.forEach(tone => { skinTones.forEach(tone => {
unicode = unicode.replace(tone, ''); unicode = unicode.replace(tone, '');
}); });
@ -65,13 +73,22 @@ Object.keys(emojiMap).forEach(key => {
if (!Array.isArray(shortCodesToEmojiData[shortcode])) { if (!Array.isArray(shortCodesToEmojiData[shortcode])) {
shortCodesToEmojiData[shortcode] = [[]]; shortCodesToEmojiData[shortcode] = [[]];
} }
shortCodesToEmojiData[shortcode][0].push(filenameData); shortCodesToEmojiData[shortcode][0].push(filenameData);
} }
}); });
Object.keys(emojiIndex.emojis).forEach(key => { Object.keys(emojiIndex.emojis).forEach(key => {
const { native } = emojiIndex.emojis[key]; let emoji = emojiIndex.emojis[key];
// Emojis with skin tone modifiers are stored like this
if (Object.prototype.hasOwnProperty.call(emoji, '1')) {
emoji = emoji['1'];
}
const { native } = emoji;
let { short_names, search, unified } = emojiMartData.emojis[key]; let { short_names, search, unified } = emojiMartData.emojis[key];
if (short_names[0] !== key) { if (short_names[0] !== key) {
throw new Error('The compresser expects the first short_code to be the ' + throw new Error('The compresser expects the first short_code to be the ' +
'key. It may need to be rewritten if the emoji change such that this ' + 'key. It may need to be rewritten if the emoji change such that this ' +
@ -81,11 +98,16 @@ Object.keys(emojiIndex.emojis).forEach(key => {
short_names = short_names.slice(1); // first short name can be inferred from the key short_names = short_names.slice(1); // first short name can be inferred from the key
const searchData = [native, short_names, search]; const searchData = [native, short_names, search];
if (unicodeToUnifiedName(native) !== unified) { if (unicodeToUnifiedName(native) !== unified) {
// unified name can't be derived from unicodeToUnifiedName // unified name can't be derived from unicodeToUnifiedName
searchData.push(unified); searchData.push(unified);
} }
if (!Array.isArray(shortCodesToEmojiData[key])) {
shortCodesToEmojiData[key] = [[]];
}
shortCodesToEmojiData[key].push(searchData); shortCodesToEmojiData[key].push(searchData);
}); });

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

Loading…
Cancel
Save