diff --git a/.circleci/config.yml b/.circleci/config.yml index ff8eb4859..3ba027d95 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -3,7 +3,7 @@ version: 2 aliases: - &defaults docker: - - image: circleci/ruby:2.6-stretch-node + - image: circleci/ruby:2.7-buster-node environment: &ruby_environment BUNDLE_APP_CONFIG: ./.bundle/ DB_HOST: localhost @@ -39,7 +39,6 @@ aliases: steps: - checkout - *attach_workspace - - restore_cache: keys: - v1-node-dependencies-{{ checksum "yarn.lock" }} @@ -49,7 +48,6 @@ aliases: key: v1-node-dependencies-{{ checksum "yarn.lock" }} paths: - ./node_modules/ - - *persist_to_workspace - &install_system_dependencies @@ -58,16 +56,25 @@ aliases: command: | sudo apt-get update sudo apt-get install -y libicu-dev libidn11-dev libprotobuf-dev protobuf-compiler + + ## TODO: FIX THESE BUSTER DEPENDANCES + sudo wget http://ftp.au.debian.org/debian/pool/main/i/icu/libicu57_57.1-6+deb9u3_amd64.deb + sudo dpkg -i libicu57_57.1-6+deb9u3_amd64.deb + sudo wget http://ftp.au.debian.org/debian/pool/main/p/protobuf/libprotobuf10_3.0.0-9_amd64.deb + sudo dpkg -i libprotobuf10_3.0.0-9_amd64.deb - &install_ruby_dependencies steps: - *attach_workspace - - *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: @@ -82,10 +89,8 @@ aliases: - &test_steps steps: - *attach_workspace - - *install_system_dependencies - run: sudo apt-get install -y ffmpeg - - run: name: Prepare Tests command: ./bin/rails parallel:create parallel:load_schema parallel:prepare @@ -98,21 +103,21 @@ jobs: <<: *defaults <<: *install_steps + install-ruby2.7: + <<: *defaults + <<: *install_ruby_dependencies + install-ruby2.6: <<: *defaults + docker: + - image: circleci/ruby:2.6-buster-node + environment: *ruby_environment <<: *install_ruby_dependencies install-ruby2.5: <<: *defaults docker: - - image: circleci/ruby:2.5-stretch-node - environment: *ruby_environment - <<: *install_ruby_dependencies - - install-ruby2.4: - <<: *defaults - docker: - - image: circleci/ruby:2.4-stretch-node + - image: circleci/ruby:2.5-buster-node environment: *ruby_environment <<: *install_ruby_dependencies @@ -128,10 +133,40 @@ jobs: - ./mastodon/public/assets - ./mastodon/public/packs-test/ + test-migrations: + <<: *defaults + docker: + - image: circleci/ruby:2.7-buster-node + environment: *ruby_environment + - image: circleci/postgres:10.6-alpine + environment: + POSTGRES_USER: root + - image: circleci/redis:5-alpine + steps: + - *attach_workspace + - *install_system_dependencies + - run: + name: Create database + command: ./bin/rails parallel:create + - run: + name: Run migrations + command: ./bin/rails parallel:migrate + + test-ruby2.7: + <<: *defaults + docker: + - image: circleci/ruby:2.7-buster-node + environment: *ruby_environment + - image: circleci/postgres:10.6-alpine + environment: + POSTGRES_USER: root + - image: circleci/redis:5-alpine + <<: *test_steps + test-ruby2.6: <<: *defaults docker: - - image: circleci/ruby:2.6-stretch-node + - image: circleci/ruby:2.6-buster-node environment: *ruby_environment - image: circleci/postgres:10.6-alpine environment: @@ -142,18 +177,7 @@ jobs: test-ruby2.5: <<: *defaults docker: - - image: circleci/ruby:2.5-stretch-node - environment: *ruby_environment - - image: circleci/postgres:10.6-alpine - environment: - POSTGRES_USER: root - - image: circleci/redis:5-alpine - <<: *test_steps - - test-ruby2.4: - <<: *defaults - docker: - - image: circleci/ruby:2.4-stretch-node + - image: circleci/ruby:2.5-buster-node environment: *ruby_environment - image: circleci/postgres:10.6-alpine environment: @@ -164,7 +188,7 @@ jobs: test-webui: <<: *defaults docker: - - image: circleci/node:12.9-stretch + - image: circleci/node:12-buster steps: - *attach_workspace - run: ./bin/retry yarn test:jest @@ -184,20 +208,27 @@ workflows: build-and-test: jobs: - install + - install-ruby2.7: + requires: + - install - install-ruby2.6: requires: - install + - install-ruby2.7 - install-ruby2.5: requires: - install - - install-ruby2.6 - - install-ruby2.4: - requires: - - install - - install-ruby2.6 + - install-ruby2.7 - build: requires: - - install-ruby2.6 + - install-ruby2.7 + - test-migrations: + requires: + - install-ruby2.7 + - test-ruby2.7: + requires: + - install-ruby2.7 + - build - test-ruby2.6: requires: - install-ruby2.6 @@ -206,13 +237,9 @@ workflows: requires: - install-ruby2.5 - build - - test-ruby2.4: - requires: - - install-ruby2.4 - - build - test-webui: requires: - install - check-i18n: requires: - - install-ruby2.6 + - install-ruby2.7 diff --git a/.codeclimate.yml b/.codeclimate.yml index 571507a54..9817d7f1c 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -27,10 +27,10 @@ plugins: enabled: true eslint: enabled: true - channel: eslint-5 + channel: eslint-6 rubocop: enabled: true - channel: rubocop-0-71 + channel: rubocop-0-76 sass-lint: enabled: true exclude_patterns: diff --git a/.env.nanobox b/.env.nanobox index cfbe487fb..5951777a2 100644 --- a/.env.nanobox +++ b/.env.nanobox @@ -183,6 +183,11 @@ SMTP_FROM_ADDRESS=notifications@${APP_NAME}.nanoapp.io # LDAP_BIND_DN= # LDAP_PASSWORD= # LDAP_UID=cn +# LDAP_MAIL=mail +# LDAP_SEARCH_FILTER=(|(%{uid}=%{email})(%{mail}=%{email})) +# LDAP_UID_CONVERSION_ENABLED=true +# LDAP_UID_CONVERSION_SEARCH=., - +# LDAP_UID_CONVERSION_REPLACE=_ # PAM authentication (optional) # PAM authentication uses for the email generation the "email" pam variable @@ -226,8 +231,8 @@ SMTP_FROM_ADDRESS=notifications@${APP_NAME}.nanoapp.io # Optional SAML authentication (cf. omniauth-saml) # SAML_ENABLED=true -# SAML_ACS_URL= -# SAML_ISSUER=http://localhost:3000/auth/auth/saml/callback +# SAML_ACS_URL=http://localhost:3000/auth/auth/saml/callback +# SAML_ISSUER=https://example.com # SAML_IDP_SSO_TARGET_URL=https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO # SAML_IDP_CERT= # SAML_IDP_CERT_FINGERPRINT= diff --git a/.env.production.sample b/.env.production.sample index f9a8bb7c1..8a6888621 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -178,7 +178,11 @@ STREAMING_CLUSTER_NUM=1 # LDAP_BIND_DN= # LDAP_PASSWORD= # LDAP_UID=cn -# LDAP_SEARCH_FILTER=%{uid}=%{email} +# LDAP_MAIL=mail +# LDAP_SEARCH_FILTER=(|(%{uid}=%{email})(%{mail}=%{email})) +# LDAP_UID_CONVERSION_ENABLED=true +# LDAP_UID_CONVERSION_SEARCH=., - +# LDAP_UID_CONVERSION_REPLACE=_ # PAM authentication (optional) # PAM authentication uses for the email generation the "email" pam variable @@ -222,8 +226,8 @@ STREAMING_CLUSTER_NUM=1 # Optional SAML authentication (cf. omniauth-saml) # SAML_ENABLED=true -# SAML_ACS_URL= -# SAML_ISSUER=http://localhost:3000/auth/auth/saml/callback +# SAML_ACS_URL=http://localhost:3000/auth/auth/saml/callback +# SAML_ISSUER=https://example.com # SAML_IDP_SSO_TARGET_URL=https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO # SAML_IDP_CERT= # SAML_IDP_CERT_FINGERPRINT= @@ -246,3 +250,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 diff --git a/.env.test b/.env.test index fa4e1d91f..761d0d921 100644 --- a/.env.test +++ b/.env.test @@ -1,5 +1,5 @@ # Node.js -NODE_ENV=test +NODE_ENV=tests # Federation LOCAL_DOMAIN=cb6e6126.ngrok.io LOCAL_HTTPS=true diff --git a/.env.vagrant b/.env.vagrant index f3b54f6e3..c2d26fa45 100644 --- a/.env.vagrant +++ b/.env.vagrant @@ -1,2 +1,3 @@ VAGRANT=true LOCAL_DOMAIN=mastodon.local +BIND=0.0.0.0 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..768868516 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Mastodon Meta Discussion Board + url: https://discourse.joinmastodon.org/ + about: Please ask and answer questions here. diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 000000000..6601ef8c0 --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,10 @@ +daysUntilStale: 120 +daysUntilClose: 7 +exemptLabels: + - security +staleLabel: wontfix +markComment: > + This issue has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. Thank you + for your contributions. +only: pulls diff --git a/.gitignore b/.gitignore index 51e47bb52..a4057eb6e 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ /db/*.sqlite3-journal # Ignore all logfiles and tempfiles. +.eslintcache /log/* !/log/.keep /tmp @@ -23,6 +24,7 @@ public/packs public/packs-test .env .env.production +.env.development node_modules/ build/ @@ -55,6 +57,8 @@ npm-debug.log yarn-error.log yarn-debug.log +# Ignore vagrant log files +ubuntu-xenial-16.04-cloudimg-console.log + # Ignore Docker option files docker-compose.override.yml - diff --git a/.nvmrc b/.nvmrc index 45a4fb75d..48082f72f 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -8 +12 diff --git a/.rubocop.yml b/.rubocop.yml index 8bd4c867f..9a68becbb 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -71,6 +71,9 @@ Naming/MemoizedInstanceVariableName: Rails: Enabled: true +Rails/EnumHash: + Enabled: false + Rails/HasAndBelongsToMany: Enabled: false @@ -102,6 +105,9 @@ Style/Documentation: Style/DoubleNegation: Enabled: true +Style/FormatStringToken: + Enabled: false + Style/FrozenStringLiteralComment: Enabled: true diff --git a/CHANGELOG.md b/CHANGELOG.md index b200747b1..2ae9fc82c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,175 @@ Changelog All notable changes to this project will be documented in this file. +## [3.1.1] - 2020-02-10 +### Fixed + +- Fix yanked dependency preventing installation ([mayaeh](https://github.com/tootsuite/mastodon/pull/13059)) + +## [3.1.0] - 2020-02-09 +### 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), [Gargron](https://github.com/tootsuite/mastodon/pull/12743)) +- Allow support for `Accept`/`Reject` activities with a non-embedded object ([puckipedia](https://github.com/tootsuite/mastodon/pull/12199)) +- Add "Show thread" button to public profiles ([Sasha-Sorokin](https://github.com/tootsuite/mastodon/pull/13000)) + +### 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)) +- Change signature verification to ignore signatures with invalid host ([Gargron](https://github.com/tootsuite/mastodon/pull/13033)) + +### 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 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), [Gargron](https://github.com/tootsuite/mastodon/pull/13035), [Gargron](https://github.com/tootsuite/mastodon/pull/13030)) +- 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 `` 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)) +- Fix rendering `` without `href` when scheme unsupported ([Gargron](https://github.com/tootsuite/mastodon/pull/13040)) +- Fix unfiltered params error when generating ActivityPub tag pagination ([Gargron](https://github.com/tootsuite/mastodon/pull/13049)) +- Fix malformed HTML causing uncaught error ([Gargron](https://github.com/tootsuite/mastodon/pull/13042)) +- Fix native share button not being displayed for unlisted toots ([ThibG](https://github.com/tootsuite/mastodon/pull/13045)) +- Fix remote convertible media attachments (e.g. GIFs) not being saved ([Gargron](https://github.com/tootsuite/mastodon/pull/13032)) +- Fix account query not using faster index ([abcang](https://github.com/tootsuite/mastodon/pull/13016)) +- Fix error when sending moderation notification ([renatolond](https://github.com/tootsuite/mastodon/pull/13014)) + +### 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 76f512198..f7b8f17cc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,13 +14,13 @@ If your contributions are accepted into Mastodon, you can request to be paid thr ## Bug reports -Bug reports and feature suggestions can be submitted to [GitHub Issues](https://github.com/tootsuite/mastodon/issues). Please make sure that you are not submitting duplicates, and that a similar report or request has not already been resolved or rejected in the past using the search function. Please also use descriptive, concise titles. +Bug reports and feature suggestions must use descriptive and concise titles and be submitted to [GitHub Issues](https://github.com/tootsuite/mastodon/issues). Please use the search function to make sure that you are not submitting duplicates, and that a similar report or request has not already been resolved or rejected. ## Translations You can submit translations via [Crowdin](https://crowdin.com/project/mastodon). They are periodically merged into the codebase. -[![Crowdin](https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg)][crowdin] +[![Crowdin](https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg)](https://crowdin.com/project/mastodon) ## Pull requests diff --git a/Dockerfile b/Dockerfile index e963674a5..16618a276 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,8 +3,8 @@ FROM ubuntu:18.04 as build-dep # Use bash for the shell SHELL ["bash", "-c"] -# Install Node -ENV NODE_VER="12.11.1" +# Install Node v12 (LTS) +ENV NODE_VER="12.14.0" RUN echo "Etc/UTC" > /etc/localtime && \ apt update && \ apt -y install wget python && \ @@ -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 @@ -123,3 +125,4 @@ RUN cd ~ && \ # Set the work dir and the container entry point WORKDIR /opt/mastodon ENTRYPOINT ["/tini", "--"] +EXPOSE 3000 4000 diff --git a/Gemfile b/Gemfile index 6f1fcb6f1..9329f3ab0 100644 --- a/Gemfile +++ b/Gemfile @@ -1,21 +1,26 @@ # frozen_string_literal: true source 'https://rubygems.org' -ruby '>= 2.4.0', '< 2.7.0' +ruby '>= 2.4.0', '< 3.0.0' -gem 'pkg-config', '~> 1.3' +gem 'pkg-config', '~> 1.4' -gem 'puma', '~> 4.2' -gem 'rails', '~> 5.2.3' +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' gem 'hamlit-rails', '~> 0.2' -gem 'pg', '~> 1.1' +gem 'pg', '~> 1.2' gem 'makara', '~> 0.4' -gem 'pghero', '~> 2.3' +gem 'pghero', '~> 2.4' gem 'dotenv-rails', '~> 2.7' -gem 'aws-sdk-s3', '~> 1.48', require: false +gem 'aws-sdk-s3', '~> 1.60', require: false gem 'fog-core', '<= 2.1.0' gem 'fog-openstack', '~> 0.3', require: false gem 'paperclip', '~> 6.0' @@ -27,10 +32,10 @@ gem 'active_model_serializers', '~> 0.10' gem 'addressable', '~> 2.7' gem 'bootsnap', '~> 1.4', require: false gem 'browser' -gem 'charlock_holmes', '~> 0.7.6' +gem 'charlock_holmes', '~> 0.7.7' gem 'iso-639' gem 'chewy', '~> 5.1' -gem 'cld3', '~> 3.2.4' +gem 'cld3', '~> 3.2.6' gem 'devise', '~> 4.7' gem 'devise-two-factor', '~> 3.1' @@ -38,7 +43,7 @@ group :pam_authentication, optional: true do gem 'devise_pam_authenticatable2', '~> 9.2' end -gem 'net-ldap', '~> 0.10' +gem 'net-ldap', '~> 0.16' gem 'omniauth-cas', '~> 1.1' gem 'omniauth-saml', '~> 1.10' gem 'omniauth', '~> 1.9' @@ -49,35 +54,34 @@ gem 'fast_blank', '~> 1.0' gem 'fastimage' gem 'goldfinger', '~> 2.1' gem 'hiredis', '~> 0.6' -gem 'redis-namespace', '~> 1.5' +gem 'redis-namespace', '~> 1.7' gem 'health_check', git: 'https://github.com/ianheggie/health_check', ref: '0b799ead604f900ed50685e9b2d469cd2befba5b' gem 'htmlentities', '~> 4.3' -gem 'http', '~> 3.3' +gem 'http', '~> 4.3' gem 'http_accept_language', '~> 2.1' gem 'http_parser.rb', '~> 0.6', git: 'https://github.com/tmm1/http_parser.rb', ref: '54b17ba8c7d8d20a16dfc65d1775241833219cf2', submodules: true -gem 'httplog', '~> 1.3' +gem 'httplog', '~> 1.4.2' gem 'idn-ruby', require: 'idn' gem 'kaminari', '~> 1.1' gem 'link_header', '~> 0.0' -gem 'mime-types', '~> 3.3', 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.10' gem 'nsa', '~> 0.2' -gem 'oj', '~> 3.9' -gem 'ostatus2', '~> 2.0' -gem 'ox', '~> 2.11' +gem 'oj', '~> 3.10' +gem 'ox', '~> 2.12' gem 'parslet' -gem 'parallel', '~> 1.17' +gem 'parallel', '~> 1.19' gem 'posix-spawn', git: 'https://github.com/rtomayko/posix-spawn', ref: '58465d2e213991f8afb13b984854a49fcdcc980c' gem 'pundit', '~> 2.1' gem 'premailer-rails' -gem 'rack-attack', '~> 6.1' -gem 'rack-cors', '~> 1.0', require: 'rack/cors' +gem 'rack-attack', '~> 6.2' +gem 'rack-cors', '~> 1.1', require: 'rack/cors' gem 'rails-i18n', '~> 5.1' gem 'rails-settings-cached', '~> 0.6' gem 'redis', '~> 4.1', require: ['redis', 'redis/connection/hiredis'] gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock' -gem 'rqrcode', '~> 0.10' +gem 'rqrcode', '~> 1.1' gem 'ruby-progressbar', '~> 1.10' gem 'sanitize', '~> 5.1' gem 'sidekiq', '~> 5.2' @@ -85,28 +89,28 @@ gem 'sidekiq-scheduler', '~> 3.0' gem 'sidekiq-unique-jobs', '~> 6.0' gem 'sidekiq-bulk', '~>0.2.0' gem 'simple-navigation', '~> 4.1' -gem 'simple_form', '~> 4.1' +gem 'simple_form', '~> 5.0' gem 'sprockets-rails', '~> 3.2', require: 'sprockets/railtie' -gem 'stoplight', '~> 2.1.3' -gem 'strong_migrations', '~> 0.4' +gem 'stoplight', '~> 2.2.0' +gem 'strong_migrations', '~> 0.5' gem 'tty-command', '~> 0.9', require: false -gem 'tty-prompt', '~> 0.19', require: false +gem 'tty-prompt', '~> 0.20', require: false gem 'twitter-text', '~> 1.14' gem 'tzinfo-data', '~> 1.2019' -gem 'webpacker', '~> 4.0' +gem 'webpacker', '~> 4.2' gem 'webpush' -gem 'json-ld', git: 'https://github.com/ruby-rdf/json-ld.git', ref: 'e742697a0906e74e8bb777ef98137bc3955d981d' +gem 'json-ld' gem 'json-ld-preloaded', '~> 3.0' -gem 'rdf-normalize', '~> 0.3' +gem 'rdf-normalize', '~> 0.4' group :development, :test do - gem 'fabrication', '~> 2.20' - gem 'fuubar', '~> 2.4' + gem 'fabrication', '~> 2.21' + gem 'fuubar', '~> 2.5' gem 'i18n-tasks', '~> 0.9', require: false gem 'pry-byebug', '~> 3.7' gem 'pry-rails', '~> 0.3' - gem 'rspec-rails', '~> 3.8' + gem 'rspec-rails', '~> 3.9' end group :production, :test do @@ -114,29 +118,29 @@ group :production, :test do end group :test do - gem 'capybara', '~> 3.29' + gem 'capybara', '~> 3.30' gem 'climate_control', '~> 0.2' - gem 'faker', '~> 2.5' - gem 'microformats', '~> 4.1' + gem 'faker', '~> 2.10' + gem 'microformats', '~> 4.2' gem 'rails-controller-testing', '~> 1.0' gem 'rspec-sidekiq', '~> 3.0' gem 'simplecov', '~> 0.17', require: false - gem 'webmock', '~> 3.7' - gem 'parallel_tests', '~> 2.29' + gem 'webmock', '~> 3.8' + gem 'parallel_tests', '~> 2.30' end group :development do - gem 'active_record_query_trace', '~> 1.6' - gem 'annotate', '~> 2.7' + gem 'active_record_query_trace', '~> 1.7' + gem 'annotate', '~> 3.0' gem 'better_errors', '~> 2.5' gem 'binding_of_caller', '~> 0.7' - gem 'bullet', '~> 6.0' + gem 'bullet', '~> 6.1' gem 'letter_opener', '~> 1.7' gem 'letter_opener_web', '~> 1.3' gem 'memory_profiler' - gem 'rubocop', '~> 0.74', require: false - gem 'rubocop-rails', '~> 2.3', require: false - gem 'brakeman', '~> 4.6', require: false + gem 'rubocop', '~> 0.79', require: false + gem 'rubocop-rails', '~> 2.4', require: false + gem 'brakeman', '~> 4.7', require: false gem 'bundler-audit', '~> 0.6', require: false gem 'capistrano', '~> 3.11' @@ -144,7 +148,6 @@ group :development do gem 'capistrano-rbenv', '~> 2.1' gem 'capistrano-yarn', '~> 2.0' - gem 'derailed_benchmarks' gem 'stackprof' end diff --git a/Gemfile.lock b/Gemfile.lock index 3c52f378f..c8a37dd4b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -13,19 +13,6 @@ GIT specs: posix-spawn (0.3.13) -GIT - remote: https://github.com/ruby-rdf/json-ld.git - revision: e742697a0906e74e8bb777ef98137bc3955d981d - ref: e742697a0906e74e8bb777ef98137bc3955d981d - specs: - json-ld (3.0.2) - htmlentities (~> 4.3) - json-canonicalization (~> 0.1) - link_header (~> 0.0, >= 0.0.8) - multi_json (~> 1.13) - rack (>= 1.6, < 3.0) - rdf (~> 3.0, >= 3.0.8) - GIT remote: https://github.com/tmm1/http_parser.rb revision: 54b17ba8c7d8d20a16dfc65d1775241833219cf2 @@ -44,25 +31,25 @@ GIT GEM remote: https://rubygems.org/ specs: - actioncable (5.2.3) - actionpack (= 5.2.3) + actioncable (5.2.4.1) + actionpack (= 5.2.4.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailer (5.2.3) - actionpack (= 5.2.3) - actionview (= 5.2.3) - activejob (= 5.2.3) + actionmailer (5.2.4.1) + actionpack (= 5.2.4.1) + actionview (= 5.2.4.1) + activejob (= 5.2.4.1) mail (~> 2.5, >= 2.5.4) rails-dom-testing (~> 2.0) - actionpack (5.2.3) - actionview (= 5.2.3) - activesupport (= 5.2.3) - rack (~> 2.0) + actionpack (5.2.4.1) + actionview (= 5.2.4.1) + activesupport (= 5.2.4.1) + rack (~> 2.0, >= 2.0.8) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.0.2) - actionview (5.2.3) - activesupport (= 5.2.3) + actionview (5.2.4.1) + activesupport (= 5.2.4.1) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) @@ -72,32 +59,32 @@ GEM activemodel (>= 4.1, < 6.1) case_transform (>= 0.2) jsonapi-renderer (>= 0.1.1.beta1, < 0.3) - active_record_query_trace (1.6.2) - activejob (5.2.3) - activesupport (= 5.2.3) + active_record_query_trace (1.7) + activejob (5.2.4.1) + activesupport (= 5.2.4.1) globalid (>= 0.3.6) - activemodel (5.2.3) - activesupport (= 5.2.3) - activerecord (5.2.3) - activemodel (= 5.2.3) - activesupport (= 5.2.3) + activemodel (5.2.4.1) + activesupport (= 5.2.4.1) + activerecord (5.2.4.1) + activemodel (= 5.2.4.1) + activesupport (= 5.2.4.1) arel (>= 9.0) - activestorage (5.2.3) - actionpack (= 5.2.3) - activerecord (= 5.2.3) + activestorage (5.2.4.1) + actionpack (= 5.2.4.1) + activerecord (= 5.2.4.1) marcel (~> 0.3.1) - activesupport (5.2.3) + activesupport (5.2.4.1) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 0.7, < 2) minitest (~> 5.1) tzinfo (~> 1.1) addressable (2.7.0) public_suffix (>= 2.0.2, < 5.0) - airbrussh (1.3.4) + airbrussh (1.4.0) sshkit (>= 1.6.1, != 1.7.0) - annotate (2.7.5) + annotate (3.0.3) activerecord (>= 3.2, < 7.0) - rake (>= 10.4, < 13.0) + rake (>= 10.4, < 14.0) arel (9.0.0) ast (2.4.0) attr_encrypted (3.1.0) @@ -105,37 +92,36 @@ GEM av (0.9.0) cocaine (~> 0.5.3) aws-eventstream (1.0.3) - aws-partitions (1.207.0) - aws-sdk-core (3.65.1) + aws-partitions (1.261.0) + aws-sdk-core (3.86.0) aws-eventstream (~> 1.0, >= 1.0.2) - aws-partitions (~> 1.0) + aws-partitions (~> 1, >= 1.239.0) aws-sigv4 (~> 1.1) jmespath (~> 1.0) - aws-sdk-kms (1.24.0) - aws-sdk-core (~> 3, >= 3.61.1) + aws-sdk-kms (1.27.0) + aws-sdk-core (~> 3, >= 3.71.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.48.0) - aws-sdk-core (~> 3, >= 3.61.1) + aws-sdk-s3 (1.60.1) + aws-sdk-core (~> 3, >= 3.83.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.1) aws-sigv4 (1.1.0) aws-eventstream (~> 1.0, >= 1.0.2) bcrypt (3.1.12) - benchmark-ips (2.7.2) better_errors (2.5.1) coderay (>= 1.0.0) erubi (>= 1.0.0) rack (>= 0.9.0) binding_of_caller (0.8.0) debug_inspector (>= 0.0.1) - blurhash (0.1.3) + blurhash (0.1.4) ffi (~> 1.10.0) bootsnap (1.4.5) msgpack (~> 1.0) - brakeman (4.6.1) - browser (2.6.1) - builder (3.2.3) - bullet (6.0.2) + brakeman (4.7.2) + browser (3.0.3) + builder (3.2.4) + bullet (6.1.0) activesupport (>= 3.0.0) uniform_notifier (~> 1.11) bundler-audit (0.6.1) @@ -153,12 +139,12 @@ GEM capistrano-rails (1.4.0) capistrano (~> 3.1) capistrano-bundler (~> 1.1) - capistrano-rbenv (2.1.4) + capistrano-rbenv (2.1.6) capistrano (~> 3.1) sshkit (~> 1.3) capistrano-yarn (2.0.2) capistrano (~> 3.0) - capybara (3.29.0) + capybara (3.30.0) addressable mini_mime (>= 0.1.3) nokogiri (~> 1.8) @@ -168,14 +154,14 @@ GEM xpath (~> 3.2) case_transform (0.2) activesupport - charlock_holmes (0.7.6) + charlock_holmes (0.7.7) chewy (5.1.0) activesupport (>= 4.0) elasticsearch (>= 2.0.0) elasticsearch-dsl - chunky_png (1.3.10) - cld3 (3.2.4) - ffi (>= 1.1.0, < 1.11.0) + chunky_png (1.3.11) + cld3 (3.2.6) + ffi (>= 1.1.0, < 1.12.0) climate_control (0.2.0) cocaine (0.5.8) climate_control (>= 0.0.3, < 1.0) @@ -184,19 +170,10 @@ GEM connection_pool (2.2.2) crack (0.4.3) safe_yaml (~> 1.0.0) - crass (1.0.4) - css_parser (1.7.0) + crass (1.0.6) + css_parser (1.7.1) addressable debug_inspector (0.0.3) - derailed_benchmarks (1.4.0) - benchmark-ips (~> 2) - get_process_mem (~> 0) - heapy (~> 0) - memory_profiler (~> 0) - rack (>= 1) - rake (> 10, < 13) - ruby-statistics (>= 2.1) - thor (~> 0.19) devise (4.7.1) bcrypt (~> 3.0) orm_adapter (~> 0.1) @@ -216,14 +193,15 @@ GEM discard (1.1.0) activerecord (>= 4.2, < 7) docile (1.3.2) - domain_name (0.5.20180417) + domain_name (0.5.20190701) unf (>= 0.0.5, < 1.0.0) - doorkeeper (5.2.1) + doorkeeper (5.2.3) railties (>= 5) dotenv (2.7.5) dotenv-rails (2.7.5) dotenv (= 2.7.5) railties (>= 3.2, < 6.1) + e2mmap (0.1.0) elasticsearch (7.3.0) elasticsearch-api (= 7.3.0) elasticsearch-transport (= 7.3.0) @@ -235,18 +213,21 @@ GEM multi_json encryptor (3.0.0) equatable (0.6.1) - erubi (1.8.0) + erubi (1.9.0) et-orbi (1.1.6) tzinfo - excon (0.62.0) - fabrication (2.20.2) - faker (2.5.0) - i18n (~> 1.6.0) - faraday (0.15.4) + excon (0.71.0) + fabrication (2.21.0) + faker (2.10.1) + i18n (>= 1.6, < 2) + faraday (1.0.0) multipart-post (>= 1.2, < 3) fast_blank (1.0.0) fastimage (2.1.7) ffi (1.10.0) + ffi-compiler (1.0.1) + ffi (>= 1.0.0) + rake fog-core (2.1.0) builder excon (~> 0.58) @@ -263,20 +244,18 @@ GEM fugit (1.1.6) et-orbi (~> 1.1, >= 1.1.6) raabro (~> 1.1) - fuubar (2.4.1) + fuubar (2.5.0) rspec-core (~> 3.0) ruby-progressbar (~> 1.4) - get_process_mem (0.2.4) - ffi (~> 1.0) globalid (0.4.2) activesupport (>= 4.2.0) - goldfinger (2.1.0) + goldfinger (2.1.1) addressable (~> 2.5) - http (~> 3.0) + http (~> 4.0) nokogiri (~> 1.8) oj (~> 3.0) - hamlit (2.9.3) - temple (>= 0.8.0) + hamlit (2.11.0) + temple (>= 0.8.2) thor tilt hamlit-rails (0.2.3) @@ -288,26 +267,27 @@ GEM concurrent-ruby (~> 1.0) hashdiff (1.0.0) hashie (3.6.0) - heapy (0.1.4) - highline (2.0.1) + highline (2.0.3) hiredis (0.6.3) hkdf (0.3.0) htmlentities (4.3.4) - http (3.3.0) + http (4.3.0) addressable (~> 2.3) http-cookie (~> 1.0) - http-form_data (~> 2.0) - http_parser.rb (~> 0.6.0) + http-form_data (~> 2.2) + http-parser (~> 1.2.0) http-cookie (1.0.3) domain_name (~> 0.5) - http-form_data (2.1.1) + http-form_data (2.2.0) + http-parser (1.2.1) + ffi-compiler (>= 1.0, < 2.0) http_accept_language (2.1.1) - httplog (1.3.2) + httplog (1.4.2) rack (>= 1.0) rainbow (>= 2.0.0) - i18n (1.6.0) + i18n (1.8.2) concurrent-ruby (~> 1.0) - i18n-tasks (0.9.29) + i18n-tasks (0.9.30) activesupport (>= 4.0.2) ast (>= 2.1.0) erubi @@ -320,11 +300,18 @@ GEM idn-ruby (0.1.0) ipaddress (0.8.3) iso-639 (0.2.8) - jaro_winkler (1.5.3) + jaro_winkler (1.5.4) jmespath (1.4.0) - json (2.2.0) - json-canonicalization (0.1.0) - json-ld-preloaded (3.0.4) + json (2.3.0) + json-canonicalization (0.2.0) + json-ld (3.1.0) + htmlentities (~> 4.3) + json-canonicalization (~> 0.1) + link_header (~> 0.0, >= 0.0.8) + multi_json (~> 1.14) + rack (~> 2.0) + rdf (~> 3.1) + json-ld-preloaded (3.0.6) json-ld (~> 3.0) multi_json (~> 1.12) rdf (~> 3.0) @@ -356,7 +343,7 @@ GEM activesupport (>= 4) railties (>= 4) request_store (~> 1.0) - loofah (2.2.3) + loofah (2.4.0) crass (~> 1.0.2) nokogiri (>= 1.5.9) mail (2.7.1) @@ -369,26 +356,26 @@ GEM redis (>= 3.0.5) memory_profiler (0.9.14) method_source (0.9.2) - microformats (4.1.0) - json (~> 2.1) - nokogiri (~> 1.8, >= 1.8.3) - mime-types (3.3) + microformats (4.2.0) + json (~> 2.2) + nokogiri (~> 1.10) + mime-types (3.3.1) mime-types-data (~> 3.2015) - mime-types-data (3.2019.0904) + mime-types-data (3.2019.1009) mimemagic (0.3.3) mini_mime (1.0.2) mini_portile2 (2.4.0) - minitest (5.12.0) + minitest (5.14.0) msgpack (1.3.1) - multi_json (1.13.1) + multi_json (1.14.1) multipart-post (2.1.1) - necromancer (0.5.0) - net-ldap (0.16.1) + necromancer (0.5.1) + net-ldap (0.16.2) net-scp (2.0.0) net-ssh (>= 2.6.5, < 6.0.0) net-ssh (5.2.0) - nio4r (2.5.1) - nokogiri (1.10.4) + nio4r (2.5.2) + nokogiri (1.10.7) mini_portile2 (~> 2.4.0) nokogumbo (2.0.1) nokogiri (~> 1.8, >= 1.8.4) @@ -397,7 +384,7 @@ GEM concurrent-ruby (~> 1.0, >= 1.0.2) sidekiq (>= 3.5) statsd-ruby (~> 1.4, >= 1.4.0) - oj (3.9.1) + oj (3.10.1) omniauth (1.9.0) hashie (>= 3.4.6, < 3.7.0) rack (>= 1.6.2, < 3) @@ -409,11 +396,7 @@ GEM omniauth (~> 1.3, >= 1.3.2) ruby-saml (~> 1.7) orm_adapter (0.5.0) - ostatus2 (2.0.3) - addressable (~> 2.5) - http (~> 3.0) - nokogiri (~> 1.8) - ox (2.11.0) + ox (2.12.1) paperclip (6.0.0) activemodel (>= 4.2.0) activesupport (>= 4.2.0) @@ -423,19 +406,19 @@ GEM paperclip-av-transcoder (0.6.4) av (~> 0.9.0) paperclip (>= 2.5.2) - parallel (1.17.0) - parallel_tests (2.29.2) + parallel (1.19.1) + parallel_tests (2.30.1) parallel - parser (2.6.4.0) + parser (2.7.0.2) ast (~> 2.4.0) parslet (1.8.2) pastel (0.7.3) equatable (~> 0.6) tty-color (~> 0.5) - pg (1.1.4) - pghero (2.3.0) + pg (1.2.2) + pghero (2.4.1) activerecord (>= 5) - pkg-config (1.3.9) + pkg-config (1.4.0) premailer (1.11.1) addressable css_parser (>= 1.6.0) @@ -452,34 +435,35 @@ GEM pry (~> 0.10) pry-rails (0.3.9) pry (>= 0.10.4) - public_suffix (4.0.1) - puma (4.2.0) + public_suffix (4.0.3) + puma (4.3.1) nio4r (~> 2.0) pundit (2.1.0) activesupport (>= 3.0.0) raabro (1.1.6) - rack (2.0.7) - rack-attack (6.1.0) + rack (2.1.2) + rack-attack (6.2.2) rack (>= 1.0, < 3) - rack-cors (1.0.3) - rack-protection (2.0.5) + rack-cors (1.1.1) + rack (>= 2.0.0) + rack-protection (2.0.7) rack rack-proxy (0.6.5) rack rack-test (1.1.0) rack (>= 1.0, < 3) - rails (5.2.3) - actioncable (= 5.2.3) - actionmailer (= 5.2.3) - actionpack (= 5.2.3) - actionview (= 5.2.3) - activejob (= 5.2.3) - activemodel (= 5.2.3) - activerecord (= 5.2.3) - activestorage (= 5.2.3) - activesupport (= 5.2.3) + rails (5.2.4.1) + actioncable (= 5.2.4.1) + actionmailer (= 5.2.4.1) + actionpack (= 5.2.4.1) + actionview (= 5.2.4.1) + activejob (= 5.2.4.1) + activemodel (= 5.2.4.1) + activerecord (= 5.2.4.1) + activestorage (= 5.2.4.1) + activesupport (= 5.2.4.1) bundler (>= 1.3.0) - railties (= 5.2.3) + railties (= 5.2.4.1) sprockets-rails (>= 2.0.0) rails-controller-testing (1.0.4) actionpack (>= 5.0.1.x) @@ -488,26 +472,26 @@ GEM rails-dom-testing (2.0.3) activesupport (>= 4.2.0) nokogiri (>= 1.6) - rails-html-sanitizer (1.2.0) - loofah (~> 2.2, >= 2.2.2) + rails-html-sanitizer (1.3.0) + loofah (~> 2.3) rails-i18n (5.1.3) i18n (>= 0.7, < 2) railties (>= 5.0, < 6) rails-settings-cached (0.6.6) rails (>= 4.2.0) - railties (5.2.3) - actionpack (= 5.2.3) - activesupport (= 5.2.3) + railties (5.2.4.1) + actionpack (= 5.2.4.1) + activesupport (= 5.2.4.1) method_source rake (>= 0.8.7) thor (>= 0.19.0, < 2.0) rainbow (3.0.0) - rake (12.3.3) - rdf (3.0.12) + rake (13.0.1) + rdf (3.1.1) hamster (~> 3.0) link_header (~> 0.0, >= 0.0.8) - rdf-normalize (0.3.3) - rdf (>= 2.2, < 4.0) + rdf-normalize (0.4.0) + rdf (~> 3.1) redis (4.1.3) redis-actionpack (5.0.2) actionpack (>= 4.0, < 6) @@ -516,7 +500,7 @@ GEM redis-activesupport (5.0.4) activesupport (>= 3, < 6) redis-store (>= 1.3, < 2) - redis-namespace (1.6.0) + redis-namespace (1.7.0) redis (>= 3.0.4) redis-rack (2.0.4) rack (>= 1.5, < 3) @@ -528,49 +512,50 @@ GEM redis-store (1.5.0) redis (>= 2.2, < 5) regexp_parser (1.6.0) - request_store (1.4.1) + request_store (1.5.0) rack (>= 1.4) responders (3.0.0) actionpack (>= 5.0) railties (>= 5.0) rotp (2.1.2) rpam2 (4.0.2) - rqrcode (0.10.1) + rqrcode (1.1.2) chunky_png (~> 1.0) - rspec-core (3.8.0) - rspec-support (~> 3.8.0) - rspec-expectations (3.8.2) + rqrcode_core (~> 0.1) + rqrcode_core (0.1.1) + rspec-core (3.9.0) + rspec-support (~> 3.9.0) + rspec-expectations (3.9.0) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.8.0) - rspec-mocks (3.8.0) + rspec-support (~> 3.9.0) + rspec-mocks (3.9.0) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.8.0) - rspec-rails (3.8.2) + rspec-support (~> 3.9.0) + rspec-rails (3.9.0) actionpack (>= 3.0) activesupport (>= 3.0) railties (>= 3.0) - rspec-core (~> 3.8.0) - rspec-expectations (~> 3.8.0) - rspec-mocks (~> 3.8.0) - rspec-support (~> 3.8.0) + rspec-core (~> 3.9.0) + rspec-expectations (~> 3.9.0) + rspec-mocks (~> 3.9.0) + rspec-support (~> 3.9.0) rspec-sidekiq (3.0.3) rspec-core (~> 3.0, >= 3.0.0) sidekiq (>= 2.4.0) - rspec-support (3.8.0) - rubocop (0.74.0) + rspec-support (3.9.0) + rubocop (0.79.0) jaro_winkler (~> 1.5.1) parallel (~> 1.10) - parser (>= 2.6) + parser (>= 2.7.0.1) rainbow (>= 2.2.2, < 4.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 1.4.0, < 1.7) - rubocop-rails (2.3.2) + rubocop-rails (2.4.1) rack (>= 1.1) rubocop (>= 0.72.0) ruby-progressbar (1.10.1) ruby-saml (1.9.0) nokogiri (>= 1.5.10) - ruby-statistics (2.1.1) rufus-scheduler (3.5.2) fugit (~> 1.1, >= 1.1.5) safe_yaml (1.0.5) @@ -590,13 +575,13 @@ GEM rufus-scheduler (~> 3.2) sidekiq (>= 3) tilt (>= 1.4.0) - sidekiq-unique-jobs (6.0.13) + sidekiq-unique-jobs (6.0.18) concurrent-ruby (~> 1.0, >= 1.0.5) sidekiq (>= 4.0, < 7.0) thor (~> 0) simple-navigation (4.1.0) activesupport (>= 2.3.2) - simple_form (4.1.0) + simple_form (5.0.1) actionpack (>= 5.0) activemodel (>= 5.0) simplecov (0.17.1) @@ -614,62 +599,63 @@ GEM sshkit (1.20.0) net-scp (>= 1.1.2) net-ssh (>= 2.8.0) - stackprof (0.2.12) + stackprof (0.2.15) statsd-ruby (1.4.0) - stoplight (2.1.3) + stoplight (2.2.0) streamio-ffmpeg (3.0.2) multi_json (~> 1.8) - strong_migrations (0.4.1) + strong_migrations (0.5.1) activerecord (>= 5) - temple (0.8.1) + temple (0.8.2) terminal-table (1.8.0) unicode-display_width (~> 1.1, >= 1.1.1) terrapin (0.6.0) climate_control (>= 0.0.3, < 1.0) thor (0.20.3) thread_safe (0.3.6) - tilt (2.0.9) + thwait (0.1.0) + tilt (2.0.10) tty-color (0.5.0) tty-command (0.9.0) pastel (~> 0.7.0) tty-cursor (0.7.0) - tty-prompt (0.19.0) + tty-prompt (0.20.0) necromancer (~> 0.5.0) pastel (~> 0.7.0) - tty-reader (~> 0.6.0) - tty-reader (0.6.0) + tty-reader (~> 0.7.0) + tty-reader (0.7.0) tty-cursor (~> 0.7) tty-screen (~> 0.7) wisper (~> 2.0.0) tty-screen (0.7.0) twitter-text (1.14.7) unf (~> 0.1.0) - tzinfo (1.2.5) + tzinfo (1.2.6) thread_safe (~> 0.1) tzinfo-data (1.2019.3) tzinfo (>= 1.0.0) unf (0.1.4) unf_ext - unf_ext (0.0.7.5) - unicode-display_width (1.6.0) - uniform_notifier (1.12.1) + unf_ext (0.0.7.6) + unicode-display_width (1.6.1) + uniform_notifier (1.13.0) warden (1.2.8) rack (>= 2.0.6) - webmock (3.7.6) + webmock (3.8.0) addressable (>= 2.3.6) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - webpacker (4.0.7) + webpacker (4.2.2) activesupport (>= 4.2) rack-proxy (>= 0.6.1) railties (>= 4.2) webpush (0.3.8) hkdf (~> 0.2) jwt (~> 2.0) - websocket-driver (0.7.0) + websocket-driver (0.7.1) websocket-extensions (>= 0.1.0) - websocket-extensions (0.1.3) - wisper (2.0.0) + websocket-extensions (0.1.4) + wisper (2.0.1) xpath (3.2.0) nokogiri (~> 1.8) @@ -678,56 +664,56 @@ PLATFORMS DEPENDENCIES active_model_serializers (~> 0.10) - active_record_query_trace (~> 1.6) + active_record_query_trace (~> 1.7) addressable (~> 2.7) - annotate (~> 2.7) - aws-sdk-s3 (~> 1.48) + annotate (~> 3.0) + aws-sdk-s3 (~> 1.60) better_errors (~> 2.5) binding_of_caller (~> 0.7) blurhash (~> 0.1) bootsnap (~> 1.4) - brakeman (~> 4.6) + brakeman (~> 4.7) browser - bullet (~> 6.0) + bullet (~> 6.1) bundler-audit (~> 0.6) capistrano (~> 3.11) capistrano-rails (~> 1.4) capistrano-rbenv (~> 2.1) capistrano-yarn (~> 2.0) - capybara (~> 3.29) - charlock_holmes (~> 0.7.6) + capybara (~> 3.30) + charlock_holmes (~> 0.7.7) chewy (~> 5.1) - cld3 (~> 3.2.4) + cld3 (~> 3.2.6) climate_control (~> 0.2) concurrent-ruby connection_pool - derailed_benchmarks devise (~> 4.7) devise-two-factor (~> 3.1) devise_pam_authenticatable2 (~> 9.2) discard (~> 1.1) doorkeeper (~> 5.2) dotenv-rails (~> 2.7) - fabrication (~> 2.20) - faker (~> 2.5) + e2mmap (~> 0.1.0) + fabrication (~> 2.21) + faker (~> 2.10) fast_blank (~> 1.0) fastimage fog-core (<= 2.1.0) fog-openstack (~> 0.3) - fuubar (~> 2.4) + fuubar (~> 2.5) goldfinger (~> 2.1) hamlit-rails (~> 0.2) health_check! hiredis (~> 0.6) htmlentities (~> 4.3) - http (~> 3.3) + http (~> 4.3) http_accept_language (~> 2.1) http_parser.rb (~> 0.6)! - httplog (~> 1.3) + httplog (~> 1.4.2) i18n-tasks (~> 0.9) idn-ruby iso-639 - json-ld! + json-ld json-ld-preloaded (~> 3.0) kaminari (~> 1.1) letter_opener (~> 1.7) @@ -737,48 +723,48 @@ DEPENDENCIES makara (~> 0.4) mario-redis-lock (~> 1.2) memory_profiler - microformats (~> 4.1) - mime-types (~> 3.3) - net-ldap (~> 0.10) + microformats (~> 4.2) + mime-types (~> 3.3.1) + net-ldap (~> 0.16) nilsimsa! nokogiri (~> 1.10) nsa (~> 0.2) - oj (~> 3.9) + oj (~> 3.10) omniauth (~> 1.9) omniauth-cas (~> 1.1) omniauth-saml (~> 1.10) - ostatus2 (~> 2.0) - ox (~> 2.11) + ox (~> 2.12) paperclip (~> 6.0) paperclip-av-transcoder (~> 0.6) - parallel (~> 1.17) - parallel_tests (~> 2.29) + parallel (~> 1.19) + parallel_tests (~> 2.30) parslet - pg (~> 1.1) - pghero (~> 2.3) - pkg-config (~> 1.3) + pg (~> 1.2) + pghero (~> 2.4) + pkg-config (~> 1.4) posix-spawn! premailer-rails private_address_check (~> 0.5) pry-byebug (~> 3.7) pry-rails (~> 0.3) - puma (~> 4.2) + puma (~> 4.3) pundit (~> 2.1) - rack-attack (~> 6.1) - rack-cors (~> 1.0) - rails (~> 5.2.3) + rack (~> 2.1.2) + rack-attack (~> 6.2) + rack-cors (~> 1.1) + rails (~> 5.2.4) rails-controller-testing (~> 1.0) rails-i18n (~> 5.1) rails-settings-cached (~> 0.6) - rdf-normalize (~> 0.3) + rdf-normalize (~> 0.4) redis (~> 4.1) - redis-namespace (~> 1.5) + redis-namespace (~> 1.7) redis-rails (~> 5.0) - rqrcode (~> 0.10) - rspec-rails (~> 3.8) + rqrcode (~> 1.1) + rspec-rails (~> 3.9) rspec-sidekiq (~> 3.0) - rubocop (~> 0.74) - rubocop-rails (~> 2.3) + rubocop (~> 0.79) + rubocop-rails (~> 2.4) ruby-progressbar (~> 1.10) sanitize (~> 5.1) sidekiq (~> 5.2) @@ -786,24 +772,20 @@ DEPENDENCIES sidekiq-scheduler (~> 3.0) sidekiq-unique-jobs (~> 6.0) simple-navigation (~> 4.1) - simple_form (~> 4.1) + simple_form (~> 5.0) simplecov (~> 0.17) + sprockets (~> 3.7.2) sprockets-rails (~> 3.2) stackprof - stoplight (~> 2.1.3) + stoplight (~> 2.2.0) streamio-ffmpeg (~> 3.0) - strong_migrations (~> 0.4) + strong_migrations (~> 0.5) thor (~> 0.20) + thwait (~> 0.1.0) tty-command (~> 0.9) - tty-prompt (~> 0.19) + tty-prompt (~> 0.20) twitter-text (~> 1.14) tzinfo-data (~> 1.2019) - webmock (~> 3.7) - webpacker (~> 4.0) + webmock (~> 3.8) + webpacker (~> 4.2) webpush - -RUBY VERSION - ruby 2.6.5p114 - -BUNDLED WITH - 1.17.3 diff --git a/README.md b/README.md index d50c1b3bc..7db66abfa 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ [crowdin]: https://crowdin.com/project/mastodon [docker]: https://hub.docker.com/r/tootsuite/mastodon/ -Mastodon is a **free, open-source social network server** based on ActivityPub. Follow friends and discover new ones. Publish anything you want: links, pictures, text, video. All servers of Mastodon are interoperable as a federated network, i.e. users on one server can seamlessly communicate with users from another one. This includes non-Mastodon software that also implements ActivityPub! +Mastodon is a **free, open-source social network server** based on ActivityPub where users can follow friends and discover new ones. On Mastodon, users can publish anything they want: links, pictures, text, video. All Mastodon servers are interoperable as a federated network (users on one server can seamlessly communicate with users from another one, including non-Mastodon software that implements ActivityPub)! Click below to **learn more** in a video: @@ -70,15 +70,15 @@ Mastodon acts as an OAuth2 provider so 3rd party apps can use the REST and Strea - **PostgreSQL** 9.5+ - **Redis** - **Ruby** 2.4+ -- **Node.js** 8+ +- **Node.js** 10.13+ -The repository includes deployment configurations for **Docker and docker-compose**, but also a few specific platforms like **Heroku**, **Scalingo**, and **Nanobox**. The [**stand-alone** installation guide](https://docs.joinmastodon.org/administration/installation/) is available in the documentation. +The repository includes deployment configurations for **Docker and docker-compose**, but also a few specific platforms like **Heroku**, **Scalingo**, and **Nanobox**. The [**stand-alone** installation guide](https://docs.joinmastodon.org/admin/install/) is available in the documentation. A **Vagrant** configuration is included for development purposes. ## Contributing -Mastodon is **free, open source software** licensed under **AGPLv3**. +Mastodon is **free, open-source software** licensed under **AGPLv3**. You can open issues for bugs you've found or features you think are missing. You can also submit pull requests to this repository, or submit translations using Weblate. To get started, take a look at [CONTRIBUTING.md](CONTRIBUTING.md). If your contributions are accepted into Mastodon, you can request to be paid through [our OpenCollective](https://opencollective.com/mastodon). @@ -86,7 +86,7 @@ You can open issues for bugs you've found or features you think are missing. You ## License -Copyright (C) 2016-2019 Eugen Rochko & other Mastodon contributors (see [AUTHORS.md](AUTHORS.md)) +Copyright (C) 2016-2020 Eugen Rochko & other Mastodon contributors (see [AUTHORS.md](AUTHORS.md)) This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. diff --git a/Vagrantfile b/Vagrantfile index c4941f673..79af1dc5d 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -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"]} diff --git a/app/controllers/activitypub/inboxes_controller.rb b/app/controllers/activitypub/inboxes_controller.rb index bcfc1e6d4..291eec19a 100644 --- a/app/controllers/activitypub/inboxes_controller.rb +++ b/app/controllers/activitypub/inboxes_controller.rb @@ -7,6 +7,7 @@ class ActivityPub::InboxesController < ActivityPub::BaseController before_action :skip_unknown_actor_delete before_action :require_signature! + skip_before_action :authenticate_user! def create upgrade_account diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb index 68b6352f8..7b1783542 100644 --- a/app/controllers/admin/accounts_controller.rb +++ b/app/controllers/admin/accounts_controller.rb @@ -109,21 +109,7 @@ module Admin end def filter_params - params.permit( - :local, - :remote, - :by_domain, - :active, - :pending, - :disabled, - :silenced, - :suspended, - :username, - :display_name, - :email, - :ip, - :staff - ) + params.slice(*AccountFilter::KEYS).permit(*AccountFilter::KEYS) end end end diff --git a/app/controllers/admin/announcements_controller.rb b/app/controllers/admin/announcements_controller.rb new file mode 100644 index 000000000..494fd13d0 --- /dev/null +++ b/app/controllers/admin/announcements_controller.rb @@ -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 diff --git a/app/controllers/admin/custom_emojis_controller.rb b/app/controllers/admin/custom_emojis_controller.rb index 2af90f051..efa8f2950 100644 --- a/app/controllers/admin/custom_emojis_controller.rb +++ b/app/controllers/admin/custom_emojis_controller.rb @@ -2,10 +2,6 @@ module Admin class CustomEmojisController < BaseController - include ObfuscateFilename - - obfuscate_filename [:custom_emoji, :image] - def index authorize :custom_emoji, :index? @@ -52,7 +48,7 @@ module Admin end def filter_params - params.slice(:local, :remote, :by_domain, :shortcode, :page).permit(:local, :remote, :by_domain, :shortcode, :page) + params.slice(:page, *CustomEmojiFilter::KEYS).permit(:page, *CustomEmojiFilter::KEYS) end def action_from_button diff --git a/app/controllers/admin/followers_controller.rb b/app/controllers/admin/followers_controller.rb deleted file mode 100644 index d826f47c5..000000000 --- a/app/controllers/admin/followers_controller.rb +++ /dev/null @@ -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 diff --git a/app/controllers/admin/instances_controller.rb b/app/controllers/admin/instances_controller.rb index b47b18f8e..2fc041207 100644 --- a/app/controllers/admin/instances_controller.rb +++ b/app/controllers/admin/instances_controller.rb @@ -62,7 +62,7 @@ module Admin end def filter_params - params.permit(:limited, :by_domain) + params.slice(*InstanceFilter::KEYS).permit(*InstanceFilter::KEYS) end end end diff --git a/app/controllers/admin/invites_controller.rb b/app/controllers/admin/invites_controller.rb index 44a8eec77..dabfe9765 100644 --- a/app/controllers/admin/invites_controller.rb +++ b/app/controllers/admin/invites_controller.rb @@ -47,7 +47,7 @@ module Admin end def filter_params - params.permit(:available, :expired) + params.slice(*InviteFilter::KEYS).permit(*InviteFilter::KEYS) end end end diff --git a/app/controllers/admin/relationships_controller.rb b/app/controllers/admin/relationships_controller.rb new file mode 100644 index 000000000..f8a95cfc8 --- /dev/null +++ b/app/controllers/admin/relationships_controller.rb @@ -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 diff --git a/app/controllers/admin/reports_controller.rb b/app/controllers/admin/reports_controller.rb index f138376b2..7c831b3d4 100644 --- a/app/controllers/admin/reports_controller.rb +++ b/app/controllers/admin/reports_controller.rb @@ -52,11 +52,7 @@ module Admin end def filter_params - params.permit( - :account_id, - :resolved, - :target_account_id - ) + params.slice(*ReportFilter::KEYS).permit(*ReportFilter::KEYS) end def set_report diff --git a/app/controllers/admin/tags_controller.rb b/app/controllers/admin/tags_controller.rb index 65341bbfb..59df4470e 100644 --- a/app/controllers/admin/tags_controller.rb +++ b/app/controllers/admin/tags_controller.rb @@ -73,7 +73,7 @@ module Admin end def filter_params - params.slice(:directory, :reviewed, :unreviewed, :pending_review, :page, :popular, :active, :name).permit(:directory, :reviewed, :unreviewed, :pending_review, :page, :popular, :active, :name) + params.slice(:page, *TagFilter::KEYS).permit(:page, *TagFilter::KEYS) end def tag_params diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index 33df75b37..68bf425f4 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -20,6 +20,10 @@ class Api::BaseController < ApplicationController render json: { error: e.to_s }, status: 422 end + rescue_from ActiveRecord::RecordNotUnique do + render json: { error: 'Duplicate record' }, status: 422 + end + rescue_from ActiveRecord::RecordNotFound do render json: { error: 'Record not found' }, status: 404 end @@ -81,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! diff --git a/app/controllers/api/oembed_controller.rb b/app/controllers/api/oembed_controller.rb index 37a163cd3..66da65bed 100644 --- a/app/controllers/api/oembed_controller.rb +++ b/app/controllers/api/oembed_controller.rb @@ -1,15 +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 diff --git a/app/controllers/api/proofs_controller.rb b/app/controllers/api/proofs_controller.rb index a98599eee..dd32cd577 100644 --- a/app/controllers/api/proofs_controller.rb +++ b/app/controllers/api/proofs_controller.rb @@ -3,6 +3,8 @@ class Api::ProofsController < Api::BaseController include AccountOwnedConcern + skip_before_action :require_authenticated_user! + before_action :set_provider def index diff --git a/app/controllers/api/v1/accounts/credentials_controller.rb b/app/controllers/api/v1/accounts/credentials_controller.rb index e77f57910..64b5cb747 100644 --- a/app/controllers/api/v1/accounts/credentials_controller.rb +++ b/app/controllers/api/v1/accounts/credentials_controller.rb @@ -25,7 +25,7 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController end def user_settings_params - return nil unless params.key?(:source) + return nil if params[:source].blank? source_params = params.require(:source) diff --git a/app/controllers/api/v1/accounts/follower_accounts_controller.rb b/app/controllers/api/v1/accounts/follower_accounts_controller.rb index 2dabb8398..e360b8a92 100644 --- a/app/controllers/api/v1/accounts/follower_accounts_controller.rb +++ b/app/controllers/api/v1/accounts/follower_accounts_controller.rb @@ -21,11 +21,13 @@ class Api::V1::Accounts::FollowerAccountsController < Api::BaseController def load_accounts return [] if hide_results? - default_accounts.merge(paginated_follows).to_a + scope = default_accounts + scope = scope.where.not(id: current_account.excluded_from_timeline_account_ids) unless current_account.nil? + scope.merge(paginated_follows).to_a end def hide_results? - (@account.user_hides_network? && current_account.id != @account.id) || (current_account && @account.blocking?(current_account)) + (@account.user_hides_network? && current_account&.id != @account.id) || (current_account && @account.blocking?(current_account)) end def default_accounts diff --git a/app/controllers/api/v1/accounts/following_accounts_controller.rb b/app/controllers/api/v1/accounts/following_accounts_controller.rb index 44e89804b..a405b365f 100644 --- a/app/controllers/api/v1/accounts/following_accounts_controller.rb +++ b/app/controllers/api/v1/accounts/following_accounts_controller.rb @@ -21,11 +21,13 @@ class Api::V1::Accounts::FollowingAccountsController < Api::BaseController def load_accounts return [] if hide_results? - default_accounts.merge(paginated_follows).to_a + scope = default_accounts + scope = scope.where.not(id: current_account.excluded_from_timeline_account_ids) unless current_account.nil? + scope.merge(paginated_follows).to_a end def hide_results? - (@account.user_hides_network? && current_account.id != @account.id) || (current_account && @account.blocking?(current_account)) + (@account.user_hides_network? && current_account&.id != @account.id) || (current_account && @account.blocking?(current_account)) end def default_accounts diff --git a/app/controllers/api/v1/announcements/reactions_controller.rb b/app/controllers/api/v1/announcements/reactions_controller.rb new file mode 100644 index 000000000..e4a72e595 --- /dev/null +++ b/app/controllers/api/v1/announcements/reactions_controller.rb @@ -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 diff --git a/app/controllers/api/v1/announcements_controller.rb b/app/controllers/api/v1/announcements_controller.rb new file mode 100644 index 000000000..1e692ff75 --- /dev/null +++ b/app/controllers/api/v1/announcements_controller.rb @@ -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 diff --git a/app/controllers/api/v1/bookmarks_controller.rb b/app/controllers/api/v1/bookmarks_controller.rb new file mode 100644 index 000000000..e1b244e76 --- /dev/null +++ b/app/controllers/api/v1/bookmarks_controller.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +class Api::V1::BookmarksController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:bookmarks' } + before_action :require_user! + after_action :insert_pagination_headers + + respond_to :json + + def index + @statuses = load_statuses + render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) + end + + private + + def load_statuses + cached_bookmarks + end + + def cached_bookmarks + cache_collection( + Status.reorder(nil).joins(:bookmarks).merge(results), + Status + ) + end + + def results + @_results ||= account_bookmarks.paginate_by_id( + limit_param(DEFAULT_STATUSES_LIMIT), + params_slice(:max_id, :since_id, :min_id) + ) + end + + def account_bookmarks + current_account.bookmarks + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + api_v1_bookmarks_url pagination_params(max_id: pagination_max_id) if records_continue? + end + + def prev_path + api_v1_bookmarks_url pagination_params(min_id: pagination_since_id) unless results.empty? + end + + def pagination_max_id + results.last.id + end + + def pagination_since_id + results.first.id + end + + def records_continue? + results.size == limit_param(DEFAULT_STATUSES_LIMIT) + end + + def pagination_params(core_params) + params.slice(:limit).permit(:limit).merge(core_params) + end +end diff --git a/app/controllers/api/v1/media_controller.rb b/app/controllers/api/v1/media_controller.rb index aaa93b615..81825db15 100644 --- a/app/controllers/api/v1/media_controller.rb +++ b/app/controllers/api/v1/media_controller.rb @@ -4,9 +4,6 @@ class Api::V1::MediaController < Api::BaseController before_action -> { doorkeeper_authorize! :write, :'write:media' } before_action :require_user! - include ObfuscateFilename - obfuscate_filename :file - respond_to :json def create diff --git a/app/controllers/api/v1/push/subscriptions_controller.rb b/app/controllers/api/v1/push/subscriptions_controller.rb index 1b658f870..1cbc92b93 100644 --- a/app/controllers/api/v1/push/subscriptions_controller.rb +++ b/app/controllers/api/v1/push/subscriptions_controller.rb @@ -51,6 +51,6 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController def data_params return {} if params[:data].blank? - params.require(:data).permit(alerts: [:follow, :favourite, :reblog, :mention, :poll]) + params.require(:data).permit(alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll]) end end diff --git a/app/controllers/api/v1/statuses/bookmarks_controller.rb b/app/controllers/api/v1/statuses/bookmarks_controller.rb new file mode 100644 index 000000000..bb9729cf5 --- /dev/null +++ b/app/controllers/api/v1/statuses/bookmarks_controller.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class Api::V1::Statuses::BookmarksController < Api::BaseController + include Authorization + + before_action -> { doorkeeper_authorize! :write, :'write:bookmarks' } + before_action :require_user! + + respond_to :json + + def create + @status = bookmarked_status + render json: @status, serializer: REST::StatusSerializer + end + + def destroy + @status = requested_status + @bookmarks_map = { @status.id => false } + + bookmark = Bookmark.find_by!(account: current_user.account, status: @status) + bookmark.destroy! + + render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_user&.account_id, bookmarks_map: @bookmarks_map) + end + + private + + def bookmarked_status + authorize_with current_user.account, requested_status, :show? + + bookmark = Bookmark.find_or_create_by!(account: current_user.account, status: requested_status) + + bookmark.status.reload + end + + def requested_status + Status.find(params[:status_id]) + end +end diff --git a/app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb b/app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb index 657e57831..99eff360e 100644 --- a/app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb +++ b/app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb @@ -17,7 +17,9 @@ class Api::V1::Statuses::FavouritedByAccountsController < Api::BaseController private def load_accounts - default_accounts.merge(paginated_favourites).to_a + scope = default_accounts + scope = scope.where.not(id: current_account.excluded_from_timeline_account_ids) unless current_account.nil? + scope.merge(paginated_favourites).to_a end def default_accounts diff --git a/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb b/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb index 6851099f6..cc285ad23 100644 --- a/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb +++ b/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb @@ -17,7 +17,9 @@ class Api::V1::Statuses::RebloggedByAccountsController < Api::BaseController private def load_accounts - default_accounts.merge(paginated_statuses).to_a + scope = default_accounts + scope = scope.where.not(id: current_account.excluded_from_timeline_account_ids) unless current_account.nil? + scope.merge(paginated_statuses).to_a end def default_accounts diff --git a/app/controllers/api/web/embeds_controller.rb b/app/controllers/api/web/embeds_controller.rb index 6231733b7..4aa31695c 100644 --- a/app/controllers/api/web/embeds_controller.rb +++ b/app/controllers/api/web/embeds_controller.rb @@ -7,15 +7,21 @@ class Api::Web::EmbedsController < Api::Web::BaseController def create status = StatusFinder.new(params[:url]).status + + return not_found if status.hidden? + render json: status, serializer: OEmbedSerializer, width: 400 rescue ActiveRecord::RecordNotFound oembed = FetchOEmbedService.new.call(params[:url]) - oembed[:html] = Formatter.instance.sanitize(oembed[:html], Sanitize::Config::MASTODON_OEMBED) if oembed[:html].present? - if oembed - render json: oembed - else - render json: {}, status: :not_found + return not_found if oembed.nil? + + begin + oembed[:html] = Formatter.instance.sanitize(oembed[:html], Sanitize::Config::MASTODON_OEMBED) + rescue ArgumentError + return not_found end + + render json: oembed end end diff --git a/app/controllers/api/web/push_subscriptions_controller.rb b/app/controllers/api/web/push_subscriptions_controller.rb index d8153e082..f388b17e5 100644 --- a/app/controllers/api/web/push_subscriptions_controller.rb +++ b/app/controllers/api/web/push_subscriptions_controller.rb @@ -19,6 +19,7 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController data = { alerts: { follow: alerts_enabled, + follow_request: false, favourite: alerts_enabled, reblog: alerts_enabled, mention: alerts_enabled, @@ -58,6 +59,6 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController end def data_params - @data_params ||= params.require(:data).permit(alerts: [:follow, :favourite, :reblog, :mention, :poll]) + @data_params ||= params.require(:data).permit(alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll]) end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index bd3d13774..0cfa2b386 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -24,6 +24,7 @@ class ApplicationController < ActionController::Base 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 HTTP::Error, OpenSSL::SSL::SSLError, with: :internal_server_error @@ -137,8 +138,8 @@ class ApplicationController < ActionController::Base def respond_with_error(code) respond_to do |format| - format.any { head code } - format.html { render "errors/#{code}", layout: 'error', status: code } + format.any { render "errors/#{code}", layout: 'error', status: code, formats: [:html] } + format.json { render json: { error: Rack::Utils::HTTP_STATUS_CODES[code] }, status: code } end end end diff --git a/app/controllers/auth/passwords_controller.rb b/app/controllers/auth/passwords_controller.rb index 34b98da53..b98bcecd0 100644 --- a/app/controllers/auth/passwords_controller.rb +++ b/app/controllers/auth/passwords_controller.rb @@ -6,6 +6,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 diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index f35c7fad6..67495c221 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -10,6 +10,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController before_action :set_instance_presenter, only: [:new, :create, :update] before_action :set_body_classes, only: [:new, :create, :edit, :update] before_action :require_not_suspended!, only: [:update] + before_action :set_cache_headers, only: [:edit, :update] skip_before_action :require_functional!, only: [:edit, :update] @@ -21,10 +22,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 @@ -110,4 +118,8 @@ class Auth::RegistrationsController < Devise::RegistrationsController def require_not_suspended! forbidden if current_account.suspended? end + + def set_cache_headers + response.headers['Cache-Control'] = 'no-cache, no-store, max-age=0, must-revalidate' + end end diff --git a/app/controllers/concerns/obfuscate_filename.rb b/app/controllers/concerns/obfuscate_filename.rb deleted file mode 100644 index 22736ec3a..000000000 --- a/app/controllers/concerns/obfuscate_filename.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -module ObfuscateFilename - extend ActiveSupport::Concern - - class_methods do - def obfuscate_filename(path) - before_action do - file = params.dig(*path) - next if file.nil? - - file.original_filename = SecureRandom.hex(8) + File.extname(file.original_filename) - end - end - end -end diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb index ce353f1de..10efbf2e0 100644 --- a/app/controllers/concerns/signature_verification.rb +++ b/app/controllers/concerns/signature_verification.rb @@ -160,6 +160,8 @@ module SignatureVerification account ||= stoplight_wrap_request { ActivityPub::FetchRemoteKeyService.new.call(key_id, id: false) } account end + rescue Mastodon::HostValidationError + nil end def stoplight_wrap_request(&block) diff --git a/app/controllers/filters_controller.rb b/app/controllers/filters_controller.rb index d2e0fb739..63d9d9cd3 100644 --- a/app/controllers/filters_controller.rb +++ b/app/controllers/filters_controller.rb @@ -1,10 +1,9 @@ # frozen_string_literal: true class FiltersController < ApplicationController - include Authorization - layout 'admin' + before_action :authenticate_user! before_action :set_filters, only: :index before_action :set_filter, only: [:edit, :update, :destroy] before_action :set_body_classes diff --git a/app/controllers/follower_accounts_controller.rb b/app/controllers/follower_accounts_controller.rb index 705ff4122..7103749ad 100644 --- a/app/controllers/follower_accounts_controller.rb +++ b/app/controllers/follower_accounts_controller.rb @@ -18,7 +18,6 @@ class FollowerAccountsController < ApplicationController next if @account.user_hides_network? follows - @relationships = AccountRelationshipsPresenter.new(follows.map(&:account_id), current_user.account_id) if user_signed_in? end format.json do @@ -37,7 +36,11 @@ class FollowerAccountsController < ApplicationController private def follows - @follows ||= Follow.where(target_account: @account).recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:account) + return @follows if defined?(@follows) + + scope = Follow.where(target_account: @account) + scope = scope.where.not(account_id: current_account.excluded_from_timeline_account_ids) if user_signed_in? + @follows = scope.recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:account) end def page_requested? diff --git a/app/controllers/following_accounts_controller.rb b/app/controllers/following_accounts_controller.rb index 968de980d..6c8fb84d8 100644 --- a/app/controllers/following_accounts_controller.rb +++ b/app/controllers/following_accounts_controller.rb @@ -18,7 +18,6 @@ class FollowingAccountsController < ApplicationController next if @account.user_hides_network? follows - @relationships = AccountRelationshipsPresenter.new(follows.map(&:target_account_id), current_user.account_id) if user_signed_in? end format.json do @@ -37,7 +36,11 @@ class FollowingAccountsController < ApplicationController private def follows - @follows ||= Follow.where(account: @account).recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:target_account) + return @follows if defined?(@follows) + + scope = Follow.where(account: @account) + scope = scope.where.not(target_account_id: current_account.excluded_from_timeline_account_ids) if user_signed_in? + @follows = scope.recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:target_account) end def page_requested? diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb index cebbdc4d0..bb5d639ce 100644 --- a/app/controllers/oauth/authorizations_controller.rb +++ b/app/controllers/oauth/authorizations_controller.rb @@ -5,6 +5,7 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController before_action :store_current_location before_action :authenticate_resource_owner! + before_action :set_cache_headers include Localized @@ -27,4 +28,8 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController def truthy_param?(key) ActiveModel::Type::Boolean.new.cast(params[key]) end + + def set_cache_headers + response.headers['Cache-Control'] = 'no-cache, no-store, max-age=0, must-revalidate' + end end diff --git a/app/controllers/relationships_controller.rb b/app/controllers/relationships_controller.rb index e6705c327..0835758f2 100644 --- a/app/controllers/relationships_controller.rb +++ b/app/controllers/relationships_controller.rb @@ -19,53 +19,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 @@ -84,8 +44,8 @@ class RelationshipsController < ApplicationController params[:relationship] == 'followed_by' end - def current_params - params.slice(:page, :status, :relationship, :by_domain, :activity, :order).permit(:page, :status, :relationship, :by_domain, :activity, :order) + def filter_params + params.slice(:page, *RelationshipFilter::KEYS).permit(:page, *RelationshipFilter::KEYS) end def action_from_button diff --git a/app/controllers/settings/base_controller.rb b/app/controllers/settings/base_controller.rb index 9bb14afa2..3c404cfff 100644 --- a/app/controllers/settings/base_controller.rb +++ b/app/controllers/settings/base_controller.rb @@ -2,10 +2,15 @@ class Settings::BaseController < ApplicationController before_action :set_body_classes + before_action :set_cache_headers private def set_body_classes @body_classes = 'admin' end + + def set_cache_headers + response.headers['Cache-Control'] = 'no-cache, no-store, max-age=0, must-revalidate' + end end diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb index edf29947b..bac9b329d 100644 --- a/app/controllers/settings/preferences_controller.rb +++ b/app/controllers/settings/preferences_controller.rb @@ -57,6 +57,7 @@ class Settings::PreferencesController < Settings::BaseController :setting_use_blurhash, :setting_use_pending_items, :setting_trends, + :setting_crop_images, notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account trending_tag), interactions: %i(must_be_follower must_be_following must_be_following_dm) ) diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb index 8b640cdca..19a7ce157 100644 --- a/app/controllers/settings/profiles_controller.rb +++ b/app/controllers/settings/profiles_controller.rb @@ -1,16 +1,11 @@ # frozen_string_literal: true class Settings::ProfilesController < Settings::BaseController - include ObfuscateFilename - layout 'admin' before_action :authenticate_user! before_action :set_account - obfuscate_filename [:account, :avatar] - obfuscate_filename [:account, :header] - def show @account.build_fields end diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index 57bbeca64..4fa128303 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -46,7 +46,7 @@ class StatusesController < ApplicationController end def embed - raise ActiveRecord::RecordNotFound if @status.hidden? + return not_found if @status.hidden? expires_in 180, public: true response.headers['X-Frame-Options'] = 'ALLOWALL' @@ -68,7 +68,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 diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 77d5661b8..b0bc2f6b7 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -24,7 +24,7 @@ class TagsController < ApplicationController format.rss do expires_in 0, public: true - @statuses = HashtagQueryService.new.call(@tag, params.slice(:any, :all, :none)).limit(PAGE_SIZE) + @statuses = HashtagQueryService.new.call(@tag, filter_params).limit(PAGE_SIZE) @statuses = cache_collection(@statuses, Status) render xml: RSS::TagSerializer.render(@tag, @statuses) @@ -33,7 +33,7 @@ class TagsController < ApplicationController format.json do expires_in 3.minutes, public: public_fetch_mode? - @statuses = HashtagQueryService.new.call(@tag, params.slice(:any, :all, :none), current_account, params[:local]).paginate_by_max_id(PAGE_SIZE, params[:max_id]) + @statuses = HashtagQueryService.new.call(@tag, filter_params, current_account, params[:local]).paginate_by_max_id(PAGE_SIZE, params[:max_id]) @statuses = cache_collection(@statuses, Status) render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' @@ -57,10 +57,14 @@ class TagsController < ApplicationController def collection_presenter ActivityPub::CollectionPresenter.new( - id: tag_url(@tag, params.slice(:any, :all, :none)), + id: tag_url(@tag, filter_params), type: :ordered, size: @tag.statuses.count, items: @statuses.map { |s| ActivityPub::TagManager.instance.uri_for(s) } ) end + + def filter_params + params.slice(:any, :all, :none).permit(:any, :all, :none) + end end diff --git a/app/controllers/well_known/host_meta_controller.rb b/app/controllers/well_known/host_meta_controller.rb index 2e9298c4a..2fd6bc7cc 100644 --- a/app/controllers/well_known/host_meta_controller.rb +++ b/app/controllers/well_known/host_meta_controller.rb @@ -8,12 +8,8 @@ module WellKnown def show @webfinger_template = "#{webfinger_url}?resource={uri}" - - respond_to do |format| - format.xml { render content_type: 'application/xrd+xml' } - end - expires_in 3.days, public: true + render content_type: 'application/xrd+xml', formats: [:xml] end end end diff --git a/app/helpers/accounts_helper.rb b/app/helpers/accounts_helper.rb new file mode 100644 index 000000000..134217734 --- /dev/null +++ b/app/helpers/accounts_helper.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +module AccountsHelper + def display_name(account, **options) + if options[:custom_emojify] + Formatter.instance.format_display_name(account, **options) + else + account.display_name.presence || account.username + end + end + + def acct(account) + if account.local? + "@#{account.acct}@#{site_hostname}" + else + "@#{account.pretty_acct}" + end + end + + def account_action_button(account) + if user_signed_in? + if account.id == current_user.account_id + link_to settings_profile_url, class: 'button logo-button' do + safe_join([svg_logo, t('settings.edit_profile')]) + end + elsif current_account.following?(account) || current_account.requested?(account) + link_to account_unfollow_path(account), class: 'button logo-button button--destructive', data: { method: :post } do + safe_join([svg_logo, t('accounts.unfollow')]) + end + elsif !(account.memorial? || account.moved?) + link_to account_follow_path(account), class: "button logo-button#{account.blocking?(current_account) ? ' disabled' : ''}", data: { method: :post } do + safe_join([svg_logo, t('accounts.follow')]) + end + end + elsif !(account.memorial? || account.moved?) + link_to account_remote_follow_path(account), class: 'button logo-button modal-button', target: '_new' do + safe_join([svg_logo, t('accounts.follow')]) + end + end + end + + def minimal_account_action_button(account) + if user_signed_in? + return if account.id == current_user.account_id + + if current_account.following?(account) || current_account.requested?(account) + link_to account_unfollow_path(account), class: 'icon-button active', data: { method: :post }, title: t('accounts.unfollow') do + fa_icon('user-times fw') + end + elsif !(account.memorial? || account.moved?) + link_to account_follow_path(account), class: "icon-button#{account.blocking?(current_account) ? ' disabled' : ''}", data: { method: :post }, title: t('accounts.follow') do + fa_icon('user-plus fw') + end + end + elsif !(account.memorial? || account.moved?) + link_to account_remote_follow_path(account), class: 'icon-button modal-button', target: '_new', title: t('accounts.follow') do + fa_icon('user-plus fw') + end + end + end + + def account_badge(account, all: false) + if account.bot? + content_tag(:div, content_tag(:div, t('accounts.roles.bot'), class: 'account-role bot'), class: 'roles') + elsif account.group? + content_tag(:div, content_tag(:div, t('accounts.roles.group'), class: 'account-role group'), class: 'roles') + elsif (Setting.show_staff_badge && account.user_staff?) || all + content_tag(:div, class: 'roles') do + if all && !account.user_staff? + content_tag(:div, t('admin.accounts.roles.user'), class: 'account-role') + elsif account.user_admin? + content_tag(:div, t('accounts.roles.admin'), class: 'account-role admin') + elsif account.user_moderator? + content_tag(:div, t('accounts.roles.moderator'), class: 'account-role moderator') + end + end + end + end + + def account_description(account) + prepend_str = [ + [ + number_to_human(account.statuses_count, strip_insignificant_zeros: true), + I18n.t('accounts.posts', count: account.statuses_count), + ].join(' '), + + [ + number_to_human(account.following_count, strip_insignificant_zeros: true), + I18n.t('accounts.following', count: account.following_count), + ].join(' '), + + [ + number_to_human(account.followers_count, strip_insignificant_zeros: true), + I18n.t('accounts.followers', count: account.followers_count), + ].join(' '), + ].join(', ') + + [prepend_str, account.note].join(' · ') + end + + def svg_logo + content_tag(:svg, tag(:use, 'xlink:href' => '#mastodon-svg-logo'), 'viewBox' => '0 0 216.4144 232.00976') + end + + def svg_logo_full + content_tag(:svg, tag(:use, 'xlink:href' => '#mastodon-svg-logo-full'), 'viewBox' => '0 0 713.35878 175.8678') + end +end diff --git a/app/helpers/admin/action_logs_helper.rb b/app/helpers/admin/action_logs_helper.rb index 1daa60774..6bc75aa56 100644 --- a/app/helpers/admin/action_logs_helper.rb +++ b/app/helpers/admin/action_logs_helper.rb @@ -22,6 +22,8 @@ module Admin::ActionLogsHelper log.recorded_changes.slice('severity', 'reject_media') 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 @@ -44,12 +46,16 @@ module Admin::ActionLogsHelper 'flag' when 'DomainBlock' 'lock' + when 'DomainAllow' + 'plus-circle' when 'EmailDomainBlock' 'envelope' when 'Status' 'pencil' when 'AccountWarning' 'warning' + when 'Announcement' + 'bullhorn' end end @@ -86,12 +92,14 @@ module Admin::ActionLogsHelper record.shortcode when 'Report' link_to "##{record.id}", admin_report_path(record) - when 'DomainBlock', 'EmailDomainBlock' + when 'DomainBlock', 'DomainAllow', 'EmailDomainBlock' link_to record.domain, "https://#{record.domain}" when 'Status' 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 @@ -99,7 +107,7 @@ module Admin::ActionLogsHelper case type when 'CustomEmoji' attributes['shortcode'] - when 'DomainBlock', 'EmailDomainBlock' + when 'DomainBlock', 'DomainAllow', 'EmailDomainBlock' link_to attributes['domain'], "https://#{attributes['domain']}" when 'Status' tmp_status = Status.new(attributes.except('reblogs_count', 'favourites_count')) @@ -109,6 +117,8 @@ module Admin::ActionLogsHelper else I18n.t('admin.action_logs.deleted_status') end + when 'Announcement' + "##{attributes['id']}" end end end diff --git a/app/helpers/admin/announcements_helper.rb b/app/helpers/admin/announcements_helper.rb new file mode 100644 index 000000000..0c053ddec --- /dev/null +++ b/app/helpers/admin/announcements_helper.rb @@ -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 diff --git a/app/helpers/admin/filter_helper.rb b/app/helpers/admin/filter_helper.rb index 8af1683e7..6ab92939d 100644 --- a/app/helpers/admin/filter_helper.rb +++ b/app/helpers/admin/filter_helper.rb @@ -1,15 +1,16 @@ # frozen_string_literal: true module Admin::FilterHelper - ACCOUNT_FILTERS = %i(local remote by_domain active pending silenced suspended username display_name email ip staff).freeze - REPORT_FILTERS = %i(resolved account_id target_account_id).freeze - INVITE_FILTER = %i(available expired).freeze - CUSTOM_EMOJI_FILTERS = %i(local remote by_domain shortcode).freeze - TAGS_FILTERS = %i(directory reviewed unreviewed pending_review popular active name).freeze - INSTANCES_FILTERS = %i(limited by_domain).freeze - FOLLOWERS_FILTERS = %i(relationship status by_domain activity order).freeze - - FILTERS = ACCOUNT_FILTERS + REPORT_FILTERS + INVITE_FILTER + CUSTOM_EMOJI_FILTERS + TAGS_FILTERS + INSTANCES_FILTERS + FOLLOWERS_FILTERS + FILTERS = [ + AccountFilter::KEYS, + CustomEmojiFilter::KEYS, + ReportFilter::KEYS, + TagFilter::KEYS, + InstanceFilter::KEYS, + InviteFilter::KEYS, + RelationshipFilter::KEYS, + AnnouncementFilter::KEYS, + ].flatten.freeze def filter_link_to(text, link_to_params, link_class_params = link_to_params) new_url = filtered_url_for(link_to_params) diff --git a/app/helpers/domain_control_helper.rb b/app/helpers/domain_control_helper.rb index 067b2c2cd..ac60cad29 100644 --- a/app/helpers/domain_control_helper.rb +++ b/app/helpers/domain_control_helper.rb @@ -6,7 +6,7 @@ module DomainControlHelper domain = begin if uri_or_domain.include?('://') - Addressable::URI.parse(uri_or_domain).domain + Addressable::URI.parse(uri_or_domain).host else uri_or_domain end diff --git a/app/helpers/routing_helper.rb b/app/helpers/routing_helper.rb index 998b7566f..fb24a1b28 100644 --- a/app/helpers/routing_helper.rb +++ b/app/helpers/routing_helper.rb @@ -13,13 +13,13 @@ module RoutingHelper end def full_asset_url(source, **options) - source = ActionController::Base.helpers.asset_url(source, options) unless use_storage? + source = ActionController::Base.helpers.asset_url(source, **options) unless use_storage? URI.join(root_url, source).to_s end def full_pack_url(source, **options) - full_asset_url(asset_pack_path(source, options)) + full_asset_url(asset_pack_path(source, **options)) end private diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index aa0a4d467..825aa974d 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -32,15 +32,19 @@ module SettingsHelper hy: 'Հայերեն', id: 'Bahasa Indonesia', io: 'Ido', + is: 'Íslenska', it: 'Italiano', ja: '日本語', ka: 'ქართული', + kab: 'Taqbaylit', kk: 'Қазақша', + kn: 'ಕನ್ನಡ', ko: '한국어', lt: 'Lietuvių', lv: 'Latviešu', mk: 'Македонски', ml: 'മലയാളം', + mr: 'मराठी', ms: 'Bahasa Melayu', nl: 'Nederlands', nn: 'Nynorsk', @@ -63,6 +67,7 @@ module SettingsHelper th: 'ไทย', tr: 'Türkçe', uk: 'Українська', + ur: 'اُردُو', 'zh-CN': '简体中文', 'zh-HK': '繁體中文(香港)', 'zh-TW': '繁體中文(臺灣)', diff --git a/app/helpers/statuses_helper.rb b/app/helpers/statuses_helper.rb index 8380b3c42..866a9902c 100644 --- a/app/helpers/statuses_helper.rb +++ b/app/helpers/statuses_helper.rb @@ -4,80 +4,6 @@ module StatusesHelper EMBEDDED_CONTROLLER = 'statuses' EMBEDDED_ACTION = 'embed' - def display_name(account, **options) - if options[:custom_emojify] - Formatter.instance.format_display_name(account, options) - else - account.display_name.presence || account.username - end - end - - def account_action_button(account) - if user_signed_in? - if account.id == current_user.account_id - link_to settings_profile_url, class: 'button logo-button' do - safe_join([svg_logo, t('settings.edit_profile')]) - end - elsif current_account.following?(account) || current_account.requested?(account) - link_to account_unfollow_path(account), class: 'button logo-button button--destructive', data: { method: :post } do - safe_join([svg_logo, t('accounts.unfollow')]) - end - elsif !(account.memorial? || account.moved?) - link_to account_follow_path(account), class: "button logo-button#{account.blocking?(current_account) ? ' disabled' : ''}", data: { method: :post } do - safe_join([svg_logo, t('accounts.follow')]) - end - end - elsif !(account.memorial? || account.moved?) - link_to account_remote_follow_path(account), class: 'button logo-button modal-button', target: '_new' do - safe_join([svg_logo, t('accounts.follow')]) - end - end - end - - def minimal_account_action_button(account) - if user_signed_in? - return if account.id == current_user.account_id - - if current_account.following?(account) || current_account.requested?(account) - link_to account_unfollow_path(account), class: 'icon-button active', data: { method: :post }, title: t('accounts.unfollow') do - fa_icon('user-times fw') - end - elsif !(account.memorial? || account.moved?) - link_to account_follow_path(account), class: "icon-button#{account.blocking?(current_account) ? ' disabled' : ''}", data: { method: :post }, title: t('accounts.follow') do - fa_icon('user-plus fw') - end - end - elsif !(account.memorial? || account.moved?) - link_to account_remote_follow_path(account), class: 'icon-button modal-button', target: '_new', title: t('accounts.follow') do - fa_icon('user-plus fw') - end - end - end - - def svg_logo - content_tag(:svg, tag(:use, 'xlink:href' => '#mastodon-svg-logo'), 'viewBox' => '0 0 216.4144 232.00976') - end - - def svg_logo_full - content_tag(:svg, tag(:use, 'xlink:href' => '#mastodon-svg-logo-full'), 'viewBox' => '0 0 713.35878 175.8678') - end - - def account_badge(account, all: false) - if account.bot? - content_tag(:div, content_tag(:div, t('accounts.roles.bot'), class: 'account-role bot'), class: 'roles') - elsif (Setting.show_staff_badge && account.user_staff?) || all - content_tag(:div, class: 'roles') do - if all && !account.user_staff? - content_tag(:div, t('admin.accounts.roles.user'), class: 'account-role') - elsif account.user_admin? - content_tag(:div, t('accounts.roles.admin'), class: 'account-role admin') - elsif account.user_moderator? - content_tag(:div, t('accounts.roles.moderator'), class: 'account-role moderator') - end - end - end - end - def link_to_more(url) link_to t('statuses.show_more'), url, class: 'load-more load-gap' end @@ -88,27 +14,6 @@ module StatusesHelper end end - def account_description(account) - prepend_str = [ - [ - number_to_human(account.statuses_count, strip_insignificant_zeros: true), - I18n.t('accounts.posts', count: account.statuses_count), - ].join(' '), - - [ - number_to_human(account.following_count, strip_insignificant_zeros: true), - I18n.t('accounts.following', count: account.following_count), - ].join(' '), - - [ - number_to_human(account.followers_count, strip_insignificant_zeros: true), - I18n.t('accounts.followers', count: account.followers_count), - ].join(' '), - ].join(', ') - - [prepend_str, account.note].join(' · ') - end - def media_summary(status) attachments = { image: 0, video: 0 } @@ -154,14 +59,6 @@ module StatusesHelper embedded_view? ? '_blank' : nil end - def acct(account) - if account.local? - "@#{account.acct}@#{Rails.configuration.x.local_domain}" - else - "@#{account.acct}" - end - end - def style_classes(status, is_predecessor, is_successor, include_threads) classes = ['entry'] classes << 'entry-predecessor' if is_predecessor diff --git a/app/javascript/images/elephant_ui_plane.svg b/app/javascript/images/elephant_ui_plane.svg index a2624d170..ca675c9eb 100644 --- a/app/javascript/images/elephant_ui_plane.svg +++ b/app/javascript/images/elephant_ui_plane.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/app/javascript/mastodon/actions/announcements.js b/app/javascript/mastodon/actions/announcements.js new file mode 100644 index 000000000..1bdea909f --- /dev/null +++ b/app/javascript/mastodon/actions/announcements.js @@ -0,0 +1,180 @@ +import api from '../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, +}); diff --git a/app/javascript/mastodon/actions/bookmarks.js b/app/javascript/mastodon/actions/bookmarks.js new file mode 100644 index 000000000..544ed2ff2 --- /dev/null +++ b/app/javascript/mastodon/actions/bookmarks.js @@ -0,0 +1,90 @@ +import api, { getLinks } from '../api'; +import { importFetchedStatuses } from './importer'; + +export const BOOKMARKED_STATUSES_FETCH_REQUEST = 'BOOKMARKED_STATUSES_FETCH_REQUEST'; +export const BOOKMARKED_STATUSES_FETCH_SUCCESS = 'BOOKMARKED_STATUSES_FETCH_SUCCESS'; +export const BOOKMARKED_STATUSES_FETCH_FAIL = 'BOOKMARKED_STATUSES_FETCH_FAIL'; + +export const BOOKMARKED_STATUSES_EXPAND_REQUEST = 'BOOKMARKED_STATUSES_EXPAND_REQUEST'; +export const BOOKMARKED_STATUSES_EXPAND_SUCCESS = 'BOOKMARKED_STATUSES_EXPAND_SUCCESS'; +export const BOOKMARKED_STATUSES_EXPAND_FAIL = 'BOOKMARKED_STATUSES_EXPAND_FAIL'; + +export function fetchBookmarkedStatuses() { + return (dispatch, getState) => { + if (getState().getIn(['status_lists', 'bookmarks', 'isLoading'])) { + return; + } + + dispatch(fetchBookmarkedStatusesRequest()); + + api(getState).get('/api/v1/bookmarks').then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); + dispatch(fetchBookmarkedStatusesSuccess(response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(fetchBookmarkedStatusesFail(error)); + }); + }; +}; + +export function fetchBookmarkedStatusesRequest() { + return { + type: BOOKMARKED_STATUSES_FETCH_REQUEST, + }; +}; + +export function fetchBookmarkedStatusesSuccess(statuses, next) { + return { + type: BOOKMARKED_STATUSES_FETCH_SUCCESS, + statuses, + next, + }; +}; + +export function fetchBookmarkedStatusesFail(error) { + return { + type: BOOKMARKED_STATUSES_FETCH_FAIL, + error, + }; +}; + +export function expandBookmarkedStatuses() { + return (dispatch, getState) => { + const url = getState().getIn(['status_lists', 'bookmarks', 'next'], null); + + if (url === null || getState().getIn(['status_lists', 'bookmarks', 'isLoading'])) { + return; + } + + dispatch(expandBookmarkedStatusesRequest()); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); + dispatch(expandBookmarkedStatusesSuccess(response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(expandBookmarkedStatusesFail(error)); + }); + }; +}; + +export function expandBookmarkedStatusesRequest() { + return { + type: BOOKMARKED_STATUSES_EXPAND_REQUEST, + }; +}; + +export function expandBookmarkedStatusesSuccess(statuses, next) { + return { + type: BOOKMARKED_STATUSES_EXPAND_SUCCESS, + statuses, + next, + }; +}; + +export function expandBookmarkedStatusesFail(error) { + return { + type: BOOKMARKED_STATUSES_EXPAND_FAIL, + error, + }; +}; diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 8e7906c73..c3c6ff1a1 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -205,10 +205,11 @@ export function uploadCompose(files) { return function (dispatch, getState) { const uploadLimit = 4; const media = getState().getIn(['compose', 'media_attachments']); + const pending = getState().getIn(['compose', 'pending_media_attachments']); const progress = new Array(files.length).fill(0); let total = Array.from(files).reduce((a, v) => a + v.size, 0); - if (files.length + media.size > uploadLimit) { + if (files.length + media.size + pending > uploadLimit) { dispatch(showAlert(undefined, messages.uploadErrorLimit)); return; } diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js index f7108fdb9..f7cbe4c1c 100644 --- a/app/javascript/mastodon/actions/importer/normalizer.js +++ b/app/javascript/mastodon/actions/importer/normalizer.js @@ -10,6 +10,12 @@ const makeEmojiMap = record => record.emojis.reduce((obj, emoji) => { return obj; }, {}); +export function searchTextFromRawStatus (status) { + const spoilerText = status.spoiler_text || ''; + const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).join('\n\n').replace(//g, '\n').replace(/<\/p>

/g, '\n\n'); + return domParser.parseFromString(searchContent, 'text/html').documentElement.textContent; +} + export function normalizeAccount(account) { account = { ...account }; @@ -70,7 +76,6 @@ export function normalizeStatus(status, normalOldStatus) { export function normalizePoll(poll) { const normalPoll = { ...poll }; - const emojiMap = makeEmojiMap(normalPoll); normalPoll.options = poll.options.map((option, index) => ({ @@ -81,3 +86,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; +} diff --git a/app/javascript/mastodon/actions/interactions.js b/app/javascript/mastodon/actions/interactions.js index 2dc4c574c..28c6b1a62 100644 --- a/app/javascript/mastodon/actions/interactions.js +++ b/app/javascript/mastodon/actions/interactions.js @@ -33,6 +33,14 @@ export const UNPIN_REQUEST = 'UNPIN_REQUEST'; export const UNPIN_SUCCESS = 'UNPIN_SUCCESS'; export const UNPIN_FAIL = 'UNPIN_FAIL'; +export const BOOKMARK_REQUEST = 'BOOKMARK_REQUEST'; +export const BOOKMARK_SUCCESS = 'BOOKMARKED_SUCCESS'; +export const BOOKMARK_FAIL = 'BOOKMARKED_FAIL'; + +export const UNBOOKMARK_REQUEST = 'UNBOOKMARKED_REQUEST'; +export const UNBOOKMARK_SUCCESS = 'UNBOOKMARKED_SUCCESS'; +export const UNBOOKMARK_FAIL = 'UNBOOKMARKED_FAIL'; + export function reblog(status) { return function (dispatch, getState) { dispatch(reblogRequest(status)); @@ -187,6 +195,78 @@ export function unfavouriteFail(status, error) { }; }; +export function bookmark(status) { + return function (dispatch, getState) { + dispatch(bookmarkRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.get('id')}/bookmark`).then(function (response) { + dispatch(importFetchedStatus(response.data)); + dispatch(bookmarkSuccess(status, response.data)); + }).catch(function (error) { + dispatch(bookmarkFail(status, error)); + }); + }; +}; + +export function unbookmark(status) { + return (dispatch, getState) => { + dispatch(unbookmarkRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.get('id')}/unbookmark`).then(response => { + dispatch(importFetchedStatus(response.data)); + dispatch(unbookmarkSuccess(status, response.data)); + }).catch(error => { + dispatch(unbookmarkFail(status, error)); + }); + }; +}; + +export function bookmarkRequest(status) { + return { + type: BOOKMARK_REQUEST, + status: status, + }; +}; + +export function bookmarkSuccess(status, response) { + return { + type: BOOKMARK_SUCCESS, + status: status, + response: response, + }; +}; + +export function bookmarkFail(status, error) { + return { + type: BOOKMARK_FAIL, + status: status, + error: error, + }; +}; + +export function unbookmarkRequest(status) { + return { + type: UNBOOKMARK_REQUEST, + status: status, + }; +}; + +export function unbookmarkSuccess(status, response) { + return { + type: UNBOOKMARK_SUCCESS, + status: status, + response: response, + }; +}; + +export function unbookmarkFail(status, error) { + return { + type: UNBOOKMARK_FAIL, + status: status, + error: error, + }; +}; + export function fetchReblogs(id) { return (dispatch, getState) => { dispatch(fetchReblogsRequest(id)); diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js index 58803d1ae..8a066b896 100644 --- a/app/javascript/mastodon/actions/notifications.js +++ b/app/javascript/mastodon/actions/notifications.js @@ -14,6 +14,7 @@ import { unescapeHTML } from '../utils/html'; import { getFiltersRegex } from '../selectors'; import { usePendingItems as preferPendingItems } from 'mastodon/initial_state'; import compareId from 'mastodon/compare_id'; +import { searchTextFromRawStatus } from 'mastodon/actions/importer/normalizer'; export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE'; export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP'; @@ -60,7 +61,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) { if (notification.type === 'mention') { const dropRegex = filters[0]; const regex = filters[1]; - const searchIndex = notification.status.spoiler_text + '\n' + unescapeHTML(notification.status.content); + const searchIndex = searchTextFromRawStatus(notification.status); if (dropRegex && dropRegex.test(searchIndex)) { return; @@ -109,7 +110,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) { const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS(); const excludeTypesFromFilter = filter => { - const allTypes = ImmutableList(['follow', 'favourite', 'reblog', 'mention', 'poll']); + const allTypes = ImmutableList(['follow', 'follow_request', 'favourite', 'reblog', 'mention', 'poll']); return allTypes.filterNot(item => item === filter).toJS(); }; @@ -156,9 +157,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(); }); }; @@ -187,6 +188,7 @@ export function expandNotificationsFail(error, isLoadingMore) { type: NOTIFICATIONS_EXPAND_FAIL, error, skipLoading: !isLoadingMore, + skipAlert: !isLoadingMore, }; }; diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js index 06a19afc3..5640201c6 100644 --- a/app/javascript/mastodon/actions/statuses.js +++ b/app/javascript/mastodon/actions/statuses.js @@ -26,8 +26,9 @@ export const STATUS_UNMUTE_REQUEST = 'STATUS_UNMUTE_REQUEST'; export const STATUS_UNMUTE_SUCCESS = 'STATUS_UNMUTE_SUCCESS'; export const STATUS_UNMUTE_FAIL = 'STATUS_UNMUTE_FAIL'; -export const STATUS_REVEAL = 'STATUS_REVEAL'; -export const STATUS_HIDE = 'STATUS_HIDE'; +export const STATUS_REVEAL = 'STATUS_REVEAL'; +export const STATUS_HIDE = 'STATUS_HIDE'; +export const STATUS_COLLAPSE = 'STATUS_COLLAPSE'; export const REDRAFT = 'REDRAFT'; @@ -320,3 +321,11 @@ export function revealStatus(ids) { ids, }; }; + +export function toggleStatusCollapse(id, isCollapsed) { + return { + type: STATUS_COLLAPSE, + id, + isCollapsed, + }; +} diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js index c678e9393..79b08bdda 100644 --- a/app/javascript/mastodon/actions/streaming.js +++ b/app/javascript/mastodon/actions/streaming.js @@ -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 '../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); diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index bc2ac5e82..054668655 100644 --- a/app/javascript/mastodon/actions/timelines.js +++ b/app/javascript/mastodon/actions/timelines.js @@ -98,9 +98,9 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) { const next = getLinks(response).refs.find(link => link.rel === 'next'); 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(); }); }; diff --git a/app/javascript/mastodon/components/animated_number.js b/app/javascript/mastodon/components/animated_number.js new file mode 100644 index 000000000..f3127c88e --- /dev/null +++ b/app/javascript/mastodon/components/animated_number.js @@ -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 'mastodon/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 ; + } + + const styles = [{ + key: `${value}`, + data: value, + style: { y: spring(0, { damping: 35, stiffness: 400 }) }, + }]; + + return ( + + {items => ( + + {items.map(({ key, data, style }) => ( + 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}> + ))} + + )} + + ); + } + +} diff --git a/app/javascript/mastodon/components/attachment_list.js b/app/javascript/mastodon/components/attachment_list.js index 5dfa1464c..ebd696583 100644 --- a/app/javascript/mastodon/components/attachment_list.js +++ b/app/javascript/mastodon/components/attachment_list.js @@ -25,7 +25,7 @@ export default class AttachmentList extends ImmutablePureComponent { return (

  • - {filename(displayUrl)} + {filename(displayUrl)}
  • ); })} @@ -46,7 +46,7 @@ export default class AttachmentList extends ImmutablePureComponent { return (
  • - {filename(displayUrl)} + {filename(displayUrl)}
  • ); })} diff --git a/app/javascript/mastodon/components/column_header.js b/app/javascript/mastodon/components/column_header.js index 0038995c8..ea82f9ef9 100644 --- a/app/javascript/mastodon/components/column_header.js +++ b/app/javascript/mastodon/components/column_header.js @@ -33,6 +33,7 @@ class ColumnHeader extends React.PureComponent { onPin: PropTypes.func, onMove: PropTypes.func, onClick: PropTypes.func, + appendContent: PropTypes.node, }; state = { @@ -81,7 +82,7 @@ class ColumnHeader extends React.PureComponent { } render () { - const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder } = this.props; + 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', { @@ -172,6 +173,8 @@ class ColumnHeader extends React.PureComponent { {(!collapsed || animating) && collapsedContent} + + {appendContent} ); diff --git a/app/javascript/mastodon/components/dropdown_menu.js b/app/javascript/mastodon/components/dropdown_menu.js index d423378c1..a4f262285 100644 --- a/app/javascript/mastodon/components/dropdown_menu.js +++ b/app/javascript/mastodon/components/dropdown_menu.js @@ -143,7 +143,7 @@ class DropdownMenu extends React.PureComponent { return (
  • - + {text}
  • diff --git a/app/javascript/mastodon/components/error_boundary.js b/app/javascript/mastodon/components/error_boundary.js index 82543e118..4e1c882e2 100644 --- a/app/javascript/mastodon/components/error_boundary.js +++ b/app/javascript/mastodon/components/error_boundary.js @@ -58,7 +58,7 @@ export default class ErrorBoundary extends React.PureComponent {

    -

    Mastodon v{version} · ·

    +

    Mastodon v{version} · ·

    ); diff --git a/app/javascript/mastodon/components/icon_button.js b/app/javascript/mastodon/components/icon_button.js index 401675052..fd715bc3c 100644 --- a/app/javascript/mastodon/components/icon_button.js +++ b/app/javascript/mastodon/components/icon_button.js @@ -1,6 +1,4 @@ import React from 'react'; -import Motion from '../features/ui/util/optional_motion'; -import spring from 'react-motion/lib/spring'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import Icon from 'mastodon/components/icon'; @@ -37,6 +35,21 @@ export default class IconButton extends React.PureComponent { tabIndex: '0', }; + state = { + activate: false, + deactivate: false, + } + + componentWillReceiveProps (nextProps) { + if (!nextProps.animate) return; + + if (this.props.active && !nextProps.active) { + this.setState({ activate: false, deactivate: true }); + } else if (!this.props.active && nextProps.active) { + this.setState({ activate: true, deactivate: false }); + } + } + handleClick = (e) => { e.preventDefault(); @@ -75,7 +88,6 @@ export default class IconButton extends React.PureComponent { const { active, - animate, className, disabled, expanded, @@ -87,57 +99,37 @@ export default class IconButton extends React.PureComponent { title, } = this.props; + const { + activate, + deactivate, + } = this.state; + const classes = classNames(className, 'icon-button', { active, disabled, inverted, + activate, + deactivate, overlayed: overlay, }); - if (!animate) { - // Perf optimization: avoid unnecessary components unless - // we actually need to animate. - return ( - - ); - } - return ( - - {({ rotate }) => ( - - )} - + ); } diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js index 1bedfff69..31c2d67d5 100644 --- a/app/javascript/mastodon/components/media_gallery.js +++ b/app/javascript/mastodon/components/media_gallery.js @@ -6,7 +6,7 @@ import IconButton from './icon_button'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { isIOS } from '../is_mobile'; import classNames from 'classnames'; -import { autoPlayGif, displayMedia, useBlurhash } from '../initial_state'; +import { autoPlayGif, cropImages, displayMedia, useBlurhash } from '../initial_state'; import { decode } from 'blurhash'; const messages = defineMessages({ @@ -23,6 +23,7 @@ class Item extends React.PureComponent { onClick: PropTypes.func.isRequired, displayWidth: PropTypes.number, visible: PropTypes.bool.isRequired, + autoplay: PropTypes.bool, }; static defaultProps = { @@ -48,9 +49,13 @@ class Item extends React.PureComponent { } } + getAutoPlay() { + return this.props.autoplay || autoPlayGif; + } + hoverToPlay () { const { attachment } = this.props; - return !autoPlayGif && attachment.get('type') === 'gifv'; + return !this.getAutoPlay() && attachment.get('type') === 'gifv'; } handleClick = (e) => { @@ -159,7 +164,7 @@ class Item extends React.PureComponent { if (attachment.get('type') === 'unknown') { return (
    - +
    @@ -187,6 +192,7 @@ class Item extends React.PureComponent { href={attachment.get('remote_url') || originalUrl} onClick={this.handleClick} target='_blank' + rel='noopener noreferrer' > ); } else if (attachment.get('type') === 'gifv') { - const autoPlay = !isIOS() && autoPlayGif; + const autoPlay = !isIOS() && this.getAutoPlay(); thumbnail = (
    @@ -247,6 +253,7 @@ class MediaGallery extends React.PureComponent { defaultWidth: PropTypes.number, cacheWidth: PropTypes.func, visible: PropTypes.bool, + autoplay: PropTypes.bool, onToggleVisibility: PropTypes.func, }; @@ -280,7 +287,7 @@ class MediaGallery extends React.PureComponent { } handleRef = (node) => { - if (node /*&& this.isStandaloneEligible()*/) { + if (node) { // offsetWidth triggers a layout, so only calculate when we need to if (this.props.cacheWidth) this.props.cacheWidth(node.offsetWidth); this.setState({ @@ -289,13 +296,13 @@ class MediaGallery extends React.PureComponent { } } - isStandaloneEligible() { - const { media, standalone } = this.props; - return standalone && media.size === 1 && media.getIn([0, 'meta', 'small', 'aspect']); + isFullSizeEligible() { + const { media } = this.props; + return media.size === 1 && media.getIn([0, 'meta', 'small', 'aspect']); } render () { - const { media, intl, sensitive, height, defaultWidth } = this.props; + const { media, intl, sensitive, height, defaultWidth, standalone, autoplay } = this.props; const { visible } = this.state; const width = this.state.width || defaultWidth; @@ -304,7 +311,7 @@ class MediaGallery extends React.PureComponent { const style = {}; - if (this.isStandaloneEligible()) { + if (this.isFullSizeEligible() && (standalone || !cropImages)) { if (width) { style.height = width / this.props.media.getIn([0, 'meta', 'small', 'aspect']); } @@ -317,10 +324,10 @@ class MediaGallery extends React.PureComponent { const size = media.take(4).size; const uncached = media.every(attachment => attachment.get('type') === 'unknown'); - if (this.isStandaloneEligible()) { - children = ; + if (standalone && this.isFullSizeEligible()) { + children = ; } else { - children = media.take(4).map((attachment, i) => ); + children = media.take(4).map((attachment, i) => ); } if (uncached) { diff --git a/app/javascript/mastodon/components/modal_root.js b/app/javascript/mastodon/components/modal_root.js index 5d4f4bbe1..fa4e59192 100644 --- a/app/javascript/mastodon/components/modal_root.js +++ b/app/javascript/mastodon/components/modal_root.js @@ -1,5 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; +import 'wicg-inert'; export default class ModalRoot extends React.PureComponent { @@ -55,15 +56,21 @@ export default class ModalRoot extends React.PureComponent { } else if (!nextProps.children) { this.setState({ revealed: false }); } - if (!nextProps.children && !!this.props.children) { - this.activeElement.focus(); - this.activeElement = null; - } } componentDidUpdate (prevProps) { if (!this.props.children && !!prevProps.children) { this.getSiblings().forEach(sibling => sibling.removeAttribute('inert')); + + // Because of the wicg-inert polyfill, the activeElement may not be + // immediately selectable, we have to wait for observers to run, as + // described in https://github.com/WICG/inert#performance-and-gotchas + Promise.resolve().then(() => { + this.activeElement.focus(); + this.activeElement = null; + }).catch((error) => { + console.error(error); + }); } if (this.props.children) { requestAnimationFrame(() => { diff --git a/app/javascript/mastodon/components/poll.js b/app/javascript/mastodon/components/poll.js index cdbcf8f70..3a17e80e7 100644 --- a/app/javascript/mastodon/components/poll.js +++ b/app/javascript/mastodon/components/poll.js @@ -39,7 +39,8 @@ class Poll extends ImmutablePureComponent { static getDerivedStateFromProps (props, state) { const { poll, intl } = props; - const expired = poll.get('expired') || (new Date(poll.get('expires_at'))).getTime() < intl.now(); + const expires_at = poll.get('expires_at'); + const expired = poll.get('expired') || expires_at !== null && (new Date(expires_at)).getTime() < intl.now(); return (expired === state.expired) ? null : { expired }; } @@ -66,9 +67,7 @@ class Poll extends ImmutablePureComponent { } } - handleOptionChange = e => { - const { target: { value } } = e; - + _toggleOption = value => { if (this.props.poll.get('multiple')) { const tmp = { ...this.state.selected }; if (tmp[value]) { @@ -82,8 +81,20 @@ class Poll extends ImmutablePureComponent { tmp[value] = true; this.setState({ selected: tmp }); } + } + + handleOptionChange = ({ target: { value } }) => { + this._toggleOption(value); }; + handleOptionKeyPress = (e) => { + if (e.key === 'Enter' || e.key === ' ') { + this._toggleOption(e.target.getAttribute('data-index')); + e.stopPropagation(); + e.preventDefault(); + } + } + handleVote = () => { if (this.props.disabled) { return; @@ -134,7 +145,17 @@ class Poll extends ImmutablePureComponent { disabled={disabled} /> - {!showResults && } + {!showResults && ( + + )} {showResults && {!!voted && } {Math.round(percent)}% diff --git a/app/javascript/mastodon/components/relative_timestamp.js b/app/javascript/mastodon/components/relative_timestamp.js index aa4b73cfe..711181dcd 100644 --- a/app/javascript/mastodon/components/relative_timestamp.js +++ b/app/javascript/mastodon/components/relative_timestamp.js @@ -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 (
    ); - } else if (isLoading || childrenCount > 0 || hasMore || !emptyMessage) { + } else if (isLoading || childrenCount > 0 || numPending > 0 || hasMore || !emptyMessage) { scrollableArea = (
    diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index b5606aca5..0dc00cb98 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -76,6 +76,7 @@ class Status extends ImmutablePureComponent { onEmbed: PropTypes.func, onHeightChange: PropTypes.func, onToggleHidden: PropTypes.func, + onToggleCollapsed: PropTypes.func, muted: PropTypes.bool, hidden: PropTypes.bool, unread: PropTypes.bool, @@ -102,19 +103,6 @@ class Status extends ImmutablePureComponent { statusId: undefined, }; - // Track height changes we know about to compensate scrolling - componentDidMount () { - this.didShowCard = !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card'); - } - - getSnapshotBeforeUpdate () { - if (this.props.getScrollPosition) { - return this.props.getScrollPosition(); - } else { - return null; - } - } - static getDerivedStateFromProps(nextProps, prevState) { if (nextProps.status && nextProps.status.get('id') !== prevState.statusId) { return { @@ -126,32 +114,6 @@ class Status extends ImmutablePureComponent { } } - // Compensate height changes - componentDidUpdate (prevProps, prevState, snapshot) { - const doShowCard = !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card'); - - if (doShowCard && !this.didShowCard) { - this.didShowCard = true; - - if (snapshot !== null && this.props.updateScrollBottom) { - if (this.node && this.node.offsetTop < snapshot.top) { - this.props.updateScrollBottom(snapshot.height - snapshot.top); - } - } - } - } - - componentWillUnmount() { - if (this.node && this.props.getScrollPosition) { - const position = this.props.getScrollPosition(); - if (position !== null && this.node.offsetTop < position.top) { - requestAnimationFrame(() => { - this.props.updateScrollBottom(position.height - position.top); - }); - } - } - } - handleToggleMediaVisibility = () => { this.setState({ showMedia: !this.state.showMedia }); } @@ -196,7 +158,11 @@ class Status extends ImmutablePureComponent { handleExpandedToggle = () => { this.props.onToggleHidden(this._properStatus()); - }; + } + + handleCollapsedToggle = isCollapsed => { + this.props.onToggleCollapsed(this._properStatus(), isCollapsed); + } renderLoadingMediaGallery () { return
    ; @@ -214,6 +180,23 @@ class Status extends ImmutablePureComponent { this.props.onOpenVideo(media, startTime); } + handleHotkeyOpenMedia = e => { + const { onOpenMedia, onOpenVideo } = this.props; + const status = this._properStatus(); + + e.preventDefault(); + + if (status.get('media_attachments').size > 0) { + if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { + // TODO: toggle play/paused? + } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { + onOpenVideo(status.getIn(['media_attachments', 0]), 0); + } else { + onOpenMedia(status.get('media_attachments'), 0); + } + } + } + handleHotkeyReply = e => { e.preventDefault(); this.props.onReply(this._properStatus(), this.context.router.history); @@ -293,6 +276,7 @@ class Status extends ImmutablePureComponent { moveDown: this.handleHotkeyMoveDown, toggleHidden: this.handleHotkeyToggleHidden, toggleSensitive: this.handleHotkeyToggleSensitive, + openMedia: this.handleHotkeyOpenMedia, }; if (hidden) { @@ -437,9 +421,9 @@ class Status extends ImmutablePureComponent {
    - + {media} diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js index 0bfbd8879..e2c8d43c9 100644 --- a/app/javascript/mastodon/components/status_action_bar.js +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -1,5 +1,6 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import IconButton from './icon_button'; import DropdownMenuContainer from '../containers/dropdown_menu_container'; @@ -23,6 +24,8 @@ const messages = defineMessages({ cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' }, cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, + bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' }, + removeBookmark: { id: 'status.remove_bookmark', defaultMessage: 'Remove bookmark' }, open: { id: 'status.open', defaultMessage: 'Expand this status' }, report: { id: 'status.report', defaultMessage: 'Report @{name}' }, muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' }, @@ -33,6 +36,10 @@ const messages = defineMessages({ admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' }, admin_status: { id: 'status.admin_status', defaultMessage: 'Open this status in the moderation interface' }, copy: { id: 'status.copy', defaultMessage: 'Copy link to status' }, + blockDomain: { id: 'account.block_domain', defaultMessage: 'Hide everything from {domain}' }, + unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' }, + unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, + unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, }); const obfuscatedCount = count => { @@ -45,7 +52,12 @@ const obfuscatedCount = count => { } }; -export default @injectIntl +const mapStateToProps = (state, { status }) => ({ + relationship: state.getIn(['relationships', status.getIn(['account', 'id'])]), +}); + +export default @connect(mapStateToProps) +@injectIntl class StatusActionBar extends ImmutablePureComponent { static contextTypes = { @@ -54,6 +66,7 @@ class StatusActionBar extends ImmutablePureComponent { static propTypes = { status: ImmutablePropTypes.map.isRequired, + relationship: ImmutablePropTypes.map, onReply: PropTypes.func, onFavourite: PropTypes.func, onReblog: PropTypes.func, @@ -61,11 +74,16 @@ class StatusActionBar extends ImmutablePureComponent { onDirect: PropTypes.func, onMention: PropTypes.func, onMute: PropTypes.func, + onUnmute: PropTypes.func, onBlock: PropTypes.func, + onUnblock: PropTypes.func, + onBlockDomain: PropTypes.func, + onUnblockDomain: PropTypes.func, onReport: PropTypes.func, onEmbed: PropTypes.func, onMuteConversation: PropTypes.func, onPin: PropTypes.func, + onBookmark: PropTypes.func, withDismiss: PropTypes.bool, intl: PropTypes.object.isRequired, }; @@ -74,6 +92,7 @@ class StatusActionBar extends ImmutablePureComponent { // evaluate to false. See react-immutable-pure-component for usage. updateOnProps = [ 'status', + 'relationship', 'withDismiss', ] @@ -114,6 +133,10 @@ class StatusActionBar extends ImmutablePureComponent { window.open(`/interact/${this.props.status.get('id')}?type=${type}`, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes'); } + handleBookmarkClick = () => { + this.props.onBookmark(this.props.status); + } + handleDeleteClick = () => { this.props.onDelete(this.props.status, this.context.router.history); } @@ -135,11 +158,39 @@ class StatusActionBar extends ImmutablePureComponent { } handleMuteClick = () => { - this.props.onMute(this.props.status.get('account')); + const { status, relationship, onMute, onUnmute } = this.props; + const account = status.get('account'); + + if (relationship && relationship.get('muting')) { + onUnmute(account); + } else { + onMute(account); + } } handleBlockClick = () => { - this.props.onBlock(this.props.status); + const { status, relationship, onBlock, onUnblock } = this.props; + const account = status.get('account'); + + if (relationship && relationship.get('blocking')) { + onUnblock(account); + } else { + onBlock(status); + } + } + + handleBlockDomain = () => { + const { status, onBlockDomain } = this.props; + const account = status.get('account'); + + onBlockDomain(account.get('acct').split('@')[1]); + } + + handleUnblockDomain = () => { + const { status, onUnblockDomain } = this.props; + const account = status.get('account'); + + onUnblockDomain(account.get('acct').split('@')[1]); } handleOpen = () => { @@ -178,11 +229,12 @@ class StatusActionBar extends ImmutablePureComponent { } render () { - const { status, intl, withDismiss } = this.props; + const { status, relationship, intl, withDismiss } = this.props; const mutingConversation = status.get('muted'); const anonymousAccess = !me; const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); + const account = status.get('account'); let menu = []; let reblogIcon = 'retweet'; @@ -196,6 +248,7 @@ class StatusActionBar extends ImmutablePureComponent { menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed }); } + menu.push({ text: intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark), action: this.handleBookmarkClick }); menu.push(null); if (status.getIn(['account', 'id']) === me || withDismiss) { @@ -215,16 +268,39 @@ class StatusActionBar extends ImmutablePureComponent { menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick }); } else { - menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); - menu.push({ text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }), action: this.handleDirectClick }); + menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.handleMentionClick }); + menu.push({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.handleDirectClick }); menu.push(null); - menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick }); - menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick }); - menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport }); + + if (relationship && relationship.get('muting')) { + menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.handleMuteClick }); + } else { + menu.push({ text: intl.formatMessage(messages.mute, { name: account.get('username') }), action: this.handleMuteClick }); + } + + if (relationship && relationship.get('blocking')) { + menu.push({ text: intl.formatMessage(messages.unblock, { name: account.get('username') }), action: this.handleBlockClick }); + } else { + menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.handleBlockClick }); + } + + menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.handleReport }); + + if (account.get('acct') !== account.get('username')) { + const domain = account.get('acct').split('@')[1]; + + menu.push(null); + + if (relationship && relationship.get('domain_blocking')) { + menu.push({ text: intl.formatMessage(messages.unblockDomain, { domain }), action: this.handleUnblockDomain }); + } else { + menu.push({ text: intl.formatMessage(messages.blockDomain, { domain }), action: this.handleBlockDomain }); + } + } if (isStaff) { menu.push(null); - menu.push({ text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }), href: `/admin/accounts/${status.getIn(['account', 'id'])}` }); + menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: `/admin/accounts/${status.getIn(['account', 'id'])}` }); menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses/${status.get('id')}` }); } } @@ -243,7 +319,7 @@ class StatusActionBar extends ImmutablePureComponent { replyTitle = intl.formatMessage(messages.replyAll); } - const shareButton = ('share' in navigator) && status.get('visibility') === 'public' && ( + const shareButton = ('share' in navigator) && publicStatus && ( ); diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js index 4ce9ec49f..5d921fd41 100644 --- a/app/javascript/mastodon/components/status_content.js +++ b/app/javascript/mastodon/components/status_content.js @@ -23,11 +23,11 @@ export default class StatusContent extends React.PureComponent { onExpandedToggle: PropTypes.func, onClick: PropTypes.func, collapsable: PropTypes.bool, + onCollapsedToggle: PropTypes.func, }; state = { hidden: true, - collapsed: null, // `collapsed: null` indicates that an element doesn't need collapsing, while `true` or `false` indicates that it does (and is/isn't). }; _updateStatusLinks () { @@ -59,17 +59,19 @@ export default class StatusContent extends React.PureComponent { } link.setAttribute('target', '_blank'); - link.setAttribute('rel', 'noopener'); + link.setAttribute('rel', 'noopener noreferrer'); } - if ( - this.props.collapsable - && this.props.onClick - && this.state.collapsed === null - && node.clientHeight > MAX_HEIGHT - && this.props.status.get('spoiler_text').length === 0 - ) { - this.setState({ collapsed: true }); + if (this.props.status.get('collapsed', null) === null) { + let collapsed = + this.props.collapsable + && this.props.onClick + && node.clientHeight > MAX_HEIGHT + && this.props.status.get('spoiler_text').length === 0; + + if(this.props.onCollapsedToggle) this.props.onCollapsedToggle(collapsed); + + this.props.status.set('collapsed', collapsed); } } @@ -178,6 +180,7 @@ export default class StatusContent extends React.PureComponent { } const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden; + const renderReadMore = this.props.onClick && status.get('collapsed'); const content = { __html: status.get('contentHtml') }; const spoilerContent = { __html: status.get('spoilerHtml') }; @@ -185,7 +188,7 @@ export default class StatusContent extends React.PureComponent { const classNames = classnames('status__content', { 'status__content--with-action': this.props.onClick && this.context.router, 'status__content--with-spoiler': status.get('spoiler_text').length > 0, - 'status__content--collapsed': this.state.collapsed === true, + 'status__content--collapsed': renderReadMore, }); if (isRtl(status.get('search_index'))) { @@ -237,7 +240,7 @@ export default class StatusContent extends React.PureComponent {
    , ]; - if (this.state.collapsed) { + if (renderReadMore) { output.push(readMoreButton); } diff --git a/app/javascript/mastodon/containers/dropdown_menu_container.js b/app/javascript/mastodon/containers/dropdown_menu_container.js index f79b19202..ab1823194 100644 --- a/app/javascript/mastodon/containers/dropdown_menu_container.js +++ b/app/javascript/mastodon/containers/dropdown_menu_container.js @@ -1,4 +1,5 @@ import { openDropdownMenu, closeDropdownMenu } from '../actions/dropdown_menu'; +import { fetchRelationships } from 'mastodon/actions/accounts'; import { openModal, closeModal } from '../actions/modal'; import { connect } from 'react-redux'; import DropdownMenu from '../components/dropdown_menu'; @@ -13,12 +14,17 @@ const mapStateToProps = state => ({ const mapDispatchToProps = (dispatch, { status, items }) => ({ onOpen(id, onItemClick, dropdownPlacement, keyboard) { + if (status) { + dispatch(fetchRelationships([status.getIn(['account', 'id'])])); + } + dispatch(isUserTouching() ? openModal('ACTIONS', { status, actions: items, onClick: onItemClick, }) : openDropdownMenu(id, dropdownPlacement, keyboard)); }, + onClose(id) { dispatch(closeModal('ACTIONS')); dispatch(closeDropdownMenu(id)); diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js index fb22676e0..2ba3a3123 100644 --- a/app/javascript/mastodon/containers/status_container.js +++ b/app/javascript/mastodon/containers/status_container.js @@ -1,3 +1,4 @@ +import React from 'react'; import { connect } from 'react-redux'; import Status from '../components/status'; import { makeGetStatus } from '../selectors'; @@ -9,8 +10,10 @@ import { import { reblog, favourite, + bookmark, unreblog, unfavourite, + unbookmark, pin, unpin, } from '../actions/interactions'; @@ -20,12 +23,21 @@ import { deleteStatus, hideStatus, revealStatus, + toggleStatusCollapse, } from '../actions/statuses'; +import { + unmuteAccount, + unblockAccount, +} from '../actions/accounts'; +import { + blockDomain, + unblockDomain, +} from '../actions/domain_blocks'; import { initMuteModal } from '../actions/mutes'; import { initBlockModal } from '../actions/blocks'; import { initReport } from '../actions/reports'; import { openModal } from '../actions/modal'; -import { defineMessages, injectIntl } from 'react-intl'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { boostModal, deleteModal } from '../initial_state'; import { showAlertForError } from '../actions/alerts'; @@ -36,6 +48,7 @@ const messages = defineMessages({ redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.' }, 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?' }, + blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' }, }); const makeMapStateToProps = () => { @@ -90,6 +103,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ } }, + onBookmark (status) { + if (status.get('bookmarked')) { + dispatch(unbookmark(status)); + } else { + dispatch(bookmark(status)); + } + }, + onPin (status) { if (status.get('pinned')) { dispatch(unpin(status)); @@ -138,6 +159,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ dispatch(initBlockModal(account)); }, + onUnblock (account) { + dispatch(unblockAccount(account.get('id'))); + }, + onReport (status) { dispatch(initReport(status.get('account'), status)); }, @@ -146,6 +171,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ dispatch(initMuteModal(account)); }, + onUnmute (account) { + dispatch(unmuteAccount(account.get('id'))); + }, + onMuteConversation (status) { if (status.get('muted')) { dispatch(unmuteStatus(status.get('id'))); @@ -162,6 +191,22 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ } }, + onToggleCollapsed (status, isCollapsed) { + dispatch(toggleStatusCollapse(status.get('id'), isCollapsed)); + }, + + onBlockDomain (domain) { + dispatch(openModal('CONFIRM', { + message: {domain} }} />, + confirm: intl.formatMessage(messages.blockDomainConfirm), + onConfirm: () => dispatch(blockDomain(domain)), + })); + }, + + onUnblockDomain (domain) { + dispatch(unblockDomain(domain)); + }, + }); export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status)); diff --git a/app/javascript/mastodon/extra_polyfills.js b/app/javascript/mastodon/extra_polyfills.js index 3acc55abd..13c4f6da9 100644 --- a/app/javascript/mastodon/extra_polyfills.js +++ b/app/javascript/mastodon/extra_polyfills.js @@ -1,5 +1,5 @@ import 'intersection-observer'; import 'requestidlecallback'; -import objectFitImages from 'object-fit-images'; +import objectFitImages from 'object-fit-images'; objectFitImages(); diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js index ac97bad71..8bd7f2db5 100644 --- a/app/javascript/mastodon/features/account/components/header.js +++ b/app/javascript/mastodon/features/account/components/header.js @@ -238,9 +238,18 @@ class Header extends ImmutablePureComponent { const content = { __html: account.get('note_emojified') }; const displayNameHtml = { __html: account.get('display_name_html') }; const fields = account.get('fields'); - const badge = account.get('bot') ? (
    ) : null; const acct = account.get('acct').indexOf('@') === -1 && domain ? `${account.get('acct')}@${domain}` : account.get('acct'); + let badge; + + if (account.get('bot')) { + badge = (
    ); + } else if (account.get('group')) { + badge = (
    ); + } else { + badge = null; + } + return (
    @@ -253,7 +262,7 @@ class Header extends ImmutablePureComponent {
    - + @@ -282,10 +291,10 @@ class Header extends ImmutablePureComponent {
    - + - +
    ))} diff --git a/app/javascript/mastodon/features/account_gallery/components/media_item.js b/app/javascript/mastodon/features/account_gallery/components/media_item.js index b6eec2243..617a45d16 100644 --- a/app/javascript/mastodon/features/account_gallery/components/media_item.js +++ b/app/javascript/mastodon/features/account_gallery/components/media_item.js @@ -1,12 +1,12 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; +import { decode } from 'blurhash'; +import classNames from 'classnames'; import Icon from 'mastodon/components/icon'; import { autoPlayGif, displayMedia } from 'mastodon/initial_state'; -import classNames from 'classnames'; -import { decode } from 'blurhash'; import { isIOS } from 'mastodon/is_mobile'; +import PropTypes from 'prop-types'; +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; export default class MediaItem extends ImmutablePureComponent { @@ -151,7 +151,7 @@ export default class MediaItem extends ImmutablePureComponent { return (
    - + {visible && thumbnail} {!visible && icon} diff --git a/app/javascript/mastodon/features/account_timeline/index.js b/app/javascript/mastodon/features/account_timeline/index.js index 8d0cbe5a1..37622d4c0 100644 --- a/app/javascript/mastodon/features/account_timeline/index.js +++ b/app/javascript/mastodon/features/account_timeline/index.js @@ -115,6 +115,7 @@ class AccountTimeline extends ImmutablePureComponent { shouldUpdateScroll={shouldUpdateScroll} emptyMessage={emptyMessage} bindToDocument={!multiColumn} + timelineId='account' /> ); diff --git a/app/javascript/mastodon/features/audio/index.js b/app/javascript/mastodon/features/audio/index.js index 95e5675f3..fda5a074f 100644 --- a/app/javascript/mastodon/features/audio/index.js +++ b/app/javascript/mastodon/features/audio/index.js @@ -12,6 +12,7 @@ const messages = defineMessages({ pause: { id: 'video.pause', defaultMessage: 'Pause' }, mute: { id: 'video.mute', defaultMessage: 'Mute sound' }, unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' }, + download: { id: 'video.download', defaultMessage: 'Download file' }, }); export default @injectIntl @@ -36,15 +37,14 @@ class Audio extends React.PureComponent { volume: 0.5, }; - // hard coded in components.scss - // any way to get ::before values programatically? - - volWidth = 50; - + // Hard coded in components.scss + // Any way to get ::before values programatically? + volWidth = 50; volOffset = 70; volHandleOffset = v => { const offset = v * this.volWidth + this.volOffset; + return (offset > 110) ? 110 : offset; } @@ -60,6 +60,8 @@ class Audio extends React.PureComponent { if (this.waveform) { this._updateWaveform(); } + + window.addEventListener('scroll', this.handleScroll); } componentDidUpdate (prevProps) { @@ -69,6 +71,8 @@ class Audio extends React.PureComponent { } componentWillUnmount () { + window.removeEventListener('scroll', this.handleScroll); + if (this.wavesurfer) { this.wavesurfer.destroy(); this.wavesurfer = null; @@ -127,16 +131,15 @@ class Audio extends React.PureComponent { this.loaded = true; } - this.wavesurfer.play(); - this.setState({ paused: false }); + this.setState({ paused: false }, () => this.wavesurfer.play()); } else { - this.wavesurfer.pause(); - this.setState({ paused: true }); + this.setState({ paused: true }, () => this.wavesurfer.pause()); } } toggleMute = () => { - this.wavesurfer.setMute(!this.state.muted); + const muted = !this.state.muted; + this.setState({ muted }, () => this.wavesurfer.setMute(muted)); } handleVolumeMouseDown = e => { @@ -175,6 +178,19 @@ class Audio extends React.PureComponent { } }, 60); + handleScroll = throttle(() => { + if (!this.waveform || !this.wavesurfer) { + return; + } + + const { top, height } = this.waveform.getBoundingClientRect(); + const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0); + + if (!this.state.paused && !inView) { + this.setState({ paused: true }, () => this.wavesurfer.pause()); + } + }, 150, { trailing: true }) + render () { const { height, intl, alt, editable } = this.props; const { paused, muted, volume, currentTime } = this.state; @@ -202,6 +218,7 @@ class Audio extends React.PureComponent {
    diff --git a/app/javascript/mastodon/features/bookmarked_statuses/index.js b/app/javascript/mastodon/features/bookmarked_statuses/index.js new file mode 100644 index 000000000..c37cb9176 --- /dev/null +++ b/app/javascript/mastodon/features/bookmarked_statuses/index.js @@ -0,0 +1,104 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from '../../actions/bookmarks'; +import Column from '../ui/components/column'; +import ColumnHeader from '../../components/column_header'; +import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; +import StatusList from '../../components/status_list'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { debounce } from 'lodash'; + +const messages = defineMessages({ + heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' }, +}); + +const mapStateToProps = state => ({ + statusIds: state.getIn(['status_lists', 'bookmarks', 'items']), + isLoading: state.getIn(['status_lists', 'bookmarks', 'isLoading'], true), + hasMore: !!state.getIn(['status_lists', 'bookmarks', 'next']), +}); + +export default @connect(mapStateToProps) +@injectIntl +class Bookmarks extends ImmutablePureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + shouldUpdateScroll: PropTypes.func, + statusIds: ImmutablePropTypes.list.isRequired, + intl: PropTypes.object.isRequired, + columnId: PropTypes.string, + multiColumn: PropTypes.bool, + hasMore: PropTypes.bool, + isLoading: PropTypes.bool, + }; + + componentWillMount () { + this.props.dispatch(fetchBookmarkedStatuses()); + } + + handlePin = () => { + const { columnId, dispatch } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('BOOKMARKS', {})); + } + } + + handleMove = (dir) => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + setRef = c => { + this.column = c; + } + + handleLoadMore = debounce(() => { + this.props.dispatch(expandBookmarkedStatuses()); + }, 300, { leading: true }) + + render () { + const { intl, shouldUpdateScroll, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props; + const pinned = !!columnId; + + const emptyMessage = ; + + return ( + + + + + + ); + } + +} diff --git a/app/javascript/mastodon/features/community_timeline/index.js b/app/javascript/mastodon/features/community_timeline/index.js index 30153cc15..b3cd39685 100644 --- a/app/javascript/mastodon/features/community_timeline/index.js +++ b/app/javascript/mastodon/features/community_timeline/index.js @@ -14,15 +14,16 @@ const messages = defineMessages({ title: { id: 'column.community', defaultMessage: 'Local timeline' }, }); -const mapStateToProps = (state, { onlyMedia, columnId }) => { +const mapStateToProps = (state, { columnId }) => { const uuid = columnId; const columns = state.getIn(['settings', 'columns']); const index = columns.findIndex(c => c.get('uuid') === uuid); + const onlyMedia = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'onlyMedia']) : state.getIn(['settings', 'community', 'other', 'onlyMedia']); const timelineState = state.getIn(['timelines', `community${onlyMedia ? ':media' : ''}`]); return { - hasUnread: !!timelineState && (timelineState.get('unread') > 0 || timelineState.get('pendingItems').size > 0), - onlyMedia: (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'onlyMedia']) : state.getIn(['settings', 'community', 'other', 'onlyMedia']), + hasUnread: !!timelineState && timelineState.get('unread') > 0, + onlyMedia, }; }; diff --git a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js index e57c3c20c..582bb0d39 100644 --- a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js +++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js @@ -290,6 +290,7 @@ class EmojiPickerDropdown extends React.PureComponent { onPickEmoji: PropTypes.func.isRequired, onSkinTone: PropTypes.func.isRequired, skinTone: PropTypes.number.isRequired, + button: PropTypes.node, }; state = { @@ -350,18 +351,18 @@ 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 (
    - 🙂 + />}
    diff --git a/app/javascript/mastodon/features/compose/components/poll_form.js b/app/javascript/mastodon/features/compose/components/poll_form.js index 211601d52..01df31d3a 100644 --- a/app/javascript/mastodon/features/compose/components/poll_form.js +++ b/app/javascript/mastodon/features/compose/components/poll_form.js @@ -13,6 +13,8 @@ const messages = defineMessages({ add_option: { id: 'compose_form.poll.add_option', defaultMessage: 'Add a choice' }, remove_option: { id: 'compose_form.poll.remove_option', defaultMessage: 'Remove this choice' }, poll_duration: { id: 'compose_form.poll.duration', defaultMessage: 'Poll duration' }, + switchToMultiple: { id: 'compose_form.poll.switch_to_multiple', defaultMessage: 'Change poll to allow multiple choices' }, + switchToSingle: { id: 'compose_form.poll.switch_to_single', defaultMessage: 'Change poll to allow for a single choice' }, minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' }, hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' }, days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' }, @@ -50,6 +52,12 @@ class Option extends React.PureComponent { e.stopPropagation(); }; + handleCheckboxKeypress = e => { + if (e.key === 'Enter' || e.key === ' ') { + this.handleToggleMultiple(e); + } + } + onSuggestionsClearRequested = () => { this.props.onClearSuggestions(); } @@ -71,8 +79,11 @@ class Option extends React.PureComponent {
    - {options.size < 4 && ( - - )} +