Browse Source

Merge branch 'master' into live

master
Zac 1 month ago
parent
commit
cc5f0b765c
100 changed files with 2732 additions and 280 deletions
  1. 6
    1
      .circleci/config.yml
  2. 10
    0
      .env.production.sample
  3. 1
    1
      .nvmrc
  4. 156
    0
      CHANGELOG.md
  5. 3
    1
      Dockerfile
  6. 1
    0
      Gemfile
  7. 2
    1
      Gemfile.lock
  8. 1
    1
      Vagrantfile
  9. 88
    0
      app/controllers/admin/announcements_controller.rb
  10. 0
    18
      app/controllers/admin/followers_controller.rb
  11. 25
    0
      app/controllers/admin/relationships_controller.rb
  12. 1
    1
      app/controllers/api/base_controller.rb
  13. 11
    3
      app/controllers/api/oembed_controller.rb
  14. 29
    0
      app/controllers/api/v1/announcements/reactions_controller.rb
  15. 29
    0
      app/controllers/api/v1/announcements_controller.rb
  16. 6
    0
      app/controllers/auth/passwords_controller.rb
  17. 7
    0
      app/controllers/auth/registrations_controller.rb
  18. 3
    43
      app/controllers/relationships_controller.rb
  19. 2
    2
      app/controllers/statuses_controller.rb
  20. 8
    0
      app/helpers/admin/action_logs_helper.rb
  21. 11
    0
      app/helpers/admin/announcements_helper.rb
  22. 1
    0
      app/helpers/admin/filter_helper.rb
  23. 1
    0
      app/helpers/settings_helper.rb
  24. 180
    0
      app/javascript/flavours/glitch/actions/announcements.js
  25. 9
    1
      app/javascript/flavours/glitch/actions/importer/normalizer.js
  26. 44
    2
      app/javascript/flavours/glitch/actions/markers.js
  27. 2
    1
      app/javascript/flavours/glitch/actions/notifications.js
  28. 18
    1
      app/javascript/flavours/glitch/actions/streaming.js
  29. 1
    1
      app/javascript/flavours/glitch/actions/timelines.js
  30. 65
    0
      app/javascript/flavours/glitch/components/animated_number.js
  31. 6
    54
      app/javascript/flavours/glitch/components/column_header.js
  32. 4
    4
      app/javascript/flavours/glitch/components/dropdown_menu.js
  33. 11
    5
      app/javascript/flavours/glitch/components/relative_timestamp.js
  34. 19
    1
      app/javascript/flavours/glitch/components/status_content.js
  35. 1
    0
      app/javascript/flavours/glitch/features/account_timeline/index.js
  36. 1
    0
      app/javascript/flavours/glitch/features/directory/components/account_card.js
  37. 3
    2
      app/javascript/flavours/glitch/features/emoji_picker/index.js
  38. 436
    0
      app/javascript/flavours/glitch/features/getting_started/components/announcements.js
  39. 20
    0
      app/javascript/flavours/glitch/features/getting_started/containers/announcements_container.js
  40. 1
    1
      app/javascript/flavours/glitch/features/getting_started/containers/trends_container.js
  41. 37
    1
      app/javascript/flavours/glitch/features/home_timeline/index.js
  42. 52
    3
      app/javascript/flavours/glitch/features/notifications/index.js
  43. 2
    1
      app/javascript/flavours/glitch/features/status/components/action_bar.js
  44. 6
    5
      app/javascript/flavours/glitch/features/status/components/detailed_status.js
  45. 0
    1
      app/javascript/flavours/glitch/features/ui/components/media_modal.js
  46. 2
    1
      app/javascript/flavours/glitch/features/ui/index.js
  47. 102
    0
      app/javascript/flavours/glitch/reducers/announcements.js
  48. 2
    0
      app/javascript/flavours/glitch/reducers/index.js
  49. 18
    1
      app/javascript/flavours/glitch/reducers/notifications.js
  50. 1
    2
      app/javascript/flavours/glitch/reducers/statuses.js
  51. 1
    0
      app/javascript/flavours/glitch/selectors/index.js
  52. 43
    0
      app/javascript/flavours/glitch/styles/admin.scss
  53. 225
    0
      app/javascript/flavours/glitch/styles/components/announcements.scss
  54. 11
    3
      app/javascript/flavours/glitch/styles/components/columns.scss
  55. 11
    11
      app/javascript/flavours/glitch/styles/components/composer.scss
  56. 1
    1
      app/javascript/flavours/glitch/styles/components/drawer.scss
  57. 9
    0
      app/javascript/flavours/glitch/styles/components/index.scss
  58. 1
    0
      app/javascript/flavours/glitch/styles/components/status.scss
  59. 6
    0
      app/javascript/flavours/glitch/styles/forms.scss
  60. 1
    0
      app/javascript/flavours/glitch/styles/polls.scss
  61. 17
    0
      app/javascript/flavours/glitch/styles/statuses.scss
  62. 46
    9
      app/javascript/flavours/glitch/util/stream.js
  63. 1
    1
      app/javascript/images/elephant_ui_plane.svg
  64. 180
    0
      app/javascript/mastodon/actions/announcements.js
  65. 9
    1
      app/javascript/mastodon/actions/importer/normalizer.js
  66. 2
    1
      app/javascript/mastodon/actions/notifications.js
  67. 18
    1
      app/javascript/mastodon/actions/streaming.js
  68. 1
    1
      app/javascript/mastodon/actions/timelines.js
  69. 65
    0
      app/javascript/mastodon/components/animated_number.js
  70. 4
    1
      app/javascript/mastodon/components/column_header.js
  71. 1
    1
      app/javascript/mastodon/components/error_boundary.js
  72. 11
    5
      app/javascript/mastodon/components/relative_timestamp.js
  73. 1
    0
      app/javascript/mastodon/features/account_timeline/index.js
  74. 3
    2
      app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
  75. 1
    0
      app/javascript/mastodon/features/directory/components/account_card.js
  76. 436
    0
      app/javascript/mastodon/features/getting_started/components/announcements.js
  77. 20
    0
      app/javascript/mastodon/features/getting_started/containers/announcements_container.js
  78. 1
    1
      app/javascript/mastodon/features/getting_started/containers/trends_container.js
  79. 37
    1
      app/javascript/mastodon/features/home_timeline/index.js
  80. 2
    1
      app/javascript/mastodon/features/status/components/action_bar.js
  81. 6
    5
      app/javascript/mastodon/features/status/components/detailed_status.js
  82. 0
    1
      app/javascript/mastodon/features/ui/components/media_modal.js
  83. 4
    3
      app/javascript/mastodon/locales/ar.json
  84. 2
    1
      app/javascript/mastodon/locales/ast.json
  85. 1
    0
      app/javascript/mastodon/locales/bg.json
  86. 1
    0
      app/javascript/mastodon/locales/bn.json
  87. 19
    18
      app/javascript/mastodon/locales/br.json
  88. 9
    8
      app/javascript/mastodon/locales/ca.json
  89. 1
    0
      app/javascript/mastodon/locales/co.json
  90. 3
    2
      app/javascript/mastodon/locales/cs.json
  91. 4
    3
      app/javascript/mastodon/locales/cy.json
  92. 1
    0
      app/javascript/mastodon/locales/da.json
  93. 4
    3
      app/javascript/mastodon/locales/de.json
  94. 24
    0
      app/javascript/mastodon/locales/defaultMessages.json
  95. 5
    4
      app/javascript/mastodon/locales/el.json
  96. 4
    0
      app/javascript/mastodon/locales/en.json
  97. 1
    0
      app/javascript/mastodon/locales/eo.json
  98. 1
    0
      app/javascript/mastodon/locales/es-AR.json
  99. 32
    31
      app/javascript/mastodon/locales/es.json
  100. 0
    0
      app/javascript/mastodon/locales/et.json

+ 6
- 1
.circleci/config.yml View File

@@ -69,7 +69,12 @@ aliases:
- *install_system_dependencies
- run: ruby -e 'puts RUBY_VERSION' | tee /tmp/.ruby-version
- *restore_ruby_dependencies
- run: bundle install --clean --jobs 16 --path ./vendor/bundle/ --retry 3 --with pam_authentication --without development production && bundle clean
- run: bundle config set clean 'true'
- run: bundle config set deployment 'true'
- run: bundle config set with 'pam_authentication'
- run: bundle config set without 'development production'
- run: bundle config set frozen 'true'
- run: bundle install --jobs 16 --retry 3 && bundle clean
- save_cache:
key: v2-ruby-dependencies-{{ checksum "/tmp/.ruby-version" }}-{{ checksum "Gemfile.lock" }}
paths:

+ 10
- 0
.env.production.sample View File

@@ -279,3 +279,13 @@ STREAMING_CLUSTER_NUM=1
# http_proxy=http://gateway.local:8118
# Access control for hidden service.
# ALLOW_ACCESS_TO_HIDDEN_SERVICE=true

# Authorized fetch mode (optional)
# Require remote servers to authentify when fetching toots, see
# https://docs.joinmastodon.org/admin/config/#authorized_fetch
# AUTHORIZED_FETCH=true

# Whitelist mode (optional)
# Only allow federation with whitelisted domains, see
# https://docs.joinmastodon.org/admin/config/#whitelist_mode
# WHITELIST_MODE=true

+ 1
- 1
.nvmrc View File

@@ -1 +1 @@
8
12

+ 156
- 0
CHANGELOG.md View File

@@ -3,6 +3,162 @@ Changelog

All notable changes to this project will be documented in this file.

## Unreleased
### Added

- Add bookmarks ([ThibG](https://github.com/tootsuite/mastodon/pull/7107), [Gargron](https://github.com/tootsuite/mastodon/pull/12494), [Gomasy](https://github.com/tootsuite/mastodon/pull/12381))
- Add announcements ([Gargron](https://github.com/tootsuite/mastodon/pull/12662), [Gargron](https://github.com/tootsuite/mastodon/pull/12967), [Gargron](https://github.com/tootsuite/mastodon/pull/12970), [Gargron](https://github.com/tootsuite/mastodon/pull/12963), [Gargron](https://github.com/tootsuite/mastodon/pull/12950), [Gargron](https://github.com/tootsuite/mastodon/pull/12990), [Gargron](https://github.com/tootsuite/mastodon/pull/12949), [Gargron](https://github.com/tootsuite/mastodon/pull/12989), [Gargron](https://github.com/tootsuite/mastodon/pull/12964), [Gargron](https://github.com/tootsuite/mastodon/pull/12965), [ThibG](https://github.com/tootsuite/mastodon/pull/12958), [ThibG](https://github.com/tootsuite/mastodon/pull/12957), [Gargron](https://github.com/tootsuite/mastodon/pull/12955), [ThibG](https://github.com/tootsuite/mastodon/pull/12946), [ThibG](https://github.com/tootsuite/mastodon/pull/12954))
- Add number animations in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/12948), [Gargron](https://github.com/tootsuite/mastodon/pull/12971))
- Add `kab`, `is`, `kn`, `mr`, `ur` to available locales ([Gargron](https://github.com/tootsuite/mastodon/pull/12882), [BoFFire](https://github.com/tootsuite/mastodon/pull/12962), [Gargron](https://github.com/tootsuite/mastodon/pull/12379))
- Add profile filter category ([ThibG](https://github.com/tootsuite/mastodon/pull/12918))
- Add ability to add oneself to lists ([ThibG](https://github.com/tootsuite/mastodon/pull/12271))
- Add hint how to contribute translations to preferences page ([Sasha-Sorokin](https://github.com/tootsuite/mastodon/pull/12736))
- Add signatures to statuses in archive takeout ([noellabo](https://github.com/tootsuite/mastodon/pull/12649))
- Add support for `magnet:` and `xmpp` links ([ThibG](https://github.com/tootsuite/mastodon/pull/12905), [ThibG](https://github.com/tootsuite/mastodon/pull/12709))
- Add `follow_request` notification type ([ThibG](https://github.com/tootsuite/mastodon/pull/12198))
- Add ability to filter reports by account domain in admin UI ([ThibG](https://github.com/tootsuite/mastodon/pull/12154))
- Add link to search for users connected from the same IP address to admin UI ([ThibG](https://github.com/tootsuite/mastodon/pull/12157))
- Add link to reports targeting a specific domain in admin view ([ThibG](https://github.com/tootsuite/mastodon/pull/12513))
- Add support for EventSource streaming in web UI ([BenLubar](https://github.com/tootsuite/mastodon/pull/12887))
- Add hotkey for opening media attachments in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/12498), [Kjwon15](https://github.com/tootsuite/mastodon/pull/12546))
- Add relationship-based options to status dropdowns in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/12377), [ThibG](https://github.com/tootsuite/mastodon/pull/12535), [Gargron](https://github.com/tootsuite/mastodon/pull/12430))
- Add support for submitting media description with `ctrl`+`enter` in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/12272))
- Add download button to audio and video players in web UI ([NimaBoscarino](https://github.com/tootsuite/mastodon/pull/12179))
- Add setting for whether to crop images in timelines in web UI ([duxovni](https://github.com/tootsuite/mastodon/pull/12126))
- Add support for `Event` activities ([tcitworld](https://github.com/tootsuite/mastodon/pull/12637))
- Add basic support for `Group` actors ([noellabo](https://github.com/tootsuite/mastodon/pull/12071))
- Add `S3_OVERRIDE_PATH_STYLE` environment variable ([Gargron](https://github.com/tootsuite/mastodon/pull/12594))
- Add `S3_OPEN_TIMEOUT` environment variable ([tateisu](https://github.com/tootsuite/mastodon/pull/12459))
- Add `LDAP_MAIL` environment variable ([madmath03](https://github.com/tootsuite/mastodon/pull/12053))
- Add `LDAP_UID_CONVERSION_ENABLED` environment variable ([madmath03](https://github.com/tootsuite/mastodon/pull/12461))
- Add `--remote-only` option to `tootctl emoji purge` ([ThibG](https://github.com/tootsuite/mastodon/pull/12810))
- Add `tootctl media remove-orphans` ([Gargron](https://github.com/tootsuite/mastodon/pull/12568), [Gargron](https://github.com/tootsuite/mastodon/pull/12571))
- Add `tootctl media lookup` command ([irlcatgirl](https://github.com/tootsuite/mastodon/pull/12283))
- Add cache for OEmbed endpoints to avoid extra HTTP requests ([Gargron](https://github.com/tootsuite/mastodon/pull/12403))
- Add support for KaiOS arrow navigation to public pages ([nolanlawson](https://github.com/tootsuite/mastodon/pull/12251))
- Add `discoverable` to accounts in REST API ([trwnh](https://github.com/tootsuite/mastodon/pull/12508))
- Add admin setting to disable default follows ([ArisuOngaku](https://github.com/tootsuite/mastodon/pull/12566))
- Add support for LDAP and PAM in the OAuth password grant strategy ([ntl-purism](https://github.com/tootsuite/mastodon/pull/12390))
- Allow support for `Accept`/`Reject` activities with a non-embedded object ([puckipedia](https://github.com/tootsuite/mastodon/pull/12199))

### Changed

- Change `last_status_at` to be a date, not datetime in REST API ([ThibG](https://github.com/tootsuite/mastodon/pull/12966))
- Change followers page to relationships page in admin UI ([Gargron](https://github.com/tootsuite/mastodon/pull/12927), [Gargron](https://github.com/tootsuite/mastodon/pull/12934))
- Change reported media attachments to always be hidden in admin UI ([Gargron](https://github.com/tootsuite/mastodon/pull/12879), [ThibG](https://github.com/tootsuite/mastodon/pull/12907))
- Change string from "Disable" to "Disable login" in admin UI ([nileshkumar](https://github.com/tootsuite/mastodon/pull/12201))
- Change report page structure in admin UI ([Sasha-Sorokin](https://github.com/tootsuite/mastodon/pull/12615))
- Change swipe sensitivity to be lower on small screens in web UI ([umonaca](https://github.com/tootsuite/mastodon/pull/12168))
- Change audio/video playback to stop playback when out of view in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/12486))
- Change media description label based on upload type in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/12270))
- Change large numbers to render without decimal units in web UI ([noellabo](https://github.com/tootsuite/mastodon/pull/12706))
- Change "Add a choice" button to be disabled rather than hidden when poll limit reached in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/12319), [hinaloe](https://github.com/tootsuite/mastodon/pull/12544))
- Change `tootctl statuses remove` to keep statuses favourited or bookmarked by local users ([ThibG](https://github.com/tootsuite/mastodon/pull/11267), [Gomasy](https://github.com/tootsuite/mastodon/pull/12818))
- Change domain block behavior to update user records (fast) before deleting data (slower) ([ThibG](https://github.com/tootsuite/mastodon/pull/12247))
- Change behaviour to strip audio metadata on uploads ([hugogameiro](https://github.com/tootsuite/mastodon/pull/12171))
- Change accepted length of remote media descriptions from 420 to 1,500 characters ([ThibG](https://github.com/tootsuite/mastodon/pull/12262))
- Change preferences pages structure ([Sasha-Sorokin](https://github.com/tootsuite/mastodon/pull/12497), [mayaeh](https://github.com/tootsuite/mastodon/pull/12517), [Sasha-Sorokin](https://github.com/tootsuite/mastodon/pull/12801), [Sasha-Sorokin](https://github.com/tootsuite/mastodon/pull/12797), [Sasha-Sorokin](https://github.com/tootsuite/mastodon/pull/12799), [Sasha-Sorokin](https://github.com/tootsuite/mastodon/pull/12793))
- Change format of titles in RSS ([devkral](https://github.com/tootsuite/mastodon/pull/8596))
- Change favourite icon animation from spring-based motion to CSS animation in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/12175))
- Change minimum required Node.js version to 10, and default to 12 ([Shleeble](https://github.com/tootsuite/mastodon/pull/12791), [mkody](https://github.com/tootsuite/mastodon/pull/12906), [Shleeble](https://github.com/tootsuite/mastodon/pull/12703))
- Change spam check to exempt server staff ([ThibG](https://github.com/tootsuite/mastodon/pull/12874))
- Change to fallback to to `Create` audience when `object` has no defined audience ([ThibG](https://github.com/tootsuite/mastodon/pull/12249))
- Change Twemoji library to 12.1.3 in web UI ([koyuawsmbrtn](https://github.com/tootsuite/mastodon/pull/12342))
- Change blocked users to be hidden from following/followers lists ([ThibG](https://github.com/tootsuite/mastodon/pull/12733))

### Removed

- Remove unused dependencies ([ykzts](https://github.com/tootsuite/mastodon/pull/12861), [mayaeh](https://github.com/tootsuite/mastodon/pull/12826), [ThibG](https://github.com/tootsuite/mastodon/pull/12822), [ykzts](https://github.com/tootsuite/mastodon/pull/12533))

### Fixed

- Fix some translatable strings being used wrongly ([Sasha-Sorokin](https://github.com/tootsuite/mastodon/pull/12569), [Sasha-Sorokin](https://github.com/tootsuite/mastodon/pull/12589), [Sasha-Sorokin](https://github.com/tootsuite/mastodon/pull/12502), [mayaeh](https://github.com/tootsuite/mastodon/pull/12231))
- Fix headline of public timeline page when set to local-only ([ykzts](https://github.com/tootsuite/mastodon/pull/12224))
- Fix space between tabs not being spread evenly in web UI ([Sasha-Sorokin](https://github.com/tootsuite/mastodon/pull/12944), [Sasha-Sorokin](https://github.com/tootsuite/mastodon/pull/12961), [Sasha-Sorokin](https://github.com/tootsuite/mastodon/pull/12446))
- Fix interactive delays in database migrations with no TTY ([Gargron](https://github.com/tootsuite/mastodon/pull/12969))
- Fix status overflowing in report dialog in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/12959))
- Fix unlocalized dropdown button title in web UI ([Sasha-Sorokin](https://github.com/tootsuite/mastodon/pull/12947))
- Fix media attachments without file being uploadable ([Gargron](https://github.com/tootsuite/mastodon/pull/12562))
- Fix unfollow confirmations in profile directory in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/12922))
- Fix duplicate `description` meta tag on accounts public pages ([ThibG](https://github.com/tootsuite/mastodon/pull/12923))
- Fix slow query of federated timeline ([notozeki](https://github.com/tootsuite/mastodon/pull/12886))
- Fix not all of account's active IPs showing up in admin UI ([Gargron](https://github.com/tootsuite/mastodon/pull/12909), [Gargron](https://github.com/tootsuite/mastodon/pull/12943))
- Fix search by IP not using alternative browser sessions in admin UI ([Gargron](https://github.com/tootsuite/mastodon/pull/12904))
- Fix “X new items” not showing up for slow mode on empty timelines in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/12875))
- Fix OEmbed endpoint being inaccessible in secure mode ([Gargron](https://github.com/tootsuite/mastodon/pull/12864))
- Fix proofs API being inaccessible in secure mode ([Gargron](https://github.com/tootsuite/mastodon/pull/12495))
- Fix Ruby 2.7 incompatibilities ([ThibG](https://github.com/tootsuite/mastodon/pull/12831), [ThibG](https://github.com/tootsuite/mastodon/pull/12824), [Shleeble](https://github.com/tootsuite/mastodon/pull/12759), [zunda](https://github.com/tootsuite/mastodon/pull/12769))
- Fix invalid poll votes being accepted in REST API ([ThibG](https://github.com/tootsuite/mastodon/pull/12601))
- Fix old migrations failing because of strong migrations update ([ThibG](https://github.com/tootsuite/mastodon/pull/12787), [ThibG](https://github.com/tootsuite/mastodon/pull/12692))
- Fix reuse of detailed status components in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/12792))
- Fix base64-encoded file uploads not being possible in REST API ([Gargron](https://github.com/tootsuite/mastodon/pull/12748), [Gargron](https://github.com/tootsuite/mastodon/pull/12857))
- Fix resource_owner_from_credentials in Doorkeeper initializer ([Gargron](https://github.com/tootsuite/mastodon/pull/12743))
- Fix error due to missing authentication call in filters controller ([Gargron](https://github.com/tootsuite/mastodon/pull/12746))
- Fix uncaught unknown format error in host meta controller ([Gargron](https://github.com/tootsuite/mastodon/pull/12747))
- Fix URL search not returning private toots user has access to ([ThibG](https://github.com/tootsuite/mastodon/pull/12742), [ThibG](https://github.com/tootsuite/mastodon/pull/12336))
- Fix cache digesting log noise on status embeds ([Gargron](https://github.com/tootsuite/mastodon/pull/12750))
- Fix slowness due to layout thrashing when reloading a large set of statuses in web UI ([panarom](https://github.com/tootsuite/mastodon/pull/12661), [panarom](https://github.com/tootsuite/mastodon/pull/12744), [Gargron](https://github.com/tootsuite/mastodon/pull/12712))
- Fix error when fetching followers/following from REST API when user has network hidden ([Gargron](https://github.com/tootsuite/mastodon/pull/12716))
- Fix IDN mentions not being processed, IDN domains not being rendered ([Gargron](https://github.com/tootsuite/mastodon/pull/12715))
- Fix error when searching for empty phrase ([Gargron](https://github.com/tootsuite/mastodon/pull/12711))
- Fix backups stopping due to read timeouts ([chr-1x](https://github.com/tootsuite/mastodon/pull/12281))
- Fix batch actions on non-pending tags in admin UI ([ThibG](https://github.com/tootsuite/mastodon/pull/12537))
- Fix sample `SAML_ACS_URL`, `SAML_ISSUER` ([orlea](https://github.com/tootsuite/mastodon/pull/12669))
- Fix manual scrolling issue on Firefox/Windows in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/12648))
- Fix archive takeout failing if total dump size exceeds 2GB ([scd31](https://github.com/tootsuite/mastodon/pull/12602), [Gargron](https://github.com/tootsuite/mastodon/pull/12653))
- Fix custom emoji category creation silently erroring out on duplicate category ([ThibG](https://github.com/tootsuite/mastodon/pull/12647))
- Fix link crawler not specifying preferred content type ([ThibG](https://github.com/tootsuite/mastodon/pull/12646))
- Fix featured hashtag setting page erroring out instead of rejecting invalid tags ([ThibG](https://github.com/tootsuite/mastodon/pull/12436))
- Fix tooltip messages of single/multiple-choice polls switcher being reversed in web UI ([acid-chicken](https://github.com/tootsuite/mastodon/pull/12616))
- Fix typo in help text of `tootctl statuses remove` ([trwnh](https://github.com/tootsuite/mastodon/pull/12603))
- Fix generic HTTP 500 error on duplicate records ([Gargron](https://github.com/tootsuite/mastodon/pull/12563))
- Fix old migration failing with new status default scope ([ThibG](https://github.com/tootsuite/mastodon/pull/12493))
- Fix errors when using search API with no query ([Gargron](https://github.com/tootsuite/mastodon/pull/12541), [trwnh](https://github.com/tootsuite/mastodon/pull/12549))
- Fix poll options not being selectable via keyboard in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/12538))
- Fix conversations not having an unread indicator in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/12506))
- Fix lost focus when modals open/close in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/12437))
- Fix pending upload count not being decremented on error in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/12499))
- Fix empty poll options not being removed on remote poll update ([ThibG](https://github.com/tootsuite/mastodon/pull/12484))
- Fix OCR with delete & redraft in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/12465))
- Fix blur behind closed registration message ([ThibG](https://github.com/tootsuite/mastodon/pull/12442))
- Fix OEmbed discovery not handling different URL variants in query ([Gargron](https://github.com/tootsuite/mastodon/pull/12439))
- Fix link crawler crashing on `<a>` tags without `href` ([ThibG](https://github.com/tootsuite/mastodon/pull/12159))
- Fix whitelisted subdomains being ignored in whitelist mode ([noiob](https://github.com/tootsuite/mastodon/pull/12435))
- Fix broken audit log in whitelist mode in admin UI ([ThibG](https://github.com/tootsuite/mastodon/pull/12303))
- Fix unread indicator not honoring "Only media" option in local and federated timelines in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/12330))
- Fix error when rebuilding home feeds ([dariusk](https://github.com/tootsuite/mastodon/pull/12324))
- Fix relationship caches being broken as result of a follow request ([ThibG](https://github.com/tootsuite/mastodon/pull/12299))
- Fix more items than the limit being uploadable in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/12300))
- Fix various issues with account migration ([ThibG](https://github.com/tootsuite/mastodon/pull/12301))
- Fix filtered out items being counted as pending items in slow mode in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/12266))
- Fix notification filters not applying to poll options ([ThibG](https://github.com/tootsuite/mastodon/pull/12269))
- Fix notification message for user's own poll saying it's a poll they voted on in web UI ([ykzts](https://github.com/tootsuite/mastodon/pull/12219))
- Fix polls with an expiration not showing up as expired in web UI ([noellabo](https://github.com/tootsuite/mastodon/pull/12222))
- Fix volume slider having an offset between cursor and slider in Chromium in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/12158))
- Fix Vagrant image not accepting connections ([shrft](https://github.com/tootsuite/mastodon/pull/12180))
- Fix batch actions being hidden on small screens in admin UI ([ThibG](https://github.com/tootsuite/mastodon/pull/12183))
- Fix incoming federation not working in whitelist mode ([ThibG](https://github.com/tootsuite/mastodon/pull/12185))
- Fix error when passing empty `source` param to `PUT /api/v1/accounts/update_credentials` ([jglauche](https://github.com/tootsuite/mastodon/pull/12259))
- Fix HTTP-based streaming API being cacheable by proxies ([BenLubar](https://github.com/tootsuite/mastodon/pull/12945))
- Fix users being able to register while `tootctl self-destruct` is in progress ([Kjwon15](https://github.com/tootsuite/mastodon/pull/12877))
- Fix microformats detection in link crawler not ignoring `h-card` links ([nightpool](https://github.com/tootsuite/mastodon/pull/12189))
- Fix outline on full-screen video in web UI ([hinaloe](https://github.com/tootsuite/mastodon/pull/12176))
- Fix TLD domain blocks not being editable ([ThibG](https://github.com/tootsuite/mastodon/pull/12805))
- Fix Nanobox deploy hooks ([danhunsaker](https://github.com/tootsuite/mastodon/pull/12663))
- Fix needlessly complicated SQL query when performing account search amongst followings ([ThibG](https://github.com/tootsuite/mastodon/pull/12302))
- Fix favourites count not updating when unfavouriting in web UI ([NimaBoscarino](https://github.com/tootsuite/mastodon/pull/12140))
- Fix occasional crash on scroll in Chromium in web UI ([hinaloe](https://github.com/tootsuite/mastodon/pull/12274))
- Fix intersection observer not working in single-column mode web UI ([panarom](https://github.com/tootsuite/mastodon/pull/12735))
- Fix voting issue with remote polls that contain trailing spaces ([ThibG](https://github.com/tootsuite/mastodon/pull/12515))
- Fix dynamic elements not working in pgHero due to CSP rules ([ykzts](https://github.com/tootsuite/mastodon/pull/12489))
- Fix overly verbose backtraces when delivering ActivityPub payloads ([zunda](https://github.com/tootsuite/mastodon/pull/12798))

### Security

- Fix OEmbed leaking information about existence of non-public statuses ([Gargron](https://github.com/tootsuite/mastodon/pull/12930))
- Fix password change/reset not immediately invalidating other sessions ([Gargron](https://github.com/tootsuite/mastodon/pull/12928))
- Fix settings pages being cacheable by the browser ([Gargron](https://github.com/tootsuite/mastodon/pull/12714))

## [3.0.1] - 2019-10-10
### Added


+ 3
- 1
Dockerfile View File

@@ -58,7 +58,9 @@ RUN npm install -g yarn && \
COPY Gemfile* package.json yarn.lock /opt/mastodon/

RUN cd /opt/mastodon && \
bundle install -j$(nproc) --deployment --without development test && \
bundle config set deployment 'true' && \
bundle config set without 'development test' && \
bundle install -j$(nproc) && \
yarn install --pure-lockfile

FROM ubuntu:18.04

+ 1
- 0
Gemfile View File

@@ -9,6 +9,7 @@ gem 'puma', '~> 4.3'
gem 'rails', '~> 5.2.4'
gem 'sprockets', '~> 3.7.2'
gem 'thor', '~> 0.20'
gem 'rack', '~> 2.1.2'

gem 'thwait', '~> 0.1.0'
gem 'e2mmap', '~> 0.1.0'

+ 2
- 1
Gemfile.lock View File

@@ -443,7 +443,7 @@ GEM
pundit (2.1.0)
activesupport (>= 3.0.0)
raabro (1.1.6)
rack (2.1.1)
rack (2.1.2)
rack-attack (6.2.2)
rack (>= 1.0, < 3)
rack-cors (1.1.1)
@@ -753,6 +753,7 @@ DEPENDENCIES
pry-rails (~> 0.3)
puma (~> 4.3)
pundit (~> 2.1)
rack (~> 2.1.2)
rack-attack (~> 6.2)
rack-cors (~> 1.1)
rails (~> 5.2.4)

+ 1
- 1
Vagrantfile View File

@@ -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'

# Add repo for NodeJS
curl -sL https://deb.nodesource.com/setup_8.x | sudo bash -
curl -sL https://deb.nodesource.com/setup_10.x | sudo bash -

# 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"]}

+ 88
- 0
app/controllers/admin/announcements_controller.rb View File

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

class Admin::AnnouncementsController < Admin::BaseController
before_action :set_announcements, only: :index
before_action :set_announcement, except: [:index, :new, :create]

def index
authorize :announcement, :index?
end

def new
authorize :announcement, :create?

@announcement = Announcement.new
end

def create
authorize :announcement, :create?

@announcement = Announcement.new(resource_params)

if @announcement.save
PublishScheduledAnnouncementWorker.perform_async(@announcement.id) if @announcement.published?
log_action :create, @announcement
redirect_to admin_announcements_path, notice: @announcement.published? ? I18n.t('admin.announcements.published_msg') : I18n.t('admin.announcements.scheduled_msg')
else
render :new
end
end

def edit
authorize :announcement, :update?
end

def update
authorize :announcement, :update?

if @announcement.update(resource_params)
PublishScheduledAnnouncementWorker.perform_async(@announcement.id) if @announcement.published?
log_action :update, @announcement
redirect_to admin_announcements_path, notice: I18n.t('admin.announcements.updated_msg')
else
render :edit
end
end

def publish
authorize :announcement, :update?
@announcement.publish!
PublishScheduledAnnouncementWorker.perform_async(@announcement.id)
log_action :update, @announcement
redirect_to admin_announcements_path, notice: I18n.t('admin.announcements.published_msg')
end

def unpublish
authorize :announcement, :update?
@announcement.unpublish!
UnpublishAnnouncementWorker.perform_async(@announcement.id)
log_action :update, @announcement
redirect_to admin_announcements_path, notice: I18n.t('admin.announcements.unpublished_msg')
end

def destroy
authorize :announcement, :destroy?
@announcement.destroy!
UnpublishAnnouncementWorker.perform_async(@announcement.id) if @announcement.published?
log_action :destroy, @announcement
redirect_to admin_announcements_path, notice: I18n.t('admin.announcements.destroyed_msg')
end

private

def set_announcements
@announcements = AnnouncementFilter.new(filter_params).results.page(params[:page])
end

def set_announcement
@announcement = Announcement.find(params[:id])
end

def filter_params
params.slice(*AnnouncementFilter::KEYS).permit(*AnnouncementFilter::KEYS)
end

def resource_params
params.require(:announcement).permit(:text, :scheduled_at, :starts_at, :ends_at, :all_day)
end
end

+ 0
- 18
app/controllers/admin/followers_controller.rb View File

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

module Admin
class FollowersController < BaseController
before_action :set_account

PER_PAGE = 40

def index
authorize :account, :index?
@followers = @account.followers.local.recent.page(params[:page]).per(PER_PAGE)
end

def set_account
@account = Account.find(params[:account_id])
end
end
end

+ 25
- 0
app/controllers/admin/relationships_controller.rb View File

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

module Admin
class RelationshipsController < BaseController
before_action :set_account

PER_PAGE = 40

def index
authorize :account, :index?

@accounts = RelationshipFilter.new(@account, filter_params).results.page(params[:page]).per(PER_PAGE)
end

private

def set_account
@account = Account.find(params[:account_id])
end

def filter_params
params.slice(*RelationshipFilter::KEYS).permit(*RelationshipFilter::KEYS)
end
end
end

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

@@ -85,7 +85,7 @@ class Api::BaseController < ApplicationController
end

def require_authenticated_user!
render json: { error: 'This API requires an authenticated user' }, status: 401 unless current_user
render json: { error: 'This method requires an authenticated user' }, status: 401 unless current_user
end

def require_user!

+ 11
- 3
app/controllers/api/oembed_controller.rb View File

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

class Api::OEmbedController < Api::BaseController
respond_to :json

skip_before_action :require_authenticated_user!

before_action :set_status
before_action :require_public_status!

def show
@status = status_finder.status
render json: @status, serializer: OEmbedSerializer, width: maxwidth_or_default, height: maxheight_or_default
end

private

def set_status
@status = status_finder.status
end

def require_public_status!
not_found if @status.hidden?
end

def status_finder
StatusFinder.new(params[:url])
end

+ 29
- 0
app/controllers/api/v1/announcements/reactions_controller.rb View File

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

class Api::V1::Announcements::ReactionsController < Api::BaseController
before_action -> { doorkeeper_authorize! :write, :'write:favourites' }
before_action :require_user!

before_action :set_announcement
before_action :set_reaction, except: :update

def update
@announcement.announcement_reactions.create!(account: current_account, name: params[:id])
render_empty
end

def destroy
@reaction.destroy!
render_empty
end

private

def set_reaction
@reaction = @announcement.announcement_reactions.where(account: current_account).find_by!(name: params[:id])
end

def set_announcement
@announcement = Announcement.published.find(params[:announcement_id])
end
end

+ 29
- 0
app/controllers/api/v1/announcements_controller.rb View File

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

class Api::V1::AnnouncementsController < Api::BaseController
before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, only: :dismiss
before_action :require_user!
before_action :set_announcements, only: :index
before_action :set_announcement, except: :index

def index
render json: @announcements, each_serializer: REST::AnnouncementSerializer
end

def dismiss
AnnouncementMute.create!(account: current_account, announcement: @announcement)
render_empty
end

private

def set_announcements
@announcements = begin
Announcement.published.chronological
end
end

def set_announcement
@announcement = Announcement.published.find(params[:id])
end
end

+ 6
- 0
app/controllers/auth/passwords_controller.rb View File

@@ -7,6 +7,12 @@ class Auth::PasswordsController < Devise::PasswordsController

layout 'auth'

def update
super do |resource|
resource.session_activations.destroy_all if resource.errors.empty?
end
end

private

def check_validity_of_reset_password_token

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

@@ -23,10 +23,17 @@ class Auth::RegistrationsController < Devise::RegistrationsController
not_found
end

def update
super do |resource|
resource.clear_other_sessions(current_session.session_id) if resource.saved_change_to_encrypted_password?
end
end

protected

def update_resource(resource, params)
params[:password] = nil if Devise.pam_authentication && resource.encrypted_password.blank?

super
end


+ 3
- 43
app/controllers/relationships_controller.rb View File

@@ -20,53 +20,13 @@ class RelationshipsController < ApplicationController
rescue ActionController::ParameterMissing
# Do nothing
ensure
redirect_to relationships_path(current_params)
redirect_to relationships_path(filter_params)
end

private

def set_accounts
@accounts = relationships_scope.page(params[:page]).per(40)
end

def relationships_scope
scope = begin
if following_relationship?
current_account.following.eager_load(:account_stat).reorder(nil)
else
current_account.followers.eager_load(:account_stat).reorder(nil)
end
end

scope.merge!(Follow.recent) if params[:order].blank? || params[:order] == 'recent'
scope.merge!(Account.by_recent_status) if params[:order] == 'active'
scope.merge!(mutual_relationship_scope) if mutual_relationship?
scope.merge!(moved_account_scope) if params[:status] == 'moved'
scope.merge!(primary_account_scope) if params[:status] == 'primary'
scope.merge!(by_domain_scope) if params[:by_domain].present?
scope.merge!(dormant_account_scope) if params[:activity] == 'dormant'

scope
end

def mutual_relationship_scope
Account.where(id: current_account.following)
end

def moved_account_scope
Account.where.not(moved_to_account_id: nil)
end

def primary_account_scope
Account.where(moved_to_account_id: nil)
end

def dormant_account_scope
AccountStat.where(last_status_at: nil).or(AccountStat.where(AccountStat.arel_table[:last_status_at].lt(1.month.ago)))
end

def by_domain_scope
Account.where(domain: params[:by_domain])
@accounts = RelationshipFilter.new(current_account, filter_params).results.page(params[:page]).per(40)
end

def form_account_batch_params
@@ -85,7 +45,7 @@ class RelationshipsController < ApplicationController
params[:relationship] == 'followed_by'
end

def current_params
def filter_params
params.slice(:page, *RelationshipFilter::KEYS).permit(:page, *RelationshipFilter::KEYS)
end


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

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

def embed
use_pack 'embed'
raise ActiveRecord::RecordNotFound if @status.hidden?
return not_found if @status.hidden?

expires_in 180, public: true
response.headers['X-Frame-Options'] = 'ALLOWALL'
@@ -71,7 +71,7 @@ class StatusesController < ApplicationController
@status = @account.statuses.find(params[:id])
authorize @status, :show?
rescue Mastodon::NotPermittedError
raise ActiveRecord::RecordNotFound
not_found
end

def set_instance_presenter

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

@@ -22,6 +22,8 @@ module Admin::ActionLogsHelper
log.recorded_changes.slice('severity', 'reject_media', 'force_sensitive')
elsif log.target_type == 'Status' && log.action == :update
log.recorded_changes.slice('sensitive')
elsif log.target_type == 'Announcement' && log.action == :update
log.recorded_changes.slice('text', 'starts_at', 'ends_at', 'all_day')
end
end

@@ -52,6 +54,8 @@ module Admin::ActionLogsHelper
'pencil'
when 'AccountWarning'
'warning'
when 'Announcement'
'bullhorn'
end
end

@@ -96,6 +100,8 @@ module Admin::ActionLogsHelper
link_to record.account.acct, ActivityPub::TagManager.instance.url_for(record)
when 'AccountWarning'
link_to record.target_account.acct, admin_account_path(record.target_account_id)
when 'Announcement'
link_to "##{record.id}", edit_admin_announcement_path(record.id)
end
end

@@ -115,6 +121,8 @@ module Admin::ActionLogsHelper
else
I18n.t('admin.action_logs.deleted_status')
end
when 'Announcement'
"##{attributes['id']}"
end
end
end

+ 11
- 0
app/helpers/admin/announcements_helper.rb View File

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

module Admin::AnnouncementsHelper
def time_range(announcement)
if announcement.all_day?
safe_join([l(announcement.starts_at.to_date), ' - ', l(announcement.ends_at.to_date)])
else
safe_join([l(announcement.starts_at), ' - ', l(announcement.ends_at)])
end
end
end

+ 1
- 0
app/helpers/admin/filter_helper.rb View File

@@ -9,6 +9,7 @@ module Admin::FilterHelper
InstanceFilter::KEYS,
InviteFilter::KEYS,
RelationshipFilter::KEYS,
AnnouncementFilter::KEYS,
].flatten.freeze

def filter_link_to(text, link_to_params, link_class_params = link_to_params)

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

@@ -37,6 +37,7 @@ module SettingsHelper
it: 'Italiano',
ja: '日本語',
ka: 'ქართული',
kab: 'Taqbaylit',
kk: 'Қазақша',
kn: 'ಕನ್ನಡ',
ko: '한국어',

+ 180
- 0
app/javascript/flavours/glitch/actions/announcements.js View File

@@ -0,0 +1,180 @@
import api from 'flavours/glitch/util/api';
import { normalizeAnnouncement } from './importer/normalizer';

export const ANNOUNCEMENTS_FETCH_REQUEST = 'ANNOUNCEMENTS_FETCH_REQUEST';
export const ANNOUNCEMENTS_FETCH_SUCCESS = 'ANNOUNCEMENTS_FETCH_SUCCESS';
export const ANNOUNCEMENTS_FETCH_FAIL = 'ANNOUNCEMENTS_FETCH_FAIL';
export const ANNOUNCEMENTS_UPDATE = 'ANNOUNCEMENTS_UPDATE';
export const ANNOUNCEMENTS_DELETE = 'ANNOUNCEMENTS_DELETE';

export const ANNOUNCEMENTS_DISMISS_REQUEST = 'ANNOUNCEMENTS_DISMISS_REQUEST';
export const ANNOUNCEMENTS_DISMISS_SUCCESS = 'ANNOUNCEMENTS_DISMISS_SUCCESS';
export const ANNOUNCEMENTS_DISMISS_FAIL = 'ANNOUNCEMENTS_DISMISS_FAIL';

export const ANNOUNCEMENTS_REACTION_ADD_REQUEST = 'ANNOUNCEMENTS_REACTION_ADD_REQUEST';
export const ANNOUNCEMENTS_REACTION_ADD_SUCCESS = 'ANNOUNCEMENTS_REACTION_ADD_SUCCESS';
export const ANNOUNCEMENTS_REACTION_ADD_FAIL = 'ANNOUNCEMENTS_REACTION_ADD_FAIL';

export const ANNOUNCEMENTS_REACTION_REMOVE_REQUEST = 'ANNOUNCEMENTS_REACTION_REMOVE_REQUEST';
export const ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS = 'ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS';
export const ANNOUNCEMENTS_REACTION_REMOVE_FAIL = 'ANNOUNCEMENTS_REACTION_REMOVE_FAIL';

export const ANNOUNCEMENTS_REACTION_UPDATE = 'ANNOUNCEMENTS_REACTION_UPDATE';

export const ANNOUNCEMENTS_TOGGLE_SHOW = 'ANNOUNCEMENTS_TOGGLE_SHOW';

const noOp = () => {};

export const fetchAnnouncements = (done = noOp) => (dispatch, getState) => {
dispatch(fetchAnnouncementsRequest());

api(getState).get('/api/v1/announcements').then(response => {
dispatch(fetchAnnouncementsSuccess(response.data.map(x => normalizeAnnouncement(x))));
}).catch(error => {
dispatch(fetchAnnouncementsFail(error));
}).finally(() => {
done();
});
};

export const fetchAnnouncementsRequest = () => ({
type: ANNOUNCEMENTS_FETCH_REQUEST,
skipLoading: true,
});

export const fetchAnnouncementsSuccess = announcements => ({
type: ANNOUNCEMENTS_FETCH_SUCCESS,
announcements,
skipLoading: true,
});

export const fetchAnnouncementsFail= error => ({
type: ANNOUNCEMENTS_FETCH_FAIL,
error,
skipLoading: true,
skipAlert: true,
});

export const updateAnnouncements = announcement => ({
type: ANNOUNCEMENTS_UPDATE,
announcement: normalizeAnnouncement(announcement),
});

export const dismissAnnouncement = announcementId => (dispatch, getState) => {
dispatch(dismissAnnouncementRequest(announcementId));

api(getState).post(`/api/v1/announcements/${announcementId}/dismiss`).then(() => {
dispatch(dismissAnnouncementSuccess(announcementId));
}).catch(error => {
dispatch(dismissAnnouncementFail(announcementId, error));
});
};

export const dismissAnnouncementRequest = announcementId => ({
type: ANNOUNCEMENTS_DISMISS_REQUEST,
id: announcementId,
});

export const dismissAnnouncementSuccess = announcementId => ({
type: ANNOUNCEMENTS_DISMISS_SUCCESS,
id: announcementId,
});

export const dismissAnnouncementFail = (announcementId, error) => ({
type: ANNOUNCEMENTS_DISMISS_FAIL,
id: announcementId,
error,
});

export const addReaction = (announcementId, name) => (dispatch, getState) => {
const announcement = getState().getIn(['announcements', 'items']).find(x => x.get('id') === announcementId);

let alreadyAdded = false;

if (announcement) {
const reaction = announcement.get('reactions').find(x => x.get('name') === name);
if (reaction && reaction.get('me')) {
alreadyAdded = true;
}
}

if (!alreadyAdded) {
dispatch(addReactionRequest(announcementId, name, alreadyAdded));
}

api(getState).put(`/api/v1/announcements/${announcementId}/reactions/${name}`).then(() => {
dispatch(addReactionSuccess(announcementId, name, alreadyAdded));
}).catch(err => {
if (!alreadyAdded) {
dispatch(addReactionFail(announcementId, name, err));
}
});
};

export const addReactionRequest = (announcementId, name) => ({
type: ANNOUNCEMENTS_REACTION_ADD_REQUEST,
id: announcementId,
name,
skipLoading: true,
});

export const addReactionSuccess = (announcementId, name) => ({
type: ANNOUNCEMENTS_REACTION_ADD_SUCCESS,
id: announcementId,
name,
skipLoading: true,
});

export const addReactionFail = (announcementId, name, error) => ({
type: ANNOUNCEMENTS_REACTION_ADD_FAIL,
id: announcementId,
name,
error,
skipLoading: true,
});

export const removeReaction = (announcementId, name) => (dispatch, getState) => {
dispatch(removeReactionRequest(announcementId, name));

api(getState).delete(`/api/v1/announcements/${announcementId}/reactions/${name}`).then(() => {
dispatch(removeReactionSuccess(announcementId, name));
}).catch(err => {
dispatch(removeReactionFail(announcementId, name, err));
});
};

export const removeReactionRequest = (announcementId, name) => ({
type: ANNOUNCEMENTS_REACTION_REMOVE_REQUEST,
id: announcementId,
name,
skipLoading: true,
});

export const removeReactionSuccess = (announcementId, name) => ({
type: ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS,
id: announcementId,
name,
skipLoading: true,
});

export const removeReactionFail = (announcementId, name, error) => ({
type: ANNOUNCEMENTS_REACTION_REMOVE_FAIL,
id: announcementId,
name,
error,
skipLoading: true,
});

export const updateReaction = reaction => ({
type: ANNOUNCEMENTS_REACTION_UPDATE,
reaction,
});

export const toggleShowAnnouncements = () => ({
type: ANNOUNCEMENTS_TOGGLE_SHOW,
});

export const deleteAnnouncement = id => ({
type: ANNOUNCEMENTS_DELETE,
id,
});

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

@@ -74,7 +74,6 @@ export function normalizeStatus(status, normalOldStatus) {

export function normalizePoll(poll) {
const normalPoll = { ...poll };

const emojiMap = makeEmojiMap(normalPoll);

normalPoll.options = poll.options.map((option, index) => ({
@@ -85,3 +84,12 @@ export function normalizePoll(poll) {

return normalPoll;
}

export function normalizeAnnouncement(announcement) {
const normalAnnouncement = { ...announcement };
const emojiMap = makeEmojiMap(normalAnnouncement);

normalAnnouncement.contentHtml = emojify(normalAnnouncement.content, emojiMap);

return normalAnnouncement;
}

+ 44
- 2
app/javascript/flavours/glitch/actions/markers.js View File

@@ -1,9 +1,15 @@
import api from 'flavours/glitch/util/api';

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

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

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

if (lastHomeId) {
params.home = {
@@ -11,7 +17,7 @@ export const submitMarkers = () => (dispatch, getState) => {
};
}

if (lastNotificationId) {
if (lastNotificationId && lastNotificationId !== '0') {
params.notifications = {
last_read_id: lastNotificationId,
};
@@ -28,3 +34,39 @@ export const submitMarkers = () => (dispatch, getState) => {
client.setRequestHeader('Authorization', `Bearer ${accessToken}`);
client.send(JSON.stringify(params));
};

export const fetchMarkers = () => (dispatch, getState) => {
const params = { timeline: ['notifications'] };

dispatch(fetchMarkersRequest());

api(getState).get('/api/v1/markers', { params }).then(response => {
dispatch(fetchMarkersSuccess(response.data));
}).catch(error => {
dispatch(fetchMarkersFail(error));
});
};

export function fetchMarkersRequest() {
return {
type: MARKERS_FETCH_REQUEST,
skipLoading: true,
};
};

export function fetchMarkersSuccess(markers) {
return {
type: MARKERS_FETCH_SUCCESS,
markers,
skipLoading: true,
};
};

export function fetchMarkersFail(error) {
return {
type: MARKERS_FETCH_FAIL,
error,
skipLoading: true,
skipAlert: true,
};
};

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

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

dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems));
fetchRelatedRelationships(dispatch, response.data);
done();
}).catch(error => {
dispatch(expandNotificationsFail(error, isLoadingMore));
}).finally(() => {
done();
});
};
@@ -199,6 +199,7 @@ export function expandNotificationsFail(error, isLoadingMore) {
type: NOTIFICATIONS_EXPAND_FAIL,
error,
skipLoading: !isLoadingMore,
skipAlert: !isLoadingMore,
};
};


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

@@ -8,6 +8,12 @@ import {
} from './timelines';
import { updateNotifications, expandNotifications } from './notifications';
import { updateConversations } from './conversations';
import {
fetchAnnouncements,
updateAnnouncements,
updateReaction as updateAnnouncementsReaction,
deleteAnnouncement,
} from './announcements';
import { fetchFilters } from './filters';
import { getLocale } from 'mastodon/locales';

@@ -44,6 +50,15 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null,
case 'filters_changed':
dispatch(fetchFilters());
break;
case 'announcement':
dispatch(updateAnnouncements(JSON.parse(data.payload)));
break;
case 'announcement.reaction':
dispatch(updateAnnouncementsReaction(JSON.parse(data.payload)));
break;
case 'announcement.delete':
dispatch(deleteAnnouncement(data.payload));
break;
}
},
};
@@ -51,7 +66,9 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null,
}

const refreshHomeTimelineAndNotification = (dispatch, done) => {
dispatch(expandHomeTimeline({}, () => dispatch(expandNotifications({}, done))));
dispatch(expandHomeTimeline({}, () =>
dispatch(expandNotifications({}, () =>
dispatch(fetchAnnouncements(done))))));
};

export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification);

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

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

dispatch(importFetchedStatuses(response.data));
dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems));
done();
}).catch(error => {
dispatch(expandTimelineFail(timelineId, error, isLoadingMore));
}).finally(() => {
done();
});
};

+ 65
- 0
app/javascript/flavours/glitch/components/animated_number.js View File

@@ -0,0 +1,65 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedNumber } from 'react-intl';
import TransitionMotion from 'react-motion/lib/TransitionMotion';
import spring from 'react-motion/lib/spring';
import { reduceMotion } from 'flavours/glitch/util/initial_state';

export default class AnimatedNumber extends React.PureComponent {

static propTypes = {
value: PropTypes.number.isRequired,
};

state = {
direction: 1,
};

componentWillReceiveProps (nextProps) {
if (nextProps.value > this.props.value) {
this.setState({ direction: 1 });
} else if (nextProps.value < this.props.value) {
this.setState({ direction: -1 });
}
}

willEnter = () => {
const { direction } = this.state;

return { y: -1 * direction };
}

willLeave = () => {
const { direction } = this.state;

return { y: spring(1 * direction, { damping: 35, stiffness: 400 }) };
}

render () {
const { value } = this.props;
const { direction } = this.state;

if (reduceMotion) {
return <FormattedNumber value={value} />;
}

const styles = [{
key: `${value}`,
data: value,
style: { y: spring(0, { damping: 35, stiffness: 400 }) },
}];

return (
<TransitionMotion styles={styles} willEnter={this.willEnter} willLeave={this.willLeave}>
{items => (
<span className='animated-number'>
{items.map(({ key, data, style }) => (
<span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}><FormattedNumber value={data} /></span>
))}
</span>
)}
</TransitionMotion>
);
}

}

+ 6
- 54
app/javascript/flavours/glitch/components/column_header.js View File

@@ -2,18 +2,14 @@ import React from 'react';
import PropTypes from 'prop-types';
import { createPortal } from 'react-dom';
import classNames from 'classnames';
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
import Icon from 'flavours/glitch/components/icon';

import NotificationPurgeButtonsContainer from 'flavours/glitch/containers/notification_purge_buttons_container';

const messages = defineMessages({
show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' },
moveLeft: { id: 'column_header.moveLeft_settings', defaultMessage: 'Move column to the left' },
moveRight: { id: 'column_header.moveRight_settings', defaultMessage: 'Move column to the right' },
enterNotifCleaning : { id: 'notification_purge.start', defaultMessage: 'Enter notification cleaning mode' },
});

export default @injectIntl
@@ -28,26 +24,21 @@ class ColumnHeader extends React.PureComponent {
title: PropTypes.node,
icon: PropTypes.string,
active: PropTypes.bool,
localSettings : ImmutablePropTypes.map,
multiColumn: PropTypes.bool,
extraButton: PropTypes.node,
showBackButton: PropTypes.bool,
notifCleaning: PropTypes.bool, // true only for the notification column
notifCleaningActive: PropTypes.bool,
onEnterCleaningMode: PropTypes.func,
children: PropTypes.node,
pinned: PropTypes.bool,
placeholder: PropTypes.bool,
onPin: PropTypes.func,
onMove: PropTypes.func,
onClick: PropTypes.func,
intl: PropTypes.object.isRequired,
appendContent: PropTypes.node,
};

state = {
collapsed: true,
animating: false,
animatingNCD: false,
};

historyBack = (skip) => {
@@ -89,10 +80,6 @@ class ColumnHeader extends React.PureComponent {
this.setState({ animating: false });
}

handleTransitionEndNCD = () => {
this.setState({ animatingNCD: false });
}

handlePin = () => {
if (!this.props.pinned) {
this.historyBack();
@@ -100,16 +87,9 @@ class ColumnHeader extends React.PureComponent {
this.props.onPin();
}

onEnterCleaningMode = () => {
this.setState({ animatingNCD: true });
this.props.onEnterCleaningMode(!this.props.notifCleaningActive);
}

render () {
const { intl, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, notifCleaning, notifCleaningActive, placeholder } = this.props;
const { collapsed, animating, animatingNCD } = this.state;

let title = this.props.title;
const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent } = this.props;
const { collapsed, animating } = this.state;

const wrapperClassName = classNames('column-header__wrapper', {
'active': active,
@@ -128,20 +108,8 @@ class ColumnHeader extends React.PureComponent {
'active': !collapsed,
});

const notifCleaningButtonClassName = classNames('column-header__button', {
'active': notifCleaningActive,
});

const notifCleaningDrawerClassName = classNames('ncd column-header__collapsible', {
'collapsed': !notifCleaningActive,
'animating': animatingNCD,
});

let extraContent, pinButton, moveButtons, backButton, collapseButton;

//*glitch
const msgEnterNotifCleaning = intl.formatMessage(messages.enterNotifCleaning);

if (children) {
extraContent = (
<div key='extra-content' className='column-header__collapsible__extra'>
@@ -202,33 +170,17 @@ class ColumnHeader extends React.PureComponent {
<div className='column-header__buttons'>
{hasTitle && backButton}
{extraButton}
{ notifCleaning ? (
<button
aria-label={msgEnterNotifCleaning}
title={msgEnterNotifCleaning}
onClick={this.onEnterCleaningMode}
className={notifCleaningButtonClassName}
>
<Icon id='eraser' />
</button>
) : null}
{collapseButton}
</div>
</h1>

{ notifCleaning ? (
<div className={notifCleaningDrawerClassName} onTransitionEnd={this.handleTransitionEndNCD}>
<div className='column-header__collapsible-inner nopad-drawer'>
{(notifCleaningActive || animatingNCD) ? (<NotificationPurgeButtonsContainer />) : null }
</div>
</div>
) : null}

<div className={collapsibleClassName} tabIndex={collapsed ? -1 : null} onTransitionEnd={this.handleTransitionEnd}>
<div className='column-header__collapsible-inner'>
{(!collapsed || animating) && collapsedContent}
</div>
</div>

{appendContent}
</div>
);


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

@@ -184,7 +184,7 @@ export default class Dropdown extends React.PureComponent {
icon: PropTypes.string.isRequired,
items: PropTypes.array.isRequired,
size: PropTypes.number.isRequired,
ariaLabel: PropTypes.string,
title: PropTypes.string,
disabled: PropTypes.bool,
status: ImmutablePropTypes.map,
isUserTouching: PropTypes.func,
@@ -197,7 +197,7 @@ export default class Dropdown extends React.PureComponent {
};

static defaultProps = {
ariaLabel: 'Menu',
title: 'Menu',
};

state = {
@@ -277,14 +277,14 @@ export default class Dropdown extends React.PureComponent {
}

render () {
const { icon, items, size, ariaLabel, disabled, dropdownPlacement, openDropdownId, openedViaKeyboard } = this.props;
const { icon, items, size, title, disabled, dropdownPlacement, openDropdownId, openedViaKeyboard } = this.props;
const open = this.state.id === openDropdownId;

return (
<div>
<IconButton
icon={icon}
title={ariaLabel}
title={title}
active={open}
disabled={disabled}
size={size}

+ 11
- 5
app/javascript/flavours/glitch/components/relative_timestamp.js View File

@@ -3,6 +3,7 @@ import { injectIntl, defineMessages } from 'react-intl';
import PropTypes from 'prop-types';

const messages = defineMessages({
today: { id: 'relative_time.today', defaultMessage: 'today' },
just_now: { id: 'relative_time.just_now', defaultMessage: 'now' },
seconds: { id: 'relative_time.seconds', defaultMessage: '{number}s' },
minutes: { id: 'relative_time.minutes', defaultMessage: '{number}m' },
@@ -65,12 +66,14 @@ const getUnitDelay = units => {
}
};

export const timeAgoString = (intl, date, now, year) => {
export const timeAgoString = (intl, date, now, year, timeGiven = true) => {
const delta = now - date.getTime();

let relativeTime;

if (delta < 10 * SECOND) {
if (delta < DAY && !timeGiven) {
relativeTime = intl.formatMessage(messages.today);
} else if (delta < 10 * SECOND) {
relativeTime = intl.formatMessage(messages.just_now);
} else if (delta < 7 * DAY) {
if (delta < MINUTE) {
@@ -91,12 +94,14 @@ export const timeAgoString = (intl, date, now, year) => {
return relativeTime;
};

const timeRemainingString = (intl, date, now) => {
const timeRemainingString = (intl, date, now, timeGiven = true) => {
const delta = date.getTime() - now;

let relativeTime;

if (delta < 10 * SECOND) {
if (delta < DAY && !timeGiven) {
relativeTime = intl.formatMessage(messages.today);
} else if (delta < 10 * SECOND) {
relativeTime = intl.formatMessage(messages.moments_remaining);
} else if (delta < MINUTE) {
relativeTime = intl.formatMessage(messages.seconds_remaining, { number: Math.floor(delta / SECOND) });
@@ -173,8 +178,9 @@ class RelativeTimestamp extends React.Component {
render () {
const { timestamp, intl, year, futureDate } = this.props;

const timeGiven = timestamp.includes('T');
const date = new Date(timestamp);
const relativeTime = futureDate ? timeRemainingString(intl, date, this.state.now) : timeAgoString(intl, date, this.state.now, year);
const relativeTime = futureDate ? timeRemainingString(intl, date, this.state.now, timeGiven) : timeAgoString(intl, date, this.state.now, year, timeGiven);

return (
<time dateTime={timestamp} title={intl.formatDate(date, dateFormatOptions)}>

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

@@ -42,6 +42,14 @@ const isLinkMisleading = (link) => {
const linkText = linkTextParts.join('');
const targetURL = new URL(link.href);

if (targetURL.protocol === 'magnet:') {
return !linkText.startsWith('magnet:');
}

if (targetURL.protocol === 'xmpp:') {
return !(linkText === targetURL.href || 'xmpp:' + linkText === targetURL.href);
}

// The following may not work with international domain names
if (textMatchesTarget(linkText, targetURL.origin, targetURL.host) || textMatchesTarget(linkText.toLowerCase(), targetURL.origin, targetURL.host)) {
return false;
@@ -120,9 +128,19 @@ export default class StatusContent extends React.PureComponent {
if (tagLinks && isLinkMisleading(link)) {
// Add a tag besides the link to display its origin

const url = new URL(link.href);
const tag = document.createElement('span');
tag.classList.add('link-origin-tag');
tag.textContent = `[${new URL(link.href).host}]`;
switch (url.protocol) {
case 'xmpp:':
tag.textContent = `[${url.href}]`;
break;
case 'magnet:':
tag.textContent = '(magnet)';
break;
default:
tag.textContent = `[${url.host}]`;
}
link.insertAdjacentText('beforeend', ' ');
link.insertAdjacentElement('beforeend', tag);
}

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

@@ -112,6 +112,7 @@ class AccountTimeline extends ImmutablePureComponent {
onLoadMore={this.handleLoadMore}
emptyMessage={<FormattedMessage id='empty_column.account_timeline' defaultMessage='No toots here!' />}
bindToDocument={!multiColumn}
timelineId='account'
/>
</Column>
);

+ 1
- 0
app/javascript/flavours/glitch/features/directory/components/account_card.js View File

@@ -22,6 +22,7 @@ const messages = defineMessages({
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
});

const makeMapStateToProps = () => {

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

@@ -372,6 +372,7 @@ class EmojiPickerDropdown extends React.PureComponent {
onPickEmoji: PropTypes.func.isRequired,
onSkinTone: PropTypes.func.isRequired,
skinTone: PropTypes.number.isRequired,
button: PropTypes.node,
};

state = {
@@ -432,14 +433,14 @@ class EmojiPickerDropdown extends React.PureComponent {
}

render () {
const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props;
const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis, button } = this.props;
const title = intl.formatMessage(messages.emoji);
const { active, loading, placement } = this.state;

return (
<div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}>
<div ref={this.setTargetRef} className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onClick={this.onToggle} onKeyDown={this.onToggle} tabIndex={0}>
<img
{button || <img
className={classNames('emojione', { 'pulse-loading': active && loading })}
alt='🦊'
src={`${assetHost}/emoji/fuwux_flower.svg`}

+ 436
- 0
app/javascript/flavours/glitch/features/getting_started/components/announcements.js View File

@@ -0,0 +1,436 @@
import React from 'react';
import ImmutablePureComponent from 'react-immutable-pure-component';
import ReactSwipeableViews from 'react-swipeable-views';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import IconButton from 'flavours/glitch/components/icon_button';
import Icon from 'flavours/glitch/components/icon';
import { defineMessages, injectIntl, FormattedMessage, FormattedDate } from 'react-intl';
import { autoPlayGif, reduceMotion } from 'flavours/glitch/util/initial_state';
import elephantUIPlane from 'mastodon/../images/elephant_ui_plane.svg';
import { mascot } from 'flavours/glitch/util/initial_state';
import unicodeMapping from 'flavours/glitch/util/emoji/emoji_unicode_mapping_light';
import classNames from 'classnames';
import EmojiPickerDropdown from 'flavours/glitch/features/emoji_picker';
import AnimatedNumber from 'flavours/glitch/components/animated_number';
import TransitionMotion from 'react-motion/lib/TransitionMotion';
import spring from 'react-motion/lib/spring';

const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
next: { id: 'lightbox.next', defaultMessage: 'Next' },
});

class Content extends ImmutablePureComponent {

static contextTypes = {
router: PropTypes.object,
};

static propTypes = {
announcement: ImmutablePropTypes.map.isRequired,
};

setRef = c => {
this.node = c;
}

componentDidMount () {
this._updateLinks();
this._updateEmojis();
}

componentDidUpdate () {
this._updateLinks();
this._updateEmojis();
}

_updateEmojis () {
const node = this.node;

if (!node || autoPlayGif) {
return;
}

const emojis = node.querySelectorAll('.custom-emoji');

for (var i = 0; i < emojis.length; i++) {
let emoji = emojis[i];

if (emoji.classList.contains('status-emoji')) {
continue;
}

emoji.classList.add('status-emoji');

emoji.addEventListener('mouseenter', this.handleEmojiMouseEnter, false);
emoji.addEventListener('mouseleave', this.handleEmojiMouseLeave, false);
}
}

_updateLinks () {
const node = this.node;

if (!node) {
return;
}

const links = node.querySelectorAll('a');

for (var i = 0; i < links.length; ++i) {
let link = links[i];

if (link.classList.contains('status-link')) {
continue;
}

link.classList.add('status-link');

let mention = this.props.announcement.get('mentions').find(item => link.href === item.get('url'));

if (mention) {
link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
link.setAttribute('title', mention.get('acct'));
} else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
} else {
link.setAttribute('title', link.href);
link.classList.add('unhandled-link');
}

link.setAttribute('target', '_blank');
link.setAttribute('rel', 'noopener noreferrer');
}
}

onMentionClick = (mention, e) => {
if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.context.router.history.push(`/accounts/${mention.get('id')}`);
}
}

onHashtagClick = (hashtag, e) => {
hashtag = hashtag.replace(/^#/, '');

if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.context.router.history.push(`/timelines/tag/${hashtag}`);
}
}

handleEmojiMouseEnter = ({ target }) => {
target.src = target.getAttribute('data-original');
}

handleEmojiMouseLeave = ({ target }) => {
target.src = target.getAttribute('data-static');
}

render () {
const { announcement } = this.props;

return (
<div
className='announcements__item__content'
ref={this.setRef}
dangerouslySetInnerHTML={{ __html: announcement.get('contentHtml') }}
/>
);
}

}

const assetHost = process.env.CDN_HOST || '';

class Emoji extends React.PureComponent {

static propTypes = {
emoji: PropTypes.string.isRequired,
emojiMap: ImmutablePropTypes.map.isRequired,
hovered: PropTypes.bool.isRequired,
};

render () {
const { emoji, emojiMap, hovered } = this.props;

if (unicodeMapping[emoji]) {
const { filename, shortCode } = unicodeMapping[this.props.emoji];
const title = shortCode ? `:${shortCode}:` : '';

return (
<img
draggable='false'
className='emojione'
alt={emoji}
title={title}
src={`${assetHost}/emoji/${filename}.svg`}
/>
);
} else if (emojiMap.get(emoji)) {
const filename = (autoPlayGif || hovered) ? emojiMap.getIn([emoji, 'url']) : emojiMap.getIn([emoji, 'static_url']);
const shortCode = `:${emoji}:`;

return (
<img
draggable='false'
className='emojione custom-emoji'
alt={shortCode}
title={shortCode}
src={filename}
/>
);
} else {
return null;
}
}

}

class Reaction extends ImmutablePureComponent {

static propTypes = {
announcementId: PropTypes.string.isRequired,
reaction: ImmutablePropTypes.map.isRequired,
addReaction: PropTypes.func.isRequired,
removeReaction: PropTypes.func.isRequired,
emojiMap: ImmutablePropTypes.map.isRequired,
style: PropTypes.object,
};

state = {
hovered: false,
};

handleClick = () => {
const { reaction, announcementId, addReaction, removeReaction } = this.props;

if (reaction.get('me')) {
removeReaction(announcementId, reaction.get('name'));
} else {
addReaction(announcementId, reaction.get('name'));
}
}

handleMouseEnter = () => this.setState({ hovered: true })

handleMouseLeave = () => this.setState({ hovered: false })

render () {
const { reaction } = this.props;

let shortCode = reaction.get('name');

if (unicodeMapping[shortCode]) {
shortCode = unicodeMapping[shortCode].shortCode;
}

return (
<button className={classNames('reactions-bar__item', { active: reaction.get('me') })} onClick={this.handleClick} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} title={`:${shortCode}:`} style={this.props.style}>
<span className='reactions-bar__item__emoji'><Emoji hovered={this.state.hovered} emoji={reaction.get('name')} emojiMap={this.props.emojiMap} /></span>
<span className='reactions-bar__item__count'><AnimatedNumber value={reaction.get('count')} /></span>
</button>
);
}

}

class ReactionsBar extends ImmutablePureComponent {

static propTypes = {
announcementId: PropTypes.string.isRequired,
reactions: ImmutablePropTypes.list.isRequired,
addReaction: PropTypes.func.isRequired,
removeReaction: PropTypes.func.isRequired,
emojiMap: ImmutablePropTypes.map.isRequired,
};

handleEmojiPick = data => {
const { addReaction, announcementId } = this.props;
addReaction(announcementId, data.native.replace(/:/g, ''));
}

willEnter () {
return { scale: reduceMotion ? 1 : 0 };
}

willLeave () {
return { scale: reduceMotion ? 0 : spring(0, { stiffness: 170, damping: 26 }) };
}

render () {
const { reactions } = this.props;
const visibleReactions = reactions.filter(x => x.get('count') > 0);

const styles = visibleReactions.map(reaction => ({
key: reaction.get('name'),
data: reaction,
style: { scale: reduceMotion ? 1 : spring(1, { stiffness: 150, damping: 13 }) },
})).toArray();

return (
<TransitionMotion styles={styles} willEnter={this.willEnter} willLeave={this.willLeave}>
{items => (
<div className={classNames('reactions-bar', { 'reactions-bar--empty': visibleReactions.isEmpty() })}>
{items.map(({ key, data, style }) => (
<Reaction
key={key}
reaction={data}
style={{ transform: `scale(${style.scale})`, position: style.scale < 0.5 ? 'absolute' : 'static' }}
announcementId={this.props.announcementId}
addReaction={this.props.addReaction}
removeReaction={this.props.removeReaction}
emojiMap={this.props.emojiMap}
/>
))}

{visibleReactions.size < 8 && <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} button={<Icon id='plus' />} />}
</div>
)}
</TransitionMotion>
);
}

}

class Announcement extends ImmutablePureComponent {

static propTypes = {
announcement: ImmutablePropTypes.map.isRequired,
emojiMap: ImmutablePropTypes.map.isRequired,
addReaction: PropTypes.func.isRequired,
removeReaction: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
selected: PropTypes.bool,
};

state = {
unread: !this.props.announcement.get('read'),
};

componentDidUpdate () {
const { selected, announcement } = this.props;
if (!selected && this.state.unread !== !announcement.get('read')) {
this.setState({ unread: !announcement.get('read') });
}
}

render () {
const { announcement } = this.props;
const { unread } = this.state;
const startsAt = announcement.get('starts_at') && new Date(announcement.get('starts_at'));
const endsAt = announcement.get('ends_at') && new Date(announcement.get('ends_at'));
const now = new Date();
const hasTimeRange = startsAt && endsAt;
const skipYear = hasTimeRange && startsAt.getFullYear() === endsAt.getFullYear() && endsAt.getFullYear() === now.getFullYear();
const skipEndDate = hasTimeRange && startsAt.getDate() === endsAt.getDate() && startsAt.getMonth() === endsAt.getMonth() && startsAt.getFullYear() === endsAt.getFullYear();
const skipTime = announcement.get('all_day');

return (
<div className='announcements__item'>
<strong className='announcements__item__range'>
<FormattedMessage id='announcement.announcement' defaultMessage='Announcement' />
{hasTimeRange && <span> · <FormattedDate value={startsAt} hour12={false} year={(skipYear || startsAt.getFullYear() === now.getFullYear()) ? undefined : 'numeric'} month='short' day='2-digit' hour={skipTime ? undefined : '2-digit'} minute={skipTime ? undefined : '2-digit'} /> - <FormattedDate value={endsAt} hour12={false} year={(skipYear || endsAt.getFullYear() === now.getFullYear()) ? undefined : 'numeric'} month={skipEndDate ? undefined : 'short'} day={skipEndDate ? undefined : '2-digit'} hour={skipTime ? undefined : '2-digit'} minute={skipTime ? undefined : '2-digit'} /></span>}
</strong>

<Content announcement={announcement} />

<ReactionsBar
reactions={announcement.get('reactions')}
announcementId={announcement.get('id')}
addReaction={this.props.addReaction}
removeReaction={this.props.removeReaction}
emojiMap={this.props.emojiMap}
/>

{unread && <span className='announcements__item__unread' />}
</div>
);
}

}

export default @injectIntl
class Announcements extends ImmutablePureComponent {

static propTypes = {
announcements: ImmutablePropTypes.list,
emojiMap: ImmutablePropTypes.map.isRequired,
dismissAnnouncement: PropTypes.func.isRequired,
addReaction: PropTypes.func.isRequired,
removeReaction: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};

state = {
index: 0,
};

componentDidMount () {
this._markAnnouncementAsRead();
}

componentDidUpdate () {
this._markAnnouncementAsRead();
}

_markAnnouncementAsRead () {
const { dismissAnnouncement, announcements } = this.props;
const { index } = this.state;
const announcement = announcements.get(index);
if (!announcement.get('read')) dismissAnnouncement(announcement.get('id'));
}

handleChangeIndex = index => {
this.setState({ index: index % this.props.announcements.size });
}

handleNextClick = () => {
this.setState({ index: (this.state.index + 1) % this.props.announcements.size });
}

handlePrevClick = () => {
this.setState({ index: (this.props.announcements.size + this.state.index - 1) % this.props.announcements.size });
}

render () {
const { announcements, intl } = this.props;
const { index } = this.state;

if (announcements.isEmpty()) {
return null;
}

return (
<div className='announcements'>
<img className='announcements__mastodon' alt='' draggable='false' src={mascot || elephantUIPlane} />

<div className='announcements__container'>
<ReactSwipeableViews animateHeight={!reduceMotion} adjustHeight={reduceMotion} index={index} onChangeIndex={this.handleChangeIndex}>
{announcements.map((announcement, idx) => (
<Announcement
key={announcement.get('id')}
announcement={announcement}
emojiMap={this.props.emojiMap}
addReaction={this.props.addReaction}
removeReaction={this.props.removeReaction}
intl={intl}
selected={index === idx}
/>
))}
</ReactSwipeableViews>

{announcements.size > 1 && (
<div className='announcements__pagination'>
<IconButton disabled={announcements.size === 1} title={intl.formatMessage(messages.previous)} icon='chevron-left' onClick={this.handlePrevClick} size={13} />
<span>{index + 1} / {announcements.size}</span>
<IconButton disabled={announcements.size === 1} title={intl.formatMessage(messages.next)} icon='chevron-right' onClick={this.handleNextClick} size={13} />
</div>
)}
</div>
</div>
);
}

}

+ 20
- 0
app/javascript/flavours/glitch/features/getting_started/containers/announcements_container.js View File

@@ -0,0 +1,20 @@
import { connect } from 'react-redux';
import { addReaction, removeReaction, dismissAnnouncement } from 'flavours/glitch/actions/announcements';
import Announcements from '../components/announcements';
import { createSelector } from 'reselect';
import { Map as ImmutableMap } from 'immutable';

const customEmojiMap = createSelector([state => state.get('custom_emojis')], items => items.reduce((map, emoji) => map.set(emoji.get('shortcode'), emoji), ImmutableMap()));

const mapStateToProps = state => ({
announcements: state.getIn(['announcements', 'items']),
emojiMap: customEmojiMap(state),
});

const mapDispatchToProps = dispatch => ({
dismissAnnouncement: id => dispatch(dismissAnnouncement(id)),
addReaction: (id, name) => dispatch(addReaction(id, name)),
removeReaction: (id, name) => dispatch(removeReaction(id, name)),
});

export default connect(mapStateToProps, mapDispatchToProps)(Announcements);

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

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

const mapStateToProps = state => ({

+ 37
- 1
app/javascript/flavours/glitch/features/home_timeline/index.js View File

@@ -9,14 +9,23 @@ import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/col
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ColumnSettingsContainer from './containers/column_settings_container';
import { Link } from 'react-router-dom';
import { fetchAnnouncements, toggleShowAnnouncements } from 'flavours/glitch/actions/announcements';
import AnnouncementsContainer from 'flavours/glitch/features/getting_started/containers/announcements_container';
import classNames from 'classnames';
import IconWithBadge from 'flavours/glitch/components/icon_with_badge';

const messages = defineMessages({
title: { id: 'column.home', defaultMessage: 'Home' },
show_announcements: { id: 'home.show_announcements', defaultMessage: 'Show announcements' },
hide_announcements: { id: 'home.hide_announcements', defaultMessage: 'Hide announcements' },
});

const mapStateToProps = state => ({
hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0,
isPartial: state.getIn(['timelines', 'home', 'isPartial']),
hasAnnouncements: !state.getIn(['announcements', 'items']).isEmpty(),
unreadAnnouncements: state.getIn(['announcements', 'items']).count(item => !item.get('read')),
showAnnouncements: state.getIn(['announcements', 'show']),
});

export default @connect(mapStateToProps)
@@ -30,6 +39,9 @@ class HomeTimeline extends React.PureComponent {
isPartial: PropTypes.bool,
columnId: PropTypes.string,
multiColumn: PropTypes.bool,
hasAnnouncements: PropTypes.bool,
unreadAnnouncements: PropTypes.number,
showAnnouncements: PropTypes.bool,
};

handlePin = () => {
@@ -60,6 +72,7 @@ class HomeTimeline extends React.PureComponent {
}

componentDidMount () {
this.props.dispatch(fetchAnnouncements());
this._checkIfReloadNeeded(false, this.props.isPartial);
}

@@ -92,10 +105,31 @@ class HomeTimeline extends React.PureComponent {
}
}

handleToggleAnnouncementsClick = (e) => {
e.stopPropagation();
this.props.dispatch(toggleShowAnnouncements());
}

render () {
const { intl, hasUnread, columnId, multiColumn } = this.props;
const { intl, hasUnread, columnId, multiColumn, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props;
const pinned = !!columnId;

let announcementsButton = null;

if (hasAnnouncements) {
announcementsButton = (
<button
className={classNames('column-header__button', { 'active': showAnnouncements })}
title={intl.formatMessage(showAnnouncements ? messages.hide_announcements : messages.show_announcements)}
aria-label={intl.formatMessage(showAnnouncements ? messages.hide_announcements : messages.show_announcements)}
aria-pressed={showAnnouncements ? 'true' : 'false'}
onClick={this.handleToggleAnnouncementsClick}
>
<IconWithBadge id='bullhorn' count={unreadAnnouncements} />
</button>
);
}

return (
<Column bindToDocument={!multiColumn} ref={this.setRef} name='home' label={intl.formatMessage(messages.title)}>
<ColumnHeader
@@ -107,6 +141,8 @@ class HomeTimeline extends React.PureComponent {
onClick={this.handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
extraButton={announcementsButton}
appendContent={hasAnnouncements && showAnnouncements && <AnnouncementsContainer />}
>
<ColumnSettingsContainer />
</ColumnHeader>

+ 52
- 3
app/javascript/flavours/glitch/features/notifications/index.js View File

@@ -1,5 +1,6 @@
import React from 'react';
import { connect } from 'react-redux';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Column from 'flavours/glitch/components/column';
@@ -22,9 +23,13 @@ import { List as ImmutableList } from 'immutable';
import { debounce } from 'lodash';
import ScrollableList from 'flavours/glitch/components/scrollable_list';
import LoadGap from 'flavours/glitch/components/load_gap';
import Icon from 'flavours/glitch/components/icon';

import NotificationPurgeButtonsContainer from 'flavours/glitch/containers/notification_purge_buttons_container';

const messages = defineMessages({
title: { id: 'column.notifications', defaultMessage: 'Notifications' },
enterNotifCleaning : { id: 'notification_purge.start', defaultMessage: 'Enter notification cleaning mode' },
});

const getNotifications = createSelector([
@@ -94,6 +99,10 @@ class Notifications extends React.PureComponent {
trackScroll: true,
};

state = {
animatingNCD: false,