diff --git a/.circleci/config.yml b/.circleci/config.yml index ff8eb4859..9f43a0573 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -3,13 +3,15 @@ version: 2 aliases: - &defaults docker: - - image: circleci/ruby:2.6-stretch-node + - image: circleci/ruby:2.7-buster-node environment: &ruby_environment + BUNDLE_JOBS: 3 + BUNDLE_RETRY: 3 BUNDLE_APP_CONFIG: ./.bundle/ + BUNDLE_PATH: ./vendor/bundle/ DB_HOST: localhost DB_USER: root RAILS_ENV: test - PARALLEL_TEST_PROCESSORS: 4 ALLOW_NOPAM: true CONTINUOUS_INTEGRATION: true DISABLE_SIMPLECOV: true @@ -31,25 +33,25 @@ aliases: - &restore_ruby_dependencies restore_cache: keys: - - v2-ruby-dependencies-{{ checksum "/tmp/.ruby-version" }}-{{ checksum "Gemfile.lock" }} - - v2-ruby-dependencies-{{ checksum "/tmp/.ruby-version" }}- - - v2-ruby-dependencies- + - v3-ruby-dependencies-{{ checksum "/tmp/.ruby-version" }}-{{ checksum "Gemfile.lock" }} + - v3-ruby-dependencies-{{ checksum "/tmp/.ruby-version" }}- + - v3-ruby-dependencies- - &install_steps steps: - checkout - *attach_workspace - - restore_cache: keys: - - v1-node-dependencies-{{ checksum "yarn.lock" }} - - v1-node-dependencies- - - run: yarn install --frozen-lockfile + - v2-node-dependencies-{{ checksum "yarn.lock" }} + - v2-node-dependencies- + - run: + name: Install yarn dependencies + command: yarn install --frozen-lockfile - save_cache: - key: v1-node-dependencies-{{ checksum "yarn.lock" }} + key: v2-node-dependencies-{{ checksum "yarn.lock" }} paths: - ./node_modules/ - - *persist_to_workspace - &install_system_dependencies @@ -62,14 +64,24 @@ aliases: - &install_ruby_dependencies steps: - *attach_workspace - - *install_system_dependencies - - - run: ruby -e 'puts RUBY_VERSION' | tee /tmp/.ruby-version + - run: + name: Set Ruby version + command: 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: + name: Set bundler settings + command: | + bundle config clean 'true' + bundle config deployment 'true' + bundle config with 'pam_authentication' + bundle config without 'development production' + bundle config frozen 'true' + - run: + name: Install bundler dependencies + command: bundle check || (bundle install && bundle clean) - save_cache: - key: v2-ruby-dependencies-{{ checksum "/tmp/.ruby-version" }}-{{ checksum "Gemfile.lock" }} + key: v3-ruby-dependencies-{{ checksum "/tmp/.ruby-version" }}-{{ checksum "Gemfile.lock" }} paths: - ./.bundle/ - ./vendor/bundle/ @@ -80,39 +92,39 @@ aliases: - ./mastodon/vendor/bundle/ - &test_steps + parallelism: 4 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 + name: Install FFMPEG + command: sudo apt-get install -y ffmpeg - run: - name: Run Tests - command: ./bin/retry bundle exec parallel_test ./spec/ --group-by filesize --type rspec - + name: Load database schema + command: ./bin/rails db:create db:schema:load db:seed + - run: + name: Run rspec in parallel + command: | + bundle exec rspec --profile 10 \ + --format RspecJunitFormatter \ + --out test_results/rspec.xml \ + --format progress \ + $(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings) + - store_test_results: + path: test_results jobs: install: <<: *defaults <<: *install_steps + install-ruby2.7: + <<: *defaults + <<: *install_ruby_dependencies + install-ruby2.6: - <<: *defaults - <<: *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.6-buster-node environment: *ruby_environment <<: *install_ruby_dependencies @@ -121,98 +133,116 @@ jobs: steps: - *attach_workspace - *install_system_dependencies - - run: ./bin/rails assets:precompile + - run: + name: Precompile assets + command: ./bin/rails assets:precompile - persist_to_workspace: root: ~/projects/ paths: - ./mastodon/public/assets - ./mastodon/public/packs-test/ + test-migrations: + <<: *defaults + docker: + - image: circleci/ruby:2.7-buster-node + environment: *ruby_environment + - image: circleci/postgres:12.2 + environment: + POSTGRES_USER: root + POSTGRES_HOST_AUTH_METHOD: trust + - image: circleci/redis:5-alpine + steps: + - *attach_workspace + - *install_system_dependencies + - run: + name: Create database + command: ./bin/rails db:create + - run: + name: Run migrations + command: ./bin/rails db:migrate + + test-ruby2.7: + <<: *defaults + docker: + - image: circleci/ruby:2.7-buster-node + environment: *ruby_environment + - image: circleci/postgres:12.2 + environment: + POSTGRES_USER: root + POSTGRES_HOST_AUTH_METHOD: trust + - image: circleci/redis:5-alpine + <<: *test_steps + test-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: - POSTGRES_USER: root - - image: circleci/redis:5-alpine - <<: *test_steps - - 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 - environment: *ruby_environment - - image: circleci/postgres:10.6-alpine + - image: circleci/postgres:12.2 environment: POSTGRES_USER: root + POSTGRES_HOST_AUTH_METHOD: trust - image: circleci/redis:5-alpine <<: *test_steps test-webui: <<: *defaults docker: - - image: circleci/node:12.9-stretch + - image: circleci/node:12-buster steps: - *attach_workspace - - run: ./bin/retry yarn test:jest + - run: + name: Run jest + command: yarn test:jest check-i18n: <<: *defaults steps: - *attach_workspace - *install_system_dependencies - - run: bundle exec i18n-tasks check-normalized - - run: bundle exec i18n-tasks unused -l en - - run: bundle exec i18n-tasks check-consistent-interpolations - - run: bundle exec rake repo:check_locales_files + - run: + name: Check locale file normalization + command: bundle exec i18n-tasks check-normalized + - run: + name: Check for unused strings + command: bundle exec i18n-tasks unused -l en + - run: + name: Check for wrong string interpolations + command: bundle exec i18n-tasks check-consistent-interpolations + - run: + name: Check that all required locale files exist + command: bundle exec rake repo:check_locales_files workflows: version: 2 build-and-test: jobs: - install + - install-ruby2.7: + requires: + - install - install-ruby2.6: requires: - install - - 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 - build - - test-ruby2.5: - 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..d8d5c0ac7 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-82 sass-lint: enabled: true exclude_patterns: diff --git a/.dependabot/config.yml b/.dependabot/config.yml index 07929aa07..06df775c2 100644 --- a/.dependabot/config.yml +++ b/.dependabot/config.yml @@ -4,7 +4,25 @@ update_configs: - package_manager: "ruby:bundler" directory: "/" update_schedule: "weekly" + # Supported update schedule: live daily weekly monthly + version_requirement_updates: "auto" + # Supported version requirements: auto widen_ranges increase_versions increase_versions_if_necessary + allowed_updates: + - match: + dependency_type: "all" + # Supported dependency types: all indirect direct production development + update_type: "all" + # Supported update types: all security - package_manager: "javascript" directory: "/" update_schedule: "weekly" + # Supported update schedule: live daily weekly monthly + version_requirement_updates: "auto" + # Supported version requirements: auto widen_ranges increase_versions increase_versions_if_necessary + allowed_updates: + - match: + dependency_type: "all" + # Supported dependency types: all indirect direct production development + update_type: "all" + # Supported update types: all security 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 8a679044e..3ccb26183 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -33,7 +33,7 @@ LOCAL_DOMAIN=example.com # ALTERNATE_DOMAINS=example1.com,example2.com # Application secrets -# Generate each with the `RAILS_ENV=production bundle exec rake secret` task (`docker-compose run --rm web rake secret` if you use docker compose) +# Generate each with the `RAILS_ENV=production bundle exec rake secret` task (`docker-compose run --rm web bundle exec rake secret` if you use docker compose) SECRET_KEY_BASE= OTP_SECRET= @@ -42,7 +42,7 @@ OTP_SECRET= # You should only generate this once per instance. If you later decide to change it, all push subscription will # be invalidated, requiring the users to access the website again to resubscribe. # -# Generate with `RAILS_ENV=production bundle exec rake mastodon:webpush:generate_vapid_key` task (`docker-compose run --rm web rake mastodon:webpush:generate_vapid_key` if you use docker compose) +# Generate with `RAILS_ENV=production bundle exec rake mastodon:webpush:generate_vapid_key` task (`docker-compose run --rm web bundle exec rake mastodon:webpush:generate_vapid_key` if you use docker compose) # # For more information visit https://rossta.net/blog/using-the-web-push-api-with-vapid.html VAPID_PRIVATE_KEY= @@ -181,7 +181,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 @@ -225,8 +229,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= @@ -249,3 +253,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..ea61b2724 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 +*-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..3a11f7000 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -2,7 +2,7 @@ require: - rubocop-rails AllCops: - TargetRubyVersion: 2.3 + TargetRubyVersion: 2.4 Exclude: - 'spec/**/*' - 'db/**/*' @@ -46,7 +46,7 @@ Metrics/ClassLength: Metrics/CyclomaticComplexity: Max: 25 -Metrics/LineLength: +Layout/LineLength: AllowURI: true Enabled: false @@ -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/.ruby-version b/.ruby-version index 57cf282eb..338a5b5d8 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.6.5 +2.6.6 diff --git a/CHANGELOG.md b/CHANGELOG.md index b200747b1..7d0110936 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,341 @@ Changelog All notable changes to this project will be documented in this file. +## [v3.1.4] - 2020-05-14 +### Added + +- Add `vi` to available locales ([taicv](https://github.com/tootsuite/mastodon/pull/13542)) +- Add ability to remove identity proofs from account ([Gargron](https://github.com/tootsuite/mastodon/pull/13682)) +- Add ability to exclude local content from federated timeline ([noellabo](https://github.com/tootsuite/mastodon/pull/13504), [noellabo](https://github.com/tootsuite/mastodon/pull/13745)) + - Add `remote` param to `GET /api/v1/timelines/public` REST API + - Add `public/remote` / `public:remote` variants to streaming API + - "Remote only" option in federated timeline column settings in web UI +- Add ability to exclude remote content from hashtag timelines in web UI ([noellabo](https://github.com/tootsuite/mastodon/pull/13502)) + - No changes to REST API + - "Local only" option in hashtag column settings in web UI +- Add Capistrano tasks that reload the services after deploying ([berkes](https://github.com/tootsuite/mastodon/pull/12642)) +- Add `invites_enabled` attribute to `GET /api/v1/instance` in REST API ([ThibG](https://github.com/tootsuite/mastodon/pull/13501)) +- Add `tootctl emoji export` command ([lfuelling](https://github.com/tootsuite/mastodon/pull/13534)) +- Add separate cache directory for non-local uploads ([Gargron](https://github.com/tootsuite/mastodon/pull/12821), [Hanage999](https://github.com/tootsuite/mastodon/pull/13593), [mayaeh](https://github.com/tootsuite/mastodon/pull/13551)) + - Add `tootctl upgrade storage-schema` command to move old non-local uploads to the cache directory +- Add buttons to delete header and avatar from profile settings ([sternenseemann](https://github.com/tootsuite/mastodon/pull/13234)) +- Add emoji graphics and shortcodes from Twemoji 12.1.5 ([DeeUnderscore](https://github.com/tootsuite/mastodon/pull/13021)) + +### Changed + +- Change error message when trying to migrate to an account that does not have current account set as an alias to be more clear ([TheEvilSkeleton](https://github.com/tootsuite/mastodon/pull/13746)) +- Change delivery failure tracking to work with hostnames instead of URLs ([Gargron](https://github.com/tootsuite/mastodon/pull/13437), [noellabo](https://github.com/tootsuite/mastodon/pull/13481), [noellabo](https://github.com/tootsuite/mastodon/pull/13482), [noellabo](https://github.com/tootsuite/mastodon/pull/13535)) +- Change Content-Security-Policy to not need unsafe-inline style-src ([ThibG](https://github.com/tootsuite/mastodon/pull/13679), [ThibG](https://github.com/tootsuite/mastodon/pull/13692), [ThibG](https://github.com/tootsuite/mastodon/pull/13576), [ThibG](https://github.com/tootsuite/mastodon/pull/13575), [ThibG](https://github.com/tootsuite/mastodon/pull/13438)) +- Change how RSS items are titled and formatted ([ThibG](https://github.com/tootsuite/mastodon/pull/13592), [ykzts](https://github.com/tootsuite/mastodon/pull/13591)) + +### Fixed + +- Fix dropdown of muted and followed accounts offering option to hide boosts in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/13748)) +- Fix "You are already signed in" alert being shown at wrong times ([ThibG](https://github.com/tootsuite/mastodon/pull/13547)) +- Fix retrying of failed-to-download media files not actually working ([noellabo](https://github.com/tootsuite/mastodon/pull/13741)) +- Fix first poll option not being focused when adding a poll in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/13740)) +- Fix `sr` locale being selected over `sr-Latn` ([ThibG](https://github.com/tootsuite/mastodon/pull/13693)) +- Fix error within error when limiting backtrace to 3 lines ([Gargron](https://github.com/tootsuite/mastodon/pull/13120)) +- Fix `tootctl media remove-orphans` crashing on "Import" files ([ThibG](https://github.com/tootsuite/mastodon/pull/13685)) +- Fix regression in `tootctl media remove-orphans` ([Gargron](https://github.com/tootsuite/mastodon/pull/13405)) +- Fix old unique jobs digests not having been cleaned up ([Gargron](https://github.com/tootsuite/mastodon/pull/13683)) +- Fix own following/followers not showing muted users ([ThibG](https://github.com/tootsuite/mastodon/pull/13614)) +- Fix list of followed people ignoring sorting on Follows & Followers page ([taras2358](https://github.com/tootsuite/mastodon/pull/13676)) +- Fix wrong pgHero Content-Security-Policy when `CDN_HOST` is set ([ThibG](https://github.com/tootsuite/mastodon/pull/13595)) +- Fix needlessly deduplicating usernames on collisions with remote accounts when signing-up through SAML/CAS ([kaiyou](https://github.com/tootsuite/mastodon/pull/13581)) +- Fix page incorrectly scrolling when bringing up dropdown menus in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/13574)) +- Fix messed up z-index when NoScript blocks media/previews in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/13449)) +- Fix "See what's happening" page showing public instead of local timeline for logged-in users ([ThibG](https://github.com/tootsuite/mastodon/pull/13499)) +- Fix not being able to resolve public resources in development environment ([Gargron](https://github.com/tootsuite/mastodon/pull/13505)) +- Fix uninformative error message when uploading unsupported image files ([ThibG](https://github.com/tootsuite/mastodon/pull/13540)) +- Fix expanded video player issues in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/13541), [eai04191](https://github.com/tootsuite/mastodon/pull/13533)) +- Fix and refactor keyboard navigation in dropdown menus in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/13528)) +- Fix uploaded image orientation being messed up in some browsers in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/13493)) +- Fix actions log crash when displaying updates of deleted announcements in admin UI ([ThibG](https://github.com/tootsuite/mastodon/pull/13489)) +- Fix search not working due to proxy settings when using hidden services ([Gargron](https://github.com/tootsuite/mastodon/pull/13488)) +- Fix poll refresh button not being debounced in web UI ([rasjonell](https://github.com/tootsuite/mastodon/pull/13485), [ThibG](https://github.com/tootsuite/mastodon/pull/13490)) +- Fix confusing error when failing to add an alias to an unknown account ([ThibG](https://github.com/tootsuite/mastodon/pull/13480)) +- Fix "Email changed" notification sometimes having wrong e-mail ([ThibG](https://github.com/tootsuite/mastodon/pull/13475)) +- Fix varioues issues on the account aliases page ([ThibG](https://github.com/tootsuite/mastodon/pull/13452)) +- Fix API footer link in web UI ([bubblineyuri](https://github.com/tootsuite/mastodon/pull/13441)) +- Fix pagination of following, followers, follow requests, blocks and mutes lists in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/13445)) +- Fix styling of polls in JS-less fallback on public pages ([ThibG](https://github.com/tootsuite/mastodon/pull/13436)) +- Fix trying to delete already deleted file when post-processing ([Gargron](https://github.com/tootsuite/mastodon/pull/13406)) + +### Security + +- Fix Doorkeeper vulnerability that exposed app secret to users who authorized the app and reset secret of the web UI that could have been exposed ([dependabot-preview[bot]](https://github.com/tootsuite/mastodon/pull/13613), [Gargron](https://github.com/tootsuite/mastodon/pull/13688)) + - For apps that self-register on behalf of every individual user (such as most mobile apps), this is a non-issue + - The issue only affects developers of apps who are shared between multiple users, such as server-side apps like cross-posters + +## [v3.1.3] - 2020-04-05 +### Added + +- Add ability to filter audit log in admin UI ([Gargron](https://github.com/tootsuite/mastodon/pull/13381)) +- Add titles to warning presets in admin UI ([Gargron](https://github.com/tootsuite/mastodon/pull/13252)) +- Add option to include resolved DNS records when blacklisting e-mail domains in admin UI ([Gargron](https://github.com/tootsuite/mastodon/pull/13254)) +- Add ability to delete files uploaded for settings in admin UI ([ThibG](https://github.com/tootsuite/mastodon/pull/13192)) +- Add sorting by username, creation and last activity in admin UI ([ThibG](https://github.com/tootsuite/mastodon/pull/13076)) +- Add explanation as to why unlocked accounts may have follow requests in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/13385)) +- Add link to bookmarks to dropdown in web UI ([mayaeh](https://github.com/tootsuite/mastodon/pull/13273)) +- Add support for links to statuses in announcements to be opened in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/13212), [ThibG](https://github.com/tootsuite/mastodon/pull/13250)) +- Add tooltips to audio/video player buttons in web UI ([ariasuni](https://github.com/tootsuite/mastodon/pull/13203)) +- Add submit button to the top of preferences pages ([guigeekz](https://github.com/tootsuite/mastodon/pull/13068)) +- Add specific rate limits for posting, following and reporting ([Gargron](https://github.com/tootsuite/mastodon/pull/13172), [Gargron](https://github.com/tootsuite/mastodon/pull/13390)) + - 300 posts every 3 hours + - 400 follows or follow requests every 24 hours + - 400 reports every 24 hours +- Add federation support for the "hide network" preference ([ThibG](https://github.com/tootsuite/mastodon/pull/11673)) +- Add `--skip-media-remove` option to `tootctl statuses remove` ([tateisu](https://github.com/tootsuite/mastodon/pull/13080)) + +### Changed + +- **Change design of polls in web UI** ([Sasha-Sorokin](https://github.com/tootsuite/mastodon/pull/13257), [ThibG](https://github.com/tootsuite/mastodon/pull/13313)) +- Change status click areas in web UI to be bigger ([ariasuni](https://github.com/tootsuite/mastodon/pull/13327)) +- **Change `tootctl media remove-orphans` to work for all classes** ([Gargron](https://github.com/tootsuite/mastodon/pull/13316)) +- **Change local media attachments to perform heavy processing asynchronously** ([Gargron](https://github.com/tootsuite/mastodon/pull/13210)) +- Change video uploads to always be converted to H264/MP4 ([Gargron](https://github.com/tootsuite/mastodon/pull/13220), [ThibG](https://github.com/tootsuite/mastodon/pull/13239), [ThibG](https://github.com/tootsuite/mastodon/pull/13242)) +- Change video uploads to enforce certain limits ([Gargron](https://github.com/tootsuite/mastodon/pull/13218)) + - Dimensions smaller than 1920x1200px + - Frame rate at most 60fps +- Change the tooltip "Toggle visibility" to "Hide media" in web UI ([ariasuni](https://github.com/tootsuite/mastodon/pull/13199)) +- Change description of privacy levels to be more intuitive in web UI ([ariasuni](https://github.com/tootsuite/mastodon/pull/13197)) +- Change GIF label to be displayed even when autoplay is enabled in web UI ([koyuawsmbrtn](https://github.com/tootsuite/mastodon/pull/13209)) +- Change the string "Hide everything from …" to "Block domain …" in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/13178), [mayaeh](https://github.com/tootsuite/mastodon/pull/13221)) +- Change wording of media display preferences to be more intuitive ([ariasuni](https://github.com/tootsuite/mastodon/pull/13198)) + +### Deprecated + +- `POST /api/v1/media` → `POST /api/v2/media` ([Gargron](https://github.com/tootsuite/mastodon/pull/13210)) + +### Fixed + +- Fix `tootctl media remove-orphans` ignoring `PAPERCLIP_ROOT_PATH` ([Gargron](https://github.com/tootsuite/mastodon/pull/13375)) +- Fix returning results when searching for URL with non-zero offset ([Gargron](https://github.com/tootsuite/mastodon/pull/13377)) +- Fix pinning a column in web UI sometimes redirecting out of web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/13376)) +- Fix background jobs not using locks like they are supposed to ([Gargron](https://github.com/tootsuite/mastodon/pull/13361)) +- Fix content warning being unnecessarily cleared when hiding content warning input in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/13348)) +- Fix "Show more" not switching to "Show less" on public pages ([ThibG](https://github.com/tootsuite/mastodon/pull/13174)) +- Fix import overwrite option not being selectable ([noellabo](https://github.com/tootsuite/mastodon/pull/13347)) +- Fix wrong color for ellipsis in boost confirmation dialog in web UI ([ariasuni](https://github.com/tootsuite/mastodon/pull/13355)) +- Fix unnecessary unfollowing when importing follows with overwrite option ([noellabo](https://github.com/tootsuite/mastodon/pull/13350)) +- Fix 404 and 410 API errors being silently discarded in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/13279)) +- Fix OCR not working on Safari because of unsupported worker-src CSP ([ThibG](https://github.com/tootsuite/mastodon/pull/13323)) +- Fix media not being marked sensitive when a content warning is set with no text ([ThibG](https://github.com/tootsuite/mastodon/pull/13277)) +- Fix crash after deleting announcements in web UI ([codesections](https://github.com/tootsuite/mastodon/pull/13283), [ThibG](https://github.com/tootsuite/mastodon/pull/13312)) +- Fix bookmarks not being searchable ([Kjwon15](https://github.com/tootsuite/mastodon/pull/13271), [noellabo](https://github.com/tootsuite/mastodon/pull/13293)) +- Fix reported accounts not being whitelisted from further spam checks when resolving a spam check report ([ThibG](https://github.com/tootsuite/mastodon/pull/13289)) +- Fix web UI crash in single-column mode on prehistoric browsers ([ThibG](https://github.com/tootsuite/mastodon/pull/13267)) +- Fix some timeouts when searching for URLs ([ThibG](https://github.com/tootsuite/mastodon/pull/13253)) +- Fix detailed view of direct messages displaying a 0 boost count in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/13244)) +- Fix regression in “Edit media” modal in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/13243)) +- Fix public posts from silenced accounts not being changed to unlisted visibility ([ThibG](https://github.com/tootsuite/mastodon/pull/13096)) +- Fix error when searching for URLs that contain the mention syntax ([ThibG](https://github.com/tootsuite/mastodon/pull/13151)) +- Fix text area above/right of emoji picker being accidentally clickable in web UI ([ariasuni](https://github.com/tootsuite/mastodon/pull/13148)) +- Fix too large announcements not being scrollable in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/13211)) +- Fix `tootctl media remove-orphans` crashing when encountering invalid media ([ThibG](https://github.com/tootsuite/mastodon/pull/13170)) +- Fix installation failing when Redis password contains special characters ([ThibG](https://github.com/tootsuite/mastodon/pull/13156)) +- Fix announcements with fully-qualified mentions to local users crashing web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/13164)) + +### Security + +- Fix re-sending of e-mail confirmation not being rate limited ([Gargron](https://github.com/tootsuite/mastodon/pull/13360)) + +## [v3.1.2] - 2020-02-27 +### Added + +- Add `--reset-password` option to `tootctl accounts modify` ([ThibG](https://github.com/tootsuite/mastodon/pull/13126)) +- Add source-mapped stacktrace to error message in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/13082)) + +### Fixed + +- Fix dismissing an announcement twice raising an obscure error ([ThibG](https://github.com/tootsuite/mastodon/pull/13124)) +- Fix misleading error when attempting to re-send a pending follow request ([ThibG](https://github.com/tootsuite/mastodon/pull/13133)) +- Fix backups failing when files are missing from media attachments ([ThibG](https://github.com/tootsuite/mastodon/pull/13146)) +- Fix duplicate accounts being created when fetching an account for its key only ([ThibG](https://github.com/tootsuite/mastodon/pull/13147)) +- Fix `/web` redirecting to `/web/web` in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/13128)) +- Fix previously OStatus-based accounts not being detected as ActivityPub ([ThibG](https://github.com/tootsuite/mastodon/pull/13129)) +- Fix account JSON/RSS not being cacheable due to wrong mime type comparison ([ThibG](https://github.com/tootsuite/mastodon/pull/13116)) +- Fix old browsers crashing because of missing `finally` polyfill in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/13115)) +- Fix account's bio not being shown if there are no proofs/fields in admin UI ([ThibG](https://github.com/tootsuite/mastodon/pull/13075)) +- Fix sign-ups without checked user agreement being accepted through the web form ([ThibG](https://github.com/tootsuite/mastodon/pull/13088)) +- Fix non-x64 architectures not being able to build Docker image because of hardcoded Node.js architecture ([SaraSmiseth](https://github.com/tootsuite/mastodon/pull/13081)) +- Fix invite request input not being shown on sign-up error if left empty ([ThibG](https://github.com/tootsuite/mastodon/pull/13089)) +- Fix some migration hints mentioning GitLab instead of Mastodon ([saper](https://github.com/tootsuite/mastodon/pull/13084)) + +### Security + +- Fix leak of arbitrary statuses through unfavourite action in REST API ([Gargron](https://github.com/tootsuite/mastodon/pull/13161)) + +## [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..0537d8fac 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,16 +3,27 @@ FROM ubuntu:18.04 as build-dep # Use bash for the shell SHELL ["bash", "-c"] -# Install Node -ENV NODE_VER="12.11.1" -RUN echo "Etc/UTC" > /etc/localtime && \ +# Install Node v12 (LTS) +ENV NODE_VER="12.16.1" +RUN ARCH= && \ + dpkgArch="$(dpkg --print-architecture)" && \ + case "${dpkgArch##*-}" in \ + amd64) ARCH='x64';; \ + ppc64el) ARCH='ppc64le';; \ + s390x) ARCH='s390x';; \ + arm64) ARCH='arm64';; \ + armhf) ARCH='armv7l';; \ + i386) ARCH='x86';; \ + *) echo "unsupported architecture"; exit 1 ;; \ + esac && \ + echo "Etc/UTC" > /etc/localtime && \ apt update && \ apt -y install wget python && \ cd ~ && \ - wget https://nodejs.org/download/release/v$NODE_VER/node-v$NODE_VER-linux-x64.tar.gz && \ - tar xf node-v$NODE_VER-linux-x64.tar.gz && \ - rm node-v$NODE_VER-linux-x64.tar.gz && \ - mv node-v$NODE_VER-linux-x64 /opt/node + wget https://nodejs.org/download/release/v$NODE_VER/node-v$NODE_VER-linux-$ARCH.tar.gz && \ + tar xf node-v$NODE_VER-linux-$ARCH.tar.gz && \ + rm node-v$NODE_VER-linux-$ARCH.tar.gz && \ + mv node-v$NODE_VER-linux-$ARCH /opt/node # Install jemalloc ENV JE_VER="5.2.1" @@ -27,8 +38,8 @@ RUN apt update && \ make -j$(nproc) > /dev/null && \ make install_bin install_include install_lib -# Install ruby -ENV RUBY_VER="2.6.5" +# Install Ruby +ENV RUBY_VER="2.6.6" ENV CPPFLAGS="-I/opt/jemalloc/include" ENV LDFLAGS="-L/opt/jemalloc/lib/" RUN apt update && \ @@ -58,7 +69,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 +136,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..3150c368d 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.5.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.2' +gem 'sprockets', '~> 3.7.2' gem 'thor', '~> 0.20' +gem 'rack', '~> 2.2.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.64', 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.3.0' gem 'devise', '~> 4.7' gem 'devise-two-factor', '~> 3.1' @@ -38,75 +43,74 @@ 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' -gem 'discard', '~> 1.1' -gem 'doorkeeper', '~> 5.2' +gem 'discard', '~> 1.2' +gem 'doorkeeper', '~> 5.4' 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.4' gem 'http_accept_language', '~> 2.1' gem 'http_parser.rb', '~> 0.6', git: 'https://github.com/tmm1/http_parser.rb', ref: '54b17ba8c7d8d20a16dfc65d1775241833219cf2', submodules: true -gem 'httplog', '~> 1.3' +gem 'httplog', '~> 1.4.2' gem 'idn-ruby', require: 'idn' -gem 'kaminari', '~> 1.1' +gem 'kaminari', '~> 1.2' 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.13' 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.3' +gem 'rack-cors', '~> 1.1', require: 'rack/cors' gem 'rails-i18n', '~> 5.1' gem 'rails-settings-cached', '~> 0.6' gem 'redis', '~> 4.1', require: ['redis', 'redis/connection/hiredis'] gem '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' +gem 'sidekiq', '~> 6.0' 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.6' gem 'tty-command', '~> 0.9', require: false -gem 'tty-prompt', '~> 0.19', require: false +gem 'tty-prompt', '~> 0.21', require: false gem 'twitter-text', '~> 1.14' -gem 'tzinfo-data', '~> 1.2019' -gem 'webpacker', '~> 4.0' +gem 'tzinfo-data', '~> 1.2020' +gem 'webpacker', '~> 5.1' gem 'webpush' -gem 'json-ld', git: 'https://github.com/ruby-rdf/json-ld.git', ref: 'e742697a0906e74e8bb777ef98137bc3955d981d' -gem 'json-ld-preloaded', '~> 3.0' -gem 'rdf-normalize', '~> 0.3' +gem 'json-ld' +gem 'json-ld-preloaded', '~> 3.1' +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-byebug', '~> 3.9' gem 'pry-rails', '~> 0.3' - gem 'rspec-rails', '~> 3.8' + gem 'rspec-rails', '~> 4.0' end group :production, :test do @@ -114,37 +118,37 @@ group :production, :test do end group :test do - gem 'capybara', '~> 3.29' + gem 'capybara', '~> 3.32' gem 'climate_control', '~> 0.2' - gem 'faker', '~> 2.5' - gem 'microformats', '~> 4.1' + gem 'faker', '~> 2.11' + 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 'simplecov', '~> 0.18', require: false + gem 'webmock', '~> 3.8' + gem 'parallel_tests', '~> 2.32' + gem 'rspec_junit_formatter', '~> 0.4' end group :development do - gem 'active_record_query_trace', '~> 1.6' - gem 'annotate', '~> 2.7' - gem 'better_errors', '~> 2.5' + gem 'active_record_query_trace', '~> 1.7' + gem 'annotate', '~> 3.1' + gem 'better_errors', '~> 2.7' 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 'letter_opener_web', '~> 1.4' 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.82', require: false + gem 'rubocop-rails', '~> 2.5', require: false + gem 'brakeman', '~> 4.8', require: false gem 'bundler-audit', '~> 0.6', require: false - gem 'capistrano', '~> 3.11' + gem 'capistrano', '~> 3.14' gem 'capistrano-rails', '~> 1.4' 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..21369e6a5 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.2) + actionpack (= 5.2.4.2) 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.2) + actionpack (= 5.2.4.2) + actionview (= 5.2.4.2) + activejob (= 5.2.4.2) 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.2) + actionview (= 5.2.4.2) + activesupport (= 5.2.4.2) + 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.2) + activesupport (= 5.2.4.2) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) @@ -72,93 +59,91 @@ 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.2) + activesupport (= 5.2.4.2) 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.2) + activesupport (= 5.2.4.2) + activerecord (5.2.4.2) + activemodel (= 5.2.4.2) + activesupport (= 5.2.4.2) arel (>= 9.0) - activestorage (5.2.3) - actionpack (= 5.2.3) - activerecord (= 5.2.3) + activestorage (5.2.4.2) + actionpack (= 5.2.4.2) + activerecord (= 5.2.4.2) marcel (~> 0.3.1) - activesupport (5.2.3) + activesupport (5.2.4.2) 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.1.1) 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) encryptor (~> 3.0.0) 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-eventstream (~> 1.0, >= 1.0.2) - aws-partitions (~> 1.0) + aws-eventstream (1.1.0) + aws-partitions (1.312.0) + aws-sdk-core (3.95.0) + aws-eventstream (~> 1, >= 1.0.2) + 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.31.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.64.0) + aws-sdk-core (~> 3, >= 3.83.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.1) - aws-sigv4 (1.1.0) + aws-sigv4 (1.1.3) aws-eventstream (~> 1.0, >= 1.0.2) - bcrypt (3.1.12) - benchmark-ips (2.7.2) - better_errors (2.5.1) + bcrypt (3.1.13) + better_errors (2.7.0) 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) + bootsnap (1.4.6) msgpack (~> 1.0) - brakeman (4.6.1) - browser (2.6.1) - builder (3.2.3) - bullet (6.0.2) + brakeman (4.8.1) + browser (4.1.0) + builder (3.2.4) + bullet (6.1.0) activesupport (>= 3.0.0) uniform_notifier (~> 1.11) bundler-audit (0.6.1) bundler (>= 1.2.0, < 3) thor (~> 0.18) - byebug (11.0.0) - capistrano (3.11.2) + byebug (11.1.3) + capistrano (3.14.0) airbrussh (>= 1.0.0) i18n rake (>= 10.0.0) sshkit (>= 1.9.0) - capistrano-bundler (1.3.0) + capistrano-bundler (1.6.0) capistrano (~> 3.1) - sshkit (~> 1.2) 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.32.1) addressable mini_mime (>= 0.1.3) nokogiri (~> 1.8) @@ -168,35 +153,26 @@ 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.3.0) + ffi (>= 1.1.0, < 1.12.0) climate_control (0.2.0) cocaine (0.5.8) climate_control (>= 0.0.3, < 1.0) coderay (1.1.2) - concurrent-ruby (1.1.5) + concurrent-ruby (1.1.6) 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) @@ -213,40 +189,44 @@ GEM devise (>= 4.0.0) rpam2 (~> 4.0) diff-lcs (1.3) - discard (1.1.0) + discard (1.2.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.4.0) railties (>= 5) dotenv (2.7.5) dotenv-rails (2.7.5) dotenv (= 2.7.5) railties (>= 3.2, < 6.1) - elasticsearch (7.3.0) - elasticsearch-api (= 7.3.0) - elasticsearch-transport (= 7.3.0) - elasticsearch-api (7.3.0) + e2mmap (0.1.0) + elasticsearch (7.6.0) + elasticsearch-api (= 7.6.0) + elasticsearch-transport (= 7.6.0) + elasticsearch-api (7.6.0) multi_json - elasticsearch-dsl (0.1.8) - elasticsearch-transport (7.3.0) - faraday + elasticsearch-dsl (0.1.9) + elasticsearch-transport (7.6.0) + faraday (~> 1) multi_json encryptor (3.0.0) equatable (0.6.1) - erubi (1.8.0) - et-orbi (1.1.6) + erubi (1.9.0) + et-orbi (1.2.4) tzinfo - excon (0.62.0) - fabrication (2.20.2) - faker (2.5.0) - i18n (~> 1.6.0) - faraday (0.15.4) + excon (0.73.0) + fabrication (2.21.1) + faker (2.11.0) + i18n (>= 1.6, < 2) + faraday (1.0.1) 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) @@ -260,23 +240,21 @@ GEM fog-json (>= 1.0) ipaddress (>= 0.8) formatador (0.2.5) - fugit (1.1.6) - et-orbi (~> 1.1, >= 1.1.6) + fugit (1.3.5) + et-orbi (~> 1.1, >= 1.1.8) 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) @@ -286,28 +264,29 @@ GEM railties (>= 4.0.1) hamster (3.0.0) concurrent-ruby (~> 1.0) - hashdiff (1.0.0) - hashie (3.6.0) - heapy (0.1.4) - highline (2.0.1) + hashdiff (1.0.1) + hashie (4.1.0) + highline (2.0.3) hiredis (0.6.3) hkdf (0.3.0) htmlentities (4.3.4) - http (3.3.0) + http (4.4.1) 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.3.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.31) activesupport (>= 4.0.2) ast (>= 2.1.0) erubi @@ -319,34 +298,40 @@ GEM terminal-table (>= 1.5.1) idn-ruby (0.1.0) ipaddress (0.8.3) - iso-639 (0.2.8) - jaro_winkler (1.5.3) + iso-639 (0.3.5) + 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-ld (~> 3.0) - multi_json (~> 1.12) - rdf (~> 3.0) + json (2.3.0) + json-canonicalization (0.2.0) + json-ld (3.1.4) + htmlentities (~> 4.3) + json-canonicalization (~> 0.2) + link_header (~> 0.0, >= 0.0.8) + multi_json (~> 1.14) + rack (~> 2.0) + rdf (~> 3.1) + json-ld-preloaded (3.1.2) + json-ld (~> 3.1) + rdf (~> 3.1) jsonapi-renderer (0.2.2) - jwt (2.1.0) - kaminari (1.1.1) + jwt (2.2.1) + kaminari (1.2.0) activesupport (>= 4.1.0) - kaminari-actionview (= 1.1.1) - kaminari-activerecord (= 1.1.1) - kaminari-core (= 1.1.1) - kaminari-actionview (1.1.1) + kaminari-actionview (= 1.2.0) + kaminari-activerecord (= 1.2.0) + kaminari-core (= 1.2.0) + kaminari-actionview (1.2.0) actionview - kaminari-core (= 1.1.1) - kaminari-activerecord (1.1.1) + kaminari-core (= 1.2.0) + kaminari-activerecord (1.2.0) activerecord - kaminari-core (= 1.1.1) - kaminari-core (1.1.1) - launchy (2.4.3) - addressable (~> 2.3) + kaminari-core (= 1.2.0) + kaminari-core (1.2.0) + launchy (2.5.0) + addressable (~> 2.7) letter_opener (1.7.0) launchy (~> 2.2) - letter_opener_web (1.3.4) + letter_opener_web (1.4.0) actionmailer (>= 3.2) letter_opener (~> 1.0) railties (>= 3.2) @@ -356,7 +341,7 @@ GEM activesupport (>= 4) railties (>= 4) request_store (~> 1.0) - loofah (2.2.3) + loofah (2.5.0) crass (~> 1.0.2) nokogiri (>= 1.5.9) mail (2.7.1) @@ -368,38 +353,38 @@ GEM mario-redis-lock (1.2.1) 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) + method_source (1.0.0) + 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) - mimemagic (0.3.3) + mime-types-data (3.2020.0425) + mimemagic (0.3.5) mini_mime (1.0.2) mini_portile2 (2.4.0) - minitest (5.12.0) - msgpack (1.3.1) - multi_json (1.13.1) + minitest (5.14.0) + msgpack (1.3.3) + multi_json (1.14.1) multipart-post (2.1.1) - necromancer (0.5.0) - net-ldap (0.16.1) - 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) + necromancer (0.5.1) + net-ldap (0.16.2) + net-scp (3.0.0) + net-ssh (>= 2.6.5, < 7.0.0) + net-ssh (6.0.2) + nio4r (2.5.2) + nokogiri (1.10.9) mini_portile2 (~> 2.4.0) - nokogumbo (2.0.1) + nokogumbo (2.0.2) nokogiri (~> 1.8, >= 1.8.4) nsa (0.2.7) activesupport (>= 4.2, < 6) concurrent-ruby (~> 1.0, >= 1.0.2) sidekiq (>= 3.5) statsd-ruby (~> 1.4, >= 1.4.0) - oj (3.9.1) - omniauth (1.9.0) - hashie (>= 3.4.6, < 3.7.0) + oj (3.10.6) + omniauth (1.9.1) + hashie (>= 3.4.6) rack (>= 1.6.2, < 3) omniauth-cas (1.1.1) addressable (~> 2.3) @@ -409,11 +394,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.13.2) paperclip (6.0.0) activemodel (>= 4.2.0) activesupport (>= 4.2.0) @@ -423,63 +404,64 @@ 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.32.0) parallel - parser (2.6.4.0) + parser (2.7.1.2) ast (~> 2.4.0) - parslet (1.8.2) - pastel (0.7.3) + parslet (2.0.0) + pastel (0.7.4) equatable (~> 0.6) tty-color (~> 0.5) - pg (1.1.4) - pghero (2.3.0) + pg (1.2.3) + pghero (2.4.2) activerecord (>= 5) - pkg-config (1.3.9) + pkg-config (1.4.1) premailer (1.11.1) addressable css_parser (>= 1.6.0) htmlentities (>= 4.0.0) - premailer-rails (1.10.3) + premailer-rails (1.11.1) actionmailer (>= 3) premailer (~> 1.7, >= 1.7.9) private_address_check (0.5.0) - pry (0.12.2) - coderay (~> 1.1.0) - method_source (~> 0.9.0) - pry-byebug (3.7.0) + pry (0.13.1) + coderay (~> 1.1) + method_source (~> 1.0) + pry-byebug (3.9.0) byebug (~> 11.0) - pry (~> 0.10) + pry (~> 0.13.0) pry-rails (0.3.9) pry (>= 0.10.4) - public_suffix (4.0.1) - puma (4.2.0) + public_suffix (4.0.5) + puma (4.3.3) nio4r (~> 2.0) pundit (2.1.0) activesupport (>= 3.0.0) - raabro (1.1.6) - rack (2.0.7) - rack-attack (6.1.0) + raabro (1.3.1) + rack (2.2.2) + rack-attack (6.3.0) 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.8.1) 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.2) + actioncable (= 5.2.4.2) + actionmailer (= 5.2.4.2) + actionpack (= 5.2.4.2) + actionview (= 5.2.4.2) + activejob (= 5.2.4.2) + activemodel (= 5.2.4.2) + activerecord (= 5.2.4.2) + activestorage (= 5.2.4.2) + activesupport (= 5.2.4.2) bundler (>= 1.3.0) - railties (= 5.2.3) + railties (= 5.2.4.2) sprockets-rails (>= 2.0.0) rails-controller-testing (1.0.4) actionpack (>= 5.0.1.x) @@ -488,122 +470,130 @@ 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.2) + actionpack (= 5.2.4.2) + activesupport (= 5.2.4.2) 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) - redis (4.1.3) - redis-actionpack (5.0.2) - actionpack (>= 4.0, < 6) - redis-rack (>= 1, < 3) + rdf-normalize (0.4.0) + rdf (~> 3.1) + redis (4.1.4) + redis-actionpack (5.2.0) + actionpack (>= 5, < 7) + redis-rack (>= 2.1.0, < 3) redis-store (>= 1.1.0, < 2) - redis-activesupport (5.0.4) - activesupport (>= 3, < 6) + redis-activesupport (5.2.0) + activesupport (>= 3, < 7) 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) + redis-rack (2.1.2) + rack (>= 2.0.8, < 3) redis-store (>= 1.2, < 2) redis-rails (5.0.2) redis-actionpack (>= 5.0, < 6) redis-activesupport (>= 5.0, < 6) redis-store (>= 1.2, < 2) - redis-store (1.5.0) - redis (>= 2.2, < 5) - regexp_parser (1.6.0) - request_store (1.4.1) + redis-store (1.8.2) + redis (>= 4, < 5) + regexp_parser (1.7.0) + request_store (1.5.0) rack (>= 1.4) responders (3.0.0) actionpack (>= 5.0) railties (>= 5.0) + rexml (3.2.4) 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.2) + rspec-core (3.9.2) + rspec-support (~> 3.9.3) + rspec-expectations (3.9.2) 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.1) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.8.0) - rspec-rails (3.8.2) - 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-support (~> 3.9.0) + rspec-rails (4.0.0) + actionpack (>= 4.2) + activesupport (>= 4.2) + railties (>= 4.2) + rspec-core (~> 3.9) + rspec-expectations (~> 3.9) + rspec-mocks (~> 3.9) + rspec-support (~> 3.9) 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.3) + rspec_junit_formatter (0.4.1) + rspec-core (>= 2, < 4, != 2.12.0) + rubocop (0.82.0) jaro_winkler (~> 1.5.1) parallel (~> 1.10) - parser (>= 2.6) + parser (>= 2.7.0.1) rainbow (>= 2.2.2, < 4.0) + rexml ruby-progressbar (~> 1.7) - unicode-display_width (>= 1.4.0, < 1.7) - rubocop-rails (2.3.2) + unicode-display_width (>= 1.4.0, < 2.0) + rubocop-rails (2.5.2) + activesupport rack (>= 1.1) rubocop (>= 0.72.0) ruby-progressbar (1.10.1) - ruby-saml (1.9.0) + ruby-saml (1.11.0) nokogiri (>= 1.5.10) - ruby-statistics (2.1.1) - rufus-scheduler (3.5.2) - fugit (~> 1.1, >= 1.1.5) + rufus-scheduler (3.6.0) + fugit (~> 1.1, >= 1.1.6) safe_yaml (1.0.5) sanitize (5.1.0) crass (~> 1.0.2) nokogiri (>= 1.8.0) nokogumbo (~> 2.0) - sidekiq (5.2.7) - connection_pool (~> 2.2, >= 2.2.2) - rack (>= 1.5.0) - rack-protection (>= 1.5.0) - redis (>= 3.3.5, < 5) + semantic_range (2.3.0) + sidekiq (6.0.7) + connection_pool (>= 2.2.2) + rack (~> 2.0) + rack-protection (>= 2.0.0) + redis (>= 4.1.0) sidekiq-bulk (0.2.0) sidekiq - sidekiq-scheduler (3.0.0) + sidekiq-scheduler (3.0.1) + e2mmap redis (>= 3, < 5) rufus-scheduler (~> 3.2) sidekiq (>= 3) + thwait tilt (>= 1.4.0) - sidekiq-unique-jobs (6.0.13) + sidekiq-unique-jobs (6.0.21) 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.2) actionpack (>= 5.0) activemodel (>= 5.0) - simplecov (0.17.1) + simplecov (0.18.5) docile (~> 1.1) - json (>= 1.8, < 3) - simplecov-html (~> 0.10.0) - simplecov-html (0.10.2) + simplecov-html (~> 0.11) + simplecov-html (0.12.2) sprockets (3.7.2) concurrent-ruby (~> 1.0) rack (> 1, < 3) @@ -611,65 +601,67 @@ GEM actionpack (>= 4.0) activesupport (>= 4.0) sprockets (>= 3.0.0) - sshkit (1.20.0) + sshkit (1.21.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.6.6) 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) - tty-color (0.5.0) + thwait (0.1.0) + tilt (2.0.10) + tty-color (0.5.1) tty-command (0.9.0) pastel (~> 0.7.0) - tty-cursor (0.7.0) - tty-prompt (0.19.0) + tty-cursor (0.7.1) + tty-prompt (0.21.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) + tty-screen (0.7.1) twitter-text (1.14.7) unf (~> 0.1.0) - tzinfo (1.2.5) + tzinfo (1.2.7) thread_safe (~> 0.1) - tzinfo-data (1.2019.3) + tzinfo-data (1.2020.1) 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.7) + unicode-display_width (1.7.0) + uniform_notifier (1.13.0) warden (1.2.8) rack (>= 2.0.6) - webmock (3.7.6) + webmock (3.8.3) addressable (>= 2.3.6) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - webpacker (4.0.7) - activesupport (>= 4.2) + webpacker (5.1.1) + activesupport (>= 5.2) rack-proxy (>= 0.6.1) - railties (>= 4.2) + railties (>= 5.2) + semantic_range (>= 2.3.0) 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,132 +670,135 @@ 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) - better_errors (~> 2.5) + annotate (~> 3.1) + aws-sdk-s3 (~> 1.64) + better_errors (~> 2.7) binding_of_caller (~> 0.7) blurhash (~> 0.1) bootsnap (~> 1.4) - brakeman (~> 4.6) + brakeman (~> 4.8) browser - bullet (~> 6.0) + bullet (~> 6.1) bundler-audit (~> 0.6) - capistrano (~> 3.11) + capistrano (~> 3.14) capistrano-rails (~> 1.4) capistrano-rbenv (~> 2.1) capistrano-yarn (~> 2.0) - capybara (~> 3.29) - charlock_holmes (~> 0.7.6) + capybara (~> 3.32) + charlock_holmes (~> 0.7.7) chewy (~> 5.1) - cld3 (~> 3.2.4) + cld3 (~> 3.3.0) 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) + discard (~> 1.2) + doorkeeper (~> 5.4) dotenv-rails (~> 2.7) - fabrication (~> 2.20) - faker (~> 2.5) + e2mmap (~> 0.1.0) + fabrication (~> 2.21) + faker (~> 2.11) 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.4) 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-preloaded (~> 3.0) - kaminari (~> 1.1) + json-ld + json-ld-preloaded (~> 3.1) + kaminari (~> 1.2) letter_opener (~> 1.7) - letter_opener_web (~> 1.3) + letter_opener_web (~> 1.4) link_header (~> 0.0) lograge (~> 0.11) 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.13) paperclip (~> 6.0) paperclip-av-transcoder (~> 0.6) - parallel (~> 1.17) - parallel_tests (~> 2.29) + parallel (~> 1.19) + parallel_tests (~> 2.32) 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-byebug (~> 3.9) 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.2.2) + rack-attack (~> 6.3) + rack-cors (~> 1.1) + rails (~> 5.2.4.2) 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 (~> 4.0) rspec-sidekiq (~> 3.0) - rubocop (~> 0.74) - rubocop-rails (~> 2.3) + rspec_junit_formatter (~> 0.4) + rubocop (~> 0.82) + rubocop-rails (~> 2.5) ruby-progressbar (~> 1.10) sanitize (~> 5.1) - sidekiq (~> 5.2) + sidekiq (~> 6.0) sidekiq-bulk (~> 0.2.0) sidekiq-scheduler (~> 3.0) sidekiq-unique-jobs (~> 6.0) simple-navigation (~> 4.1) - simple_form (~> 4.1) - simplecov (~> 0.17) + simple_form (~> 5.0) + simplecov (~> 0.18) + 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.6) thor (~> 0.20) + thwait (~> 0.1.0) tty-command (~> 0.9) - tty-prompt (~> 0.19) + tty-prompt (~> 0.21) twitter-text (~> 1.14) - tzinfo-data (~> 1.2019) - webmock (~> 3.7) - webpacker (~> 4.0) + tzinfo-data (~> 1.2020) + webmock (~> 3.8) + webpacker (~> 5.1) webpush RUBY VERSION ruby 2.6.5p114 BUNDLED WITH - 1.17.3 + 1.17.2 diff --git a/README.md b/README.md index f07816d33..fd08ca0dd 100644 --- a/README.md +++ b/README.md @@ -64,13 +64,7 @@ What does this have to do with content types? Well, if we support an `Article` c If Hometown is going to be a universal reader, you're going to need better control over organizing your feeds than mainline Mastodon provides. -### Exclusive lists - -My first plan is to introduce a new kind of _exclusive list_. Right now if you add an account to your "friends I like" list in Mastodon, posts from people on that list appear on that list. But they also appear on your home timeline, and maybe you don't want that! You'd rather treat your "friends I like" list as your "real" home timeline, and then check your home timeline when you're bored. - -Or another case: I might have all the blogs I read in one list, but I only check it on Saturdays when I have time to read things. In that case I don't want updates from those blogs clogging up my home timeline. - -> This is not yet implemented but will be available in the first release. +I've introduced a new kind of [exclusive list](https://github.com/hometown-fork/hometown/wiki/Exclusive-lists). In vanilla Mastodon, if you add an account to your "friends I like" list, posts from people on that list appear on that list. But they also appear on your home timeline, and maybe you don't want that! You'd rather treat your "friends I like" list as your "real" home timeline, and then check your home timeline when you're bored. Check out [more details about exclusive lists on the wiki](https://github.com/hometown-fork/hometown/wiki/Exclusive-lists). ## Better accessibility defaults @@ -88,11 +82,11 @@ Hometown uses [semantic versioning](https://semver.org) and follows a versioning ## Contributing to Hometown -Setting up your Hometown development environment is [exactly like setting up your Mastodon development environment](https://docs.joinmastodon.org/development/overview/). Pull requests should be made to the `hometown-dev` branch, which is our default branch in Github. +Setting up your Hometown development environment is [exactly like setting up your Mastodon development environment](https://docs.joinmastodon.org/dev/overview/). Pull requests should be made to the `hometown-dev` branch, which is our default branch in Github. ## License -Copyright (C) 2016-2019 Eugen Rochko and 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..7d0f7b3de 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"]} @@ -91,7 +91,7 @@ VAGRANTFILE_API_VERSION = "2" Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| - config.vm.box = "ubuntu/xenial64" + config.vm.box = "ubuntu/bionic64" config.vm.provider :virtualbox do |vb| vb.name = "mastodon" diff --git a/app/chewy/statuses_index.rb b/app/chewy/statuses_index.rb index f5735421c..bec9ed88b 100644 --- a/app/chewy/statuses_index.rb +++ b/app/chewy/statuses_index.rb @@ -47,6 +47,11 @@ class StatusesIndex < Chewy::Index data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) } end + crutch :bookmarks do |collection| + data = ::Bookmark.where(status_id: collection.map(&:id)).where(account: Account.local).pluck(:status_id, :account_id) + data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) } + end + root date_detection: false do field :id, type: 'long' field :account_id, type: 'long' diff --git a/app/controllers/account_follow_controller.rb b/app/controllers/account_follow_controller.rb index 185a355f8..33394074d 100644 --- a/app/controllers/account_follow_controller.rb +++ b/app/controllers/account_follow_controller.rb @@ -6,7 +6,7 @@ class AccountFollowController < ApplicationController before_action :authenticate_user! def create - FollowService.new.call(current_user.account, @account.acct) + FollowService.new.call(current_user.account, @account, with_rate_limit: true) redirect_to account_path(@account) end end diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 47e67509e..402555e10 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -9,7 +9,7 @@ class AccountsController < ApplicationController before_action :set_cache_headers before_action :set_body_classes - skip_around_action :set_locale, if: -> { [:json, :rss].include?(request.format) } + skip_around_action :set_locale, if: -> { [:json, :rss].include?(request.format&.to_sym) } skip_before_action :require_functional! def show @@ -26,8 +26,12 @@ class AccountsController < ApplicationController return end - @pinned_statuses = cache_collection(@account.pinned_statuses, Status) if show_pinned_statuses? - @statuses = filtered_status_page(params) + if current_user.nil? + @pinned_statuses = cache_collection(@account.pinned_statuses.without_local_only, Status) if show_pinned_statuses? + else + @pinned_statuses = cache_collection(@account.pinned_statuses, Status) if show_pinned_statuses? + end + @statuses = filtered_status_page @statuses = cache_collection(@statuses, Status) @rss_url = rss_url @@ -40,7 +44,7 @@ class AccountsController < ApplicationController format.rss do expires_in 1.minute, public: true - @statuses = filtered_statuses.without_local_only.without_reblogs.without_replies.limit(PAGE_SIZE) + @statuses = filtered_statuses.without_reblogs.without_local_only.limit(PAGE_SIZE) @statuses = cache_collection(@statuses, Status) render xml: RSS::AccountSerializer.render(@account, @statuses, params[:tag]) end @@ -133,23 +137,23 @@ class AccountsController < ApplicationController end def media_requested? - request.path.ends_with?('/media') && !tag_requested? + request.path.split('.').first.ends_with?('/media') && !tag_requested? end def replies_requested? - request.path.ends_with?('/with_replies') && !tag_requested? + request.path.split('.').first.ends_with?('/with_replies') && !tag_requested? end def tag_requested? request.path.split('.').first.ends_with?(Addressable::URI.parse("/tagged/#{params[:tag]}").normalize) end - def filtered_status_page(params) - if params[:min_id].present? - filtered_statuses.paginate_by_min_id(PAGE_SIZE, params[:min_id]).reverse - else - filtered_statuses.paginate_by_max_id(PAGE_SIZE, params[:max_id], params[:since_id]).to_a - end + def filtered_status_page + filtered_statuses.paginate_by_id(PAGE_SIZE, params_slice(:max_id, :min_id, :since_id)) + end + + def params_slice(*keys) + params.slice(*keys).permit(*keys) end def restrict_fields_to diff --git a/app/controllers/activitypub/collections_controller.rb b/app/controllers/activitypub/collections_controller.rb index 910fefb1c..c1e7aa550 100644 --- a/app/controllers/activitypub/collections_controller.rb +++ b/app/controllers/activitypub/collections_controller.rb @@ -24,20 +24,23 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController def set_size case params[:id] when 'featured' - @account.pinned_statuses.count + @size = @account.pinned_statuses.count else - raise ActiveRecord::RecordNotFound + not_found end end def scope_for_collection case params[:id] when 'featured' - return Status.none if @account.blocking?(signed_request_account) - - @account.pinned_statuses - else - raise ActiveRecord::RecordNotFound + # Because in public fetch mode we cache the response, there would be no + # benefit from performing the check below, since a blocked account or domain + # would likely be served the cache from the reverse proxy anyway + if authorized_fetch_mode? && !signed_request_account.nil? && (@account.blocking?(signed_request_account) || (!signed_request_account.domain.nil? && @account.domain_blocking?(signed_request_account.domain))) + Status.none + else + @account.pinned_statuses + end end end diff --git a/app/controllers/activitypub/inboxes_controller.rb b/app/controllers/activitypub/inboxes_controller.rb index bcfc1e6d4..0a561e7f0 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 @@ -48,7 +49,7 @@ class ActivityPub::InboxesController < ActivityPub::BaseController ResolveAccountWorker.perform_async(signed_request_account.acct) end - DeliveryFailureTracker.track_inverse_success!(signed_request_account) + DeliveryFailureTracker.reset!(signed_request_account.inbox_url) end def process_payload diff --git a/app/controllers/activitypub/outboxes_controller.rb b/app/controllers/activitypub/outboxes_controller.rb index 891756b7e..e25a4bc07 100644 --- a/app/controllers/activitypub/outboxes_controller.rb +++ b/app/controllers/activitypub/outboxes_controller.rb @@ -11,7 +11,7 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController before_action :set_cache_headers def show - expires_in(page_requested? ? 0 : 3.minutes, public: public_fetch_mode?) + expires_in(page_requested? ? 0 : 3.minutes, public: public_fetch_mode? && !(signed_request_account.present? && page_requested?)) render json: outbox_presenter, serializer: ActivityPub::OutboxSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' end @@ -50,12 +50,12 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController return unless page_requested? @statuses = @account.statuses.permitted_for(@account, signed_request_account) - @statuses = params[:min_id].present? ? @statuses.paginate_by_min_id(LIMIT, params[:min_id]).reverse : @statuses.paginate_by_max_id(LIMIT, params[:max_id]) + @statuses = @statuses.paginate_by_id(LIMIT, params_slice(:max_id, :min_id, :since_id)) @statuses = cache_collection(@statuses, Status) end def page_requested? - params[:page] == 'true' + truthy_param?(:page) end def page_params diff --git a/app/controllers/activitypub/replies_controller.rb b/app/controllers/activitypub/replies_controller.rb index c62061555..43bf4e657 100644 --- a/app/controllers/activitypub/replies_controller.rb +++ b/app/controllers/activitypub/replies_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class ActivityPub::RepliesController < ActivityPub::BaseController - include SignatureAuthentication + include SignatureVerification include Authorization include AccountOwnedConcern @@ -19,15 +19,19 @@ class ActivityPub::RepliesController < ActivityPub::BaseController private + def pundit_user + signed_request_account + end + def set_status @status = @account.statuses.find(params[:status_id]) authorize @status, :show? rescue Mastodon::NotPermittedError - raise ActiveRecord::RecordNotFound + not_found end def set_replies - @replies = page_params[:only_other_accounts] ? Status.where.not(account_id: @account.id) : @account.statuses + @replies = only_other_accounts? ? Status.where.not(account_id: @account.id) : @account.statuses @replies = @replies.where(in_reply_to_id: @status.id, visibility: [:public, :unlisted]) @replies = @replies.paginate_by_min_id(DESCENDANTS_LIMIT, params[:min_id]) end @@ -38,7 +42,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController type: :unordered, part_of: account_status_replies_url(@account, @status), next: next_page, - items: @replies.map { |status| status.local ? status : status.uri } + items: @replies.map { |status| status.local? ? status : status.uri } ) return page if page_requested? @@ -51,16 +55,21 @@ class ActivityPub::RepliesController < ActivityPub::BaseController end def page_requested? - params[:page] == 'true' + truthy_param?(:page) + end + + def only_other_accounts? + truthy_param?(:only_other_accounts) end def next_page only_other_accounts = !(@replies&.last&.account_id == @account.id && @replies.size == DESCENDANTS_LIMIT) + account_status_replies_url( @account, @status, page: true, - min_id: only_other_accounts && !page_params[:only_other_accounts] ? nil : @replies&.last&.id, + min_id: only_other_accounts && !only_other_accounts? ? nil : @replies&.last&.id, only_other_accounts: only_other_accounts ) end 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/action_logs_controller.rb b/app/controllers/admin/action_logs_controller.rb index e273dfeae..2d77620df 100644 --- a/app/controllers/admin/action_logs_controller.rb +++ b/app/controllers/admin/action_logs_controller.rb @@ -2,8 +2,18 @@ module Admin class ActionLogsController < BaseController - def index - @action_logs = Admin::ActionLog.page(params[:page]) + before_action :set_action_logs + + def index; end + + private + + def set_action_logs + @action_logs = Admin::ActionLogFilter.new(filter_params).results.page(params[:page]) + end + + def filter_params + params.slice(:page, *Admin::ActionLogFilter::KEYS).permit(:page, *Admin::ActionLogFilter::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/email_domain_blocks_controller.rb b/app/controllers/admin/email_domain_blocks_controller.rb index 9fe85064e..c25919726 100644 --- a/app/controllers/admin/email_domain_blocks_controller.rb +++ b/app/controllers/admin/email_domain_blocks_controller.rb @@ -6,12 +6,12 @@ module Admin def index authorize :email_domain_block, :index? - @email_domain_blocks = EmailDomainBlock.page(params[:page]) + @email_domain_blocks = EmailDomainBlock.where(parent_id: nil).includes(:children).order(id: :desc).page(params[:page]) end def new authorize :email_domain_block, :create? - @email_domain_block = EmailDomainBlock.new + @email_domain_block = EmailDomainBlock.new(domain: params[:_domain]) end def create @@ -21,6 +21,28 @@ module Admin if @email_domain_block.save log_action :create, @email_domain_block + + if @email_domain_block.with_dns_records? + hostnames = [] + ips = [] + + Resolv::DNS.open do |dns| + dns.timeouts = 1 + + hostnames = dns.getresources(@email_domain_block.domain, Resolv::DNS::Resource::IN::MX).to_a.map { |e| e.exchange.to_s } + + ([@email_domain_block.domain] + hostnames).uniq.each do |hostname| + ips.concat(dns.getresources(hostname, Resolv::DNS::Resource::IN::A).to_a.map { |e| e.address.to_s }) + ips.concat(dns.getresources(hostname, Resolv::DNS::Resource::IN::AAAA).to_a.map { |e| e.address.to_s }) + end + end + + (hostnames + ips).each do |hostname| + another_email_domain_block = EmailDomainBlock.new(domain: hostname, parent: @email_domain_block) + log_action :create, another_email_domain_block if another_email_domain_block.save + end + end + redirect_to admin_email_domain_blocks_path, notice: I18n.t('admin.email_domain_blocks.created_msg') else render :new @@ -41,7 +63,7 @@ module Admin end def resource_params - params.require(:email_domain_block).permit(:domain) + params.require(:email_domain_block).permit(:domain, :with_dns_records) end end end 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..1790becbf 100644 --- a/app/controllers/admin/instances_controller.rb +++ b/app/controllers/admin/instances_controller.rb @@ -19,7 +19,7 @@ module Admin @followers_count = Follow.where(target_account: Account.where(domain: params[:id])).count @reports_count = Report.where(target_account: Account.where(domain: params[:id])).count @blocks_count = Block.where(target_account: Account.where(domain: params[:id])).count - @available = DeliveryFailureTracker.available?(Account.select(:shared_inbox_url).where(domain: params[:id]).first&.shared_inbox_url) + @available = DeliveryFailureTracker.available?(params[:id]) @media_storage = MediaAttachment.where(account: Account.where(domain: params[:id])).sum(:file_file_size) @private_comment = @domain_block&.private_comment @public_comment = @domain_block&.public_comment @@ -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/site_uploads_controller.rb b/app/controllers/admin/site_uploads_controller.rb new file mode 100644 index 000000000..cacecedb0 --- /dev/null +++ b/app/controllers/admin/site_uploads_controller.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Admin + class SiteUploadsController < BaseController + before_action :set_site_upload + + def destroy + authorize :settings, :destroy? + + @site_upload.destroy! + + redirect_to edit_admin_settings_path, notice: I18n.t('admin.site_uploads.destroyed_msg') + end + + private + + def set_site_upload + @site_upload = SiteUpload.find(params[:id]) + end + end +end 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/admin/warning_presets_controller.rb b/app/controllers/admin/warning_presets_controller.rb index 37be842c5..b376f8d9b 100644 --- a/app/controllers/admin/warning_presets_controller.rb +++ b/app/controllers/admin/warning_presets_controller.rb @@ -7,7 +7,7 @@ module Admin def index authorize :account_warning_preset, :index? - @warning_presets = AccountWarningPreset.all + @warning_presets = AccountWarningPreset.alphabetic @warning_preset = AccountWarningPreset.new end @@ -19,7 +19,7 @@ module Admin if @warning_preset.save redirect_to admin_warning_presets_path else - @warning_presets = AccountWarningPreset.all + @warning_presets = AccountWarningPreset.alphabetic render :index end end @@ -52,7 +52,7 @@ module Admin end def warning_preset_params - params.require(:account_warning_preset).permit(:text) + params.require(:account_warning_preset).permit(:title, :text) end end end diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index 33df75b37..153ade253 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 @@ -40,6 +44,10 @@ class Api::BaseController < ApplicationController render json: { error: 'There was a temporary problem serving your request, please try again' }, status: 503 end + rescue_from Mastodon::RateLimitExceededError do + render json: { error: I18n.t('errors.429') }, status: 429 + end + rescue_from ActionController::ParameterMissing do |e| render json: { error: e.to_s }, status: 400 end @@ -81,7 +89,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 b2fa720b6..af4b6e68f 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..2277067c9 100644 --- a/app/controllers/api/v1/accounts/follower_accounts_controller.rb +++ b/app/controllers/api/v1/accounts/follower_accounts_controller.rb @@ -5,8 +5,6 @@ class Api::V1::Accounts::FollowerAccountsController < Api::BaseController before_action :set_account after_action :insert_pagination_headers - respond_to :json - def index @accounts = load_accounts render json: @accounts, each_serializer: REST::AccountSerializer @@ -21,11 +19,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? || current_account.id == @account.id + 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.hides_followers? && 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..93d4bd3a4 100644 --- a/app/controllers/api/v1/accounts/following_accounts_controller.rb +++ b/app/controllers/api/v1/accounts/following_accounts_controller.rb @@ -5,8 +5,6 @@ class Api::V1::Accounts::FollowingAccountsController < Api::BaseController before_action :set_account after_action :insert_pagination_headers - respond_to :json - def index @accounts = load_accounts render json: @accounts, each_serializer: REST::AccountSerializer @@ -21,11 +19,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? || current_account.id == @account.id + 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.hides_following? && current_account&.id != @account.id) || (current_account && @account.blocking?(current_account)) end def default_accounts diff --git a/app/controllers/api/v1/accounts/identity_proofs_controller.rb b/app/controllers/api/v1/accounts/identity_proofs_controller.rb index bea51ae11..8dad6fee9 100644 --- a/app/controllers/api/v1/accounts/identity_proofs_controller.rb +++ b/app/controllers/api/v1/accounts/identity_proofs_controller.rb @@ -4,8 +4,6 @@ class Api::V1::Accounts::IdentityProofsController < Api::BaseController before_action :require_user! before_action :set_account - respond_to :json - def index @proofs = @account.identity_proofs.active render json: @proofs, each_serializer: REST::IdentityProofSerializer diff --git a/app/controllers/api/v1/accounts/lists_controller.rb b/app/controllers/api/v1/accounts/lists_controller.rb index 72392453c..ccb751f8f 100644 --- a/app/controllers/api/v1/accounts/lists_controller.rb +++ b/app/controllers/api/v1/accounts/lists_controller.rb @@ -5,8 +5,6 @@ class Api::V1::Accounts::ListsController < Api::BaseController before_action :require_user! before_action :set_account - respond_to :json - def index @lists = @account.lists.where(account: current_account) render json: @lists, each_serializer: REST::ListSerializer diff --git a/app/controllers/api/v1/accounts/pins_controller.rb b/app/controllers/api/v1/accounts/pins_controller.rb index 0a0239c42..3915b5669 100644 --- a/app/controllers/api/v1/accounts/pins_controller.rb +++ b/app/controllers/api/v1/accounts/pins_controller.rb @@ -7,8 +7,6 @@ class Api::V1::Accounts::PinsController < Api::BaseController before_action :require_user! before_action :set_account - respond_to :json - def create AccountPin.create!(account: current_account, target_account: @account) render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships_presenter diff --git a/app/controllers/api/v1/accounts/relationships_controller.rb b/app/controllers/api/v1/accounts/relationships_controller.rb index ab8a0461f..1d3992a28 100644 --- a/app/controllers/api/v1/accounts/relationships_controller.rb +++ b/app/controllers/api/v1/accounts/relationships_controller.rb @@ -4,8 +4,6 @@ class Api::V1::Accounts::RelationshipsController < Api::BaseController before_action -> { doorkeeper_authorize! :read, :'read:follows' } before_action :require_user! - respond_to :json - def index accounts = Account.where(id: account_ids).select('id') # .where doesn't guarantee that our results are in the same order diff --git a/app/controllers/api/v1/accounts/search_controller.rb b/app/controllers/api/v1/accounts/search_controller.rb index 4217b527a..3061fcb7e 100644 --- a/app/controllers/api/v1/accounts/search_controller.rb +++ b/app/controllers/api/v1/accounts/search_controller.rb @@ -4,8 +4,6 @@ class Api::V1::Accounts::SearchController < Api::BaseController before_action -> { doorkeeper_authorize! :read, :'read:accounts' } before_action :require_user! - respond_to :json - def show @accounts = account_search render json: @accounts, each_serializer: REST::AccountSerializer diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb index 333db9618..114ee0a82 100644 --- a/app/controllers/api/v1/accounts/statuses_controller.rb +++ b/app/controllers/api/v1/accounts/statuses_controller.rb @@ -6,8 +6,6 @@ class Api::V1::Accounts::StatusesController < Api::BaseController after_action :insert_pagination_headers, unless: -> { truthy_param?(:pinned) } - respond_to :json - def index @statuses = load_statuses render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index d68d2715f..0080faf33 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -14,7 +14,7 @@ class Api::V1::AccountsController < Api::BaseController skip_before_action :require_authenticated_user!, only: :create - respond_to :json + override_rate_limit_headers :follow, family: :follows def show render json: @account, serializer: REST::AccountSerializer @@ -31,7 +31,7 @@ class Api::V1::AccountsController < Api::BaseController end def follow - FollowService.new.call(current_user.account, @account, reblogs: truthy_param?(:reblogs)) + FollowService.new.call(current_user.account, @account, reblogs: truthy_param?(:reblogs), with_rate_limit: true) options = @account.locked? || current_user.account.silenced? ? {} : { following_map: { @account.id => { reblogs: truthy_param?(:reblogs) } }, requested_map: { @account.id => false } } 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..ee79fc19f --- /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.find_or_create_by!(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/apps/credentials_controller.rb b/app/controllers/api/v1/apps/credentials_controller.rb index 8b63d0490..0475b2d4a 100644 --- a/app/controllers/api/v1/apps/credentials_controller.rb +++ b/app/controllers/api/v1/apps/credentials_controller.rb @@ -3,8 +3,6 @@ class Api::V1::Apps::CredentialsController < Api::BaseController before_action -> { doorkeeper_authorize! :read } - respond_to :json - def show render json: doorkeeper_token.application, serializer: REST::ApplicationSerializer, fields: %i(name website vapid_key) end diff --git a/app/controllers/api/v1/blocks_controller.rb b/app/controllers/api/v1/blocks_controller.rb index 4cff04cad..a2baeef90 100644 --- a/app/controllers/api/v1/blocks_controller.rb +++ b/app/controllers/api/v1/blocks_controller.rb @@ -5,8 +5,6 @@ class Api::V1::BlocksController < Api::BaseController before_action :require_user! after_action :insert_pagination_headers - respond_to :json - def index @accounts = load_accounts render json: @accounts, each_serializer: REST::AccountSerializer diff --git a/app/controllers/api/v1/bookmarks_controller.rb b/app/controllers/api/v1/bookmarks_controller.rb new file mode 100644 index 000000000..c15212f0a --- /dev/null +++ b/app/controllers/api/v1/bookmarks_controller.rb @@ -0,0 +1,64 @@ +# 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 + + 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/conversations_controller.rb b/app/controllers/api/v1/conversations_controller.rb index b19f27ebf..bc8013379 100644 --- a/app/controllers/api/v1/conversations_controller.rb +++ b/app/controllers/api/v1/conversations_controller.rb @@ -9,8 +9,6 @@ class Api::V1::ConversationsController < Api::BaseController before_action :set_conversation, except: :index after_action :insert_pagination_headers, only: :index - respond_to :json - def index @conversations = paginated_conversations render json: @conversations, each_serializer: REST::ConversationSerializer diff --git a/app/controllers/api/v1/custom_emojis_controller.rb b/app/controllers/api/v1/custom_emojis_controller.rb index 4e6d5d7c6..08b3474cc 100644 --- a/app/controllers/api/v1/custom_emojis_controller.rb +++ b/app/controllers/api/v1/custom_emojis_controller.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true class Api::V1::CustomEmojisController < Api::BaseController - respond_to :json - skip_before_action :set_cache_headers def index diff --git a/app/controllers/api/v1/domain_blocks_controller.rb b/app/controllers/api/v1/domain_blocks_controller.rb index af9e7a20f..5bb02d834 100644 --- a/app/controllers/api/v1/domain_blocks_controller.rb +++ b/app/controllers/api/v1/domain_blocks_controller.rb @@ -8,8 +8,6 @@ class Api::V1::DomainBlocksController < Api::BaseController before_action :require_user! after_action :insert_pagination_headers, only: :show - respond_to :json - def show @blocks = load_domain_blocks render json: @blocks.map(&:domain) diff --git a/app/controllers/api/v1/endorsements_controller.rb b/app/controllers/api/v1/endorsements_controller.rb index 2770c7aef..c87dbc4ce 100644 --- a/app/controllers/api/v1/endorsements_controller.rb +++ b/app/controllers/api/v1/endorsements_controller.rb @@ -5,8 +5,6 @@ class Api::V1::EndorsementsController < Api::BaseController before_action :require_user! after_action :insert_pagination_headers - respond_to :json - def index @accounts = load_accounts render json: @accounts, each_serializer: REST::AccountSerializer diff --git a/app/controllers/api/v1/favourites_controller.rb b/app/controllers/api/v1/favourites_controller.rb index db827f9d4..3e242905d 100644 --- a/app/controllers/api/v1/favourites_controller.rb +++ b/app/controllers/api/v1/favourites_controller.rb @@ -5,8 +5,6 @@ class Api::V1::FavouritesController < Api::BaseController 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) diff --git a/app/controllers/api/v1/featured_tags/suggestions_controller.rb b/app/controllers/api/v1/featured_tags/suggestions_controller.rb index fb27ef88b..8c1b81a0f 100644 --- a/app/controllers/api/v1/featured_tags/suggestions_controller.rb +++ b/app/controllers/api/v1/featured_tags/suggestions_controller.rb @@ -2,12 +2,9 @@ class Api::V1::FeaturedTags::SuggestionsController < Api::BaseController before_action -> { doorkeeper_authorize! :read, :'read:accounts' }, only: :index - before_action :require_user! before_action :set_most_used_tags, only: :index - respond_to :json - def index render json: @most_used_tags, each_serializer: REST::TagSerializer end diff --git a/app/controllers/api/v1/filters_controller.rb b/app/controllers/api/v1/filters_controller.rb index e5ebaff4d..b0ace3af0 100644 --- a/app/controllers/api/v1/filters_controller.rb +++ b/app/controllers/api/v1/filters_controller.rb @@ -7,8 +7,6 @@ class Api::V1::FiltersController < Api::BaseController before_action :set_filters, only: :index before_action :set_filter, only: [:show, :update, :destroy] - respond_to :json - def index render json: @filters, each_serializer: REST::FilterSerializer end diff --git a/app/controllers/api/v1/instances/activity_controller.rb b/app/controllers/api/v1/instances/activity_controller.rb index b30e8464c..4f6b4bcbf 100644 --- a/app/controllers/api/v1/instances/activity_controller.rb +++ b/app/controllers/api/v1/instances/activity_controller.rb @@ -6,8 +6,6 @@ class Api::V1::Instances::ActivityController < Api::BaseController skip_before_action :set_cache_headers skip_before_action :require_authenticated_user!, unless: :whitelist_mode? - respond_to :json - def show expires_in 1.day, public: true render_with_cache json: :activity, expires_in: 1.day diff --git a/app/controllers/api/v1/instances/peers_controller.rb b/app/controllers/api/v1/instances/peers_controller.rb index cc00d8a6b..9fa440935 100644 --- a/app/controllers/api/v1/instances/peers_controller.rb +++ b/app/controllers/api/v1/instances/peers_controller.rb @@ -6,8 +6,6 @@ class Api::V1::Instances::PeersController < Api::BaseController skip_before_action :set_cache_headers skip_before_action :require_authenticated_user!, unless: :whitelist_mode? - respond_to :json - def index expires_in 1.day, public: true render_with_cache(expires_in: 1.day) { Account.remote.domains } diff --git a/app/controllers/api/v1/instances_controller.rb b/app/controllers/api/v1/instances_controller.rb index c323b60b4..5b5058a7b 100644 --- a/app/controllers/api/v1/instances_controller.rb +++ b/app/controllers/api/v1/instances_controller.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true class Api::V1::InstancesController < Api::BaseController - respond_to :json - skip_before_action :set_cache_headers skip_before_action :require_authenticated_user!, unless: :whitelist_mode? diff --git a/app/controllers/api/v1/media_controller.rb b/app/controllers/api/v1/media_controller.rb index aaa93b615..0bb3d0d27 100644 --- a/app/controllers/api/v1/media_controller.rb +++ b/app/controllers/api/v1/media_controller.rb @@ -3,30 +3,42 @@ 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 + before_action :set_media_attachment, except: [:create] + before_action :check_processing, except: [:create] def create - @media = current_account.media_attachments.create!(media_params) - render json: @media, serializer: REST::MediaAttachmentSerializer + @media_attachment = current_account.media_attachments.create!(media_attachment_params) + render json: @media_attachment, serializer: REST::MediaAttachmentSerializer rescue Paperclip::Errors::NotIdentifiedByImageMagickError render json: file_type_error, status: 422 rescue Paperclip::Error render json: processing_error, status: 500 end + def show + render json: @media_attachment, serializer: REST::MediaAttachmentSerializer, status: status_code_for_media_attachment + end + def update - @media = current_account.media_attachments.where(status_id: nil).find(params[:id]) - @media.update!(media_params) - render json: @media, serializer: REST::MediaAttachmentSerializer + @media_attachment.update!(media_attachment_params) + render json: @media_attachment, serializer: REST::MediaAttachmentSerializer, status: status_code_for_media_attachment end private - def media_params + def status_code_for_media_attachment + @media_attachment.not_processed? ? 206 : 200 + end + + def set_media_attachment + @media_attachment = current_account.media_attachments.unattached.find(params[:id]) + end + + def check_processing + render json: processing_error, status: 422 if @media_attachment.processing_failed? + end + + def media_attachment_params params.permit(:file, :description, :focus) end diff --git a/app/controllers/api/v1/mutes_controller.rb b/app/controllers/api/v1/mutes_controller.rb index df6c8e86c..65439fe9b 100644 --- a/app/controllers/api/v1/mutes_controller.rb +++ b/app/controllers/api/v1/mutes_controller.rb @@ -5,8 +5,6 @@ class Api::V1::MutesController < Api::BaseController before_action :require_user! after_action :insert_pagination_headers - respond_to :json - def index @accounts = load_accounts render json: @accounts, each_serializer: REST::AccountSerializer diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb index bf3002e79..8ac227765 100644 --- a/app/controllers/api/v1/notifications_controller.rb +++ b/app/controllers/api/v1/notifications_controller.rb @@ -6,8 +6,6 @@ class Api::V1::NotificationsController < Api::BaseController before_action :require_user! after_action :insert_pagination_headers, only: :index - respond_to :json - DEFAULT_NOTIFICATIONS_LIMIT = 15 def index diff --git a/app/controllers/api/v1/polls/votes_controller.rb b/app/controllers/api/v1/polls/votes_controller.rb index 3fa0b6a76..513b937ef 100644 --- a/app/controllers/api/v1/polls/votes_controller.rb +++ b/app/controllers/api/v1/polls/votes_controller.rb @@ -7,8 +7,6 @@ class Api::V1::Polls::VotesController < Api::BaseController before_action :require_user! before_action :set_poll - respond_to :json - def create VoteService.new.call(current_account, @poll, vote_params[:choices]) render json: @poll, serializer: REST::PollSerializer @@ -20,7 +18,7 @@ class Api::V1::Polls::VotesController < Api::BaseController @poll = Poll.attached.find(params[:poll_id]) authorize @poll.status, :show? rescue Mastodon::NotPermittedError - raise ActiveRecord::RecordNotFound + not_found end def vote_params diff --git a/app/controllers/api/v1/polls_controller.rb b/app/controllers/api/v1/polls_controller.rb index 031e6d42d..6435e9f0d 100644 --- a/app/controllers/api/v1/polls_controller.rb +++ b/app/controllers/api/v1/polls_controller.rb @@ -7,8 +7,6 @@ class Api::V1::PollsController < Api::BaseController before_action :set_poll before_action :refresh_poll - respond_to :json - def show render json: @poll, serializer: REST::PollSerializer, include_results: true end @@ -19,7 +17,7 @@ class Api::V1::PollsController < Api::BaseController @poll = Poll.attached.find(params[:id]) authorize @poll.status, :show? rescue Mastodon::NotPermittedError - raise ActiveRecord::RecordNotFound + not_found end def refresh_poll diff --git a/app/controllers/api/v1/preferences_controller.rb b/app/controllers/api/v1/preferences_controller.rb index 077d39f5d..1640a8224 100644 --- a/app/controllers/api/v1/preferences_controller.rb +++ b/app/controllers/api/v1/preferences_controller.rb @@ -4,8 +4,6 @@ class Api::V1::PreferencesController < Api::BaseController before_action -> { doorkeeper_authorize! :read, :'read:accounts' } before_action :require_user! - respond_to :json - def index render json: current_account, serializer: REST::PreferencesSerializer end diff --git a/app/controllers/api/v1/push/subscriptions_controller.rb b/app/controllers/api/v1/push/subscriptions_controller.rb index 1b658f870..d34b333eb 100644 --- a/app/controllers/api/v1/push/subscriptions_controller.rb +++ b/app/controllers/api/v1/push/subscriptions_controller.rb @@ -4,6 +4,7 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController before_action -> { doorkeeper_authorize! :push } before_action :require_user! before_action :set_web_push_subscription + before_action :check_web_push_subscription, only: [:show, :update] def create @web_subscription&.destroy! @@ -21,16 +22,11 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController end def show - raise ActiveRecord::RecordNotFound if @web_subscription.nil? - render json: @web_subscription, serializer: REST::WebPushSubscriptionSerializer end def update - raise ActiveRecord::RecordNotFound if @web_subscription.nil? - @web_subscription.update!(data: data_params) - render json: @web_subscription, serializer: REST::WebPushSubscriptionSerializer end @@ -45,12 +41,17 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController @web_subscription = ::Web::PushSubscription.find_by(access_token_id: doorkeeper_token.id) end + def check_web_push_subscription + not_found if @web_subscription.nil? + end + def subscription_params params.require(:subscription).permit(:endpoint, keys: [:auth, :p256dh]) end 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/reports_controller.rb b/app/controllers/api/v1/reports_controller.rb index 1b0b4b05b..e10083d45 100644 --- a/app/controllers/api/v1/reports_controller.rb +++ b/app/controllers/api/v1/reports_controller.rb @@ -4,7 +4,7 @@ class Api::V1::ReportsController < Api::BaseController before_action -> { doorkeeper_authorize! :write, :'write:reports' }, only: [:create] before_action :require_user! - respond_to :json + override_rate_limit_headers :create, family: :reports def create @report = ReportService.new.call( 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..3954af3c9 --- /dev/null +++ b/app/controllers/api/v1/statuses/bookmarks_controller.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class Api::V1::Statuses::BookmarksController < Api::BaseController + include Authorization + + before_action -> { doorkeeper_authorize! :write, :'write:bookmarks' } + before_action :require_user! + before_action :set_status + + def create + current_account.bookmarks.find_or_create_by!(account: current_account, status: @status) + render json: @status, serializer: REST::StatusSerializer + end + + def destroy + bookmark = current_account.bookmarks.find_by(status: @status) + bookmark&.destroy! + + render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_account.id, bookmarks_map: { @status.id => false }) + end + + private + + def set_status + @status = Status.find(params[:status_id]) + authorize @status, :show? + rescue Mastodon::NotPermittedError + not_found + 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..8229786d6 100644 --- a/app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb +++ b/app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb @@ -7,8 +7,6 @@ class Api::V1::Statuses::FavouritedByAccountsController < Api::BaseController before_action :set_status after_action :insert_pagination_headers - respond_to :json - def index @accounts = load_accounts render json: @accounts, each_serializer: REST::AccountSerializer @@ -17,7 +15,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 @@ -67,8 +67,7 @@ class Api::V1::Statuses::FavouritedByAccountsController < Api::BaseController @status = Status.find(params[:status_id]) authorize @status, :show? rescue Mastodon::NotPermittedError - # Reraise in order to get a 404 instead of a 403 error code - raise ActiveRecord::RecordNotFound + not_found end def pagination_params(core_params) diff --git a/app/controllers/api/v1/statuses/favourites_controller.rb b/app/controllers/api/v1/statuses/favourites_controller.rb index cceee9060..7afa822ed 100644 --- a/app/controllers/api/v1/statuses/favourites_controller.rb +++ b/app/controllers/api/v1/statuses/favourites_controller.rb @@ -5,34 +5,24 @@ class Api::V1::Statuses::FavouritesController < Api::BaseController before_action -> { doorkeeper_authorize! :write, :'write:favourites' } before_action :require_user! - - respond_to :json + before_action :set_status def create - @status = favourited_status + FavouriteService.new.call(current_account, @status) render json: @status, serializer: REST::StatusSerializer end def destroy - @status = requested_status - @favourites_map = { @status.id => false } - - UnfavouriteWorker.perform_async(current_user.account_id, @status.id) - - render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_user&.account_id, favourites_map: @favourites_map) + UnfavouriteWorker.perform_async(current_account.id, @status.id) + render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_account.id, favourites_map: { @status.id => false }) end private - def favourited_status - service_result.status.reload - end - - def service_result - FavouriteService.new.call(current_user.account, requested_status) - end - - def requested_status - Status.find(params[:status_id]) + def set_status + @status = Status.find(params[:status_id]) + authorize @status, :show? + rescue Mastodon::NotPermittedError + not_found end end diff --git a/app/controllers/api/v1/statuses/mutes_controller.rb b/app/controllers/api/v1/statuses/mutes_controller.rb index b02469b4f..87071a2b9 100644 --- a/app/controllers/api/v1/statuses/mutes_controller.rb +++ b/app/controllers/api/v1/statuses/mutes_controller.rb @@ -8,8 +8,6 @@ class Api::V1::Statuses::MutesController < Api::BaseController before_action :set_status before_action :set_conversation - respond_to :json - def create current_account.mute_conversation!(@conversation) @mutes_map = { @conversation.id => true } @@ -30,8 +28,7 @@ class Api::V1::Statuses::MutesController < Api::BaseController @status = Status.find(params[:status_id]) authorize @status, :show? rescue Mastodon::NotPermittedError - # Reraise in order to get a 404 instead of a 403 error code - raise ActiveRecord::RecordNotFound + not_found end def set_conversation diff --git a/app/controllers/api/v1/statuses/pins_controller.rb b/app/controllers/api/v1/statuses/pins_controller.rb index 4118a8ce4..51b1621b6 100644 --- a/app/controllers/api/v1/statuses/pins_controller.rb +++ b/app/controllers/api/v1/statuses/pins_controller.rb @@ -7,8 +7,6 @@ class Api::V1::Statuses::PinsController < Api::BaseController before_action :require_user! before_action :set_status - respond_to :json - def create StatusPin.create!(account: current_account, status: @status) distribute_add_activity! 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..6c9e49d90 100644 --- a/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb +++ b/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb @@ -7,8 +7,6 @@ class Api::V1::Statuses::RebloggedByAccountsController < Api::BaseController before_action :set_status after_action :insert_pagination_headers - respond_to :json - def index @accounts = load_accounts render json: @accounts, each_serializer: REST::AccountSerializer @@ -17,7 +15,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 @@ -64,8 +64,7 @@ class Api::V1::Statuses::RebloggedByAccountsController < Api::BaseController @status = Status.find(params[:status_id]) authorize @status, :show? rescue Mastodon::NotPermittedError - # Reraise in order to get a 404 instead of a 403 error code - raise ActiveRecord::RecordNotFound + not_found end def pagination_params(core_params) diff --git a/app/controllers/api/v1/statuses/reblogs_controller.rb b/app/controllers/api/v1/statuses/reblogs_controller.rb index 42381a37f..7fa774a4d 100644 --- a/app/controllers/api/v1/statuses/reblogs_controller.rb +++ b/app/controllers/api/v1/statuses/reblogs_controller.rb @@ -5,33 +5,35 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController before_action -> { doorkeeper_authorize! :write, :'write:statuses' } before_action :require_user! + before_action :set_reblog - respond_to :json + override_rate_limit_headers :create, family: :statuses def create - @status = ReblogService.new.call(current_user.account, status_for_reblog, reblog_params) + @status = ReblogService.new.call(current_account, @reblog, reblog_params) + render json: @status, serializer: REST::StatusSerializer end def destroy - @status = status_for_destroy.reblog - @reblogs_map = { @status.id => false } + @status = current_account.statuses.find_by(reblog_of_id: @reblog.id) - authorize status_for_destroy, :unreblog? - status_for_destroy.discard - RemovalWorker.perform_async(status_for_destroy.id) + if @status + authorize @status, :unreblog? + @status.discard + RemovalWorker.perform_async(@status.id) + end - render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_user&.account_id, reblogs_map: @reblogs_map) + render json: @reblog, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_account.id, reblogs_map: { @reblog.id => false }) end private - def status_for_reblog - Status.find params[:status_id] - end - - def status_for_destroy - @status_for_destroy ||= current_user.account.statuses.where(reblog_of_id: params[:status_id]).first! + def set_reblog + @reblog = Status.find(params[:status_id]) + authorize @reblog, :show? + rescue Mastodon::NotPermittedError + not_found end def reblog_params diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index cb454e286..4897f3e1f 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -7,8 +7,9 @@ class Api::V1::StatusesController < Api::BaseController before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:create, :destroy] before_action :require_user!, except: [:show, :context] before_action :set_status, only: [:show, :context] + before_action :set_thread, only: [:create] - respond_to :json + override_rate_limit_headers :create, family: :statuses # This API was originally unlimited, pagination cannot be introduced without # breaking backwards-compatibility. Arbitrarily high number to cover most @@ -36,7 +37,7 @@ class Api::V1::StatusesController < Api::BaseController def create @status = PostStatusService.new.call(current_user.account, text: status_params[:status], - thread: status_params[:in_reply_to_id].blank? ? nil : Status.find(status_params[:in_reply_to_id]), + thread: @thread, media_ids: status_params[:media_ids], sensitive: status_params[:sensitive], spoiler_text: status_params[:spoiler_text], @@ -45,7 +46,8 @@ class Api::V1::StatusesController < Api::BaseController application: doorkeeper_token.application, poll: status_params[:poll], idempotency: request.headers['Idempotency-Key'], - local_only: status_params[:local_only]) + local_only: status_params[:local_only], + with_rate_limit: true) render json: @status, serializer: @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer end @@ -66,7 +68,13 @@ class Api::V1::StatusesController < Api::BaseController @status = Status.find(params[:id]) authorize @status, :show? rescue Mastodon::NotPermittedError - raise ActiveRecord::RecordNotFound + not_found + end + + def set_thread + @thread = status_params[:in_reply_to_id].blank? ? nil : Status.find(status_params[:in_reply_to_id]) + rescue ActiveRecord::RecordNotFound + render json: { error: I18n.t('statuses.errors.in_reply_not_found') }, status: 404 end def status_params diff --git a/app/controllers/api/v1/streaming_controller.rb b/app/controllers/api/v1/streaming_controller.rb index ebb17608c..7cd60615a 100644 --- a/app/controllers/api/v1/streaming_controller.rb +++ b/app/controllers/api/v1/streaming_controller.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true class Api::V1::StreamingController < Api::BaseController - respond_to :json - def index if Rails.configuration.x.streaming_api_base_url != request.host redirect_to streaming_api_url, status: 301 diff --git a/app/controllers/api/v1/suggestions_controller.rb b/app/controllers/api/v1/suggestions_controller.rb index 9da2b60ae..52054160d 100644 --- a/app/controllers/api/v1/suggestions_controller.rb +++ b/app/controllers/api/v1/suggestions_controller.rb @@ -7,8 +7,6 @@ class Api::V1::SuggestionsController < Api::BaseController before_action :require_user! before_action :set_accounts - respond_to :json - def index render json: @accounts, each_serializer: REST::AccountSerializer end diff --git a/app/controllers/api/v1/timelines/home_controller.rb b/app/controllers/api/v1/timelines/home_controller.rb index ff5ede138..ae6dbcb8b 100644 --- a/app/controllers/api/v1/timelines/home_controller.rb +++ b/app/controllers/api/v1/timelines/home_controller.rb @@ -5,8 +5,6 @@ class Api::V1::Timelines::HomeController < Api::BaseController before_action :require_user!, only: [:show] after_action :insert_pagination_headers, unless: -> { @statuses.empty? } - respond_to :json - def show @statuses = load_statuses diff --git a/app/controllers/api/v1/timelines/public_controller.rb b/app/controllers/api/v1/timelines/public_controller.rb index ccc10f966..c6e7854d9 100644 --- a/app/controllers/api/v1/timelines/public_controller.rb +++ b/app/controllers/api/v1/timelines/public_controller.rb @@ -4,8 +4,6 @@ class Api::V1::Timelines::PublicController < Api::BaseController before_action :require_user!, only: [:show], if: :require_auth? after_action :insert_pagination_headers, unless: -> { @statuses.empty? } - respond_to :json - def show @statuses = load_statuses render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) @@ -41,7 +39,7 @@ class Api::V1::Timelines::PublicController < Api::BaseController end def public_timeline_statuses - Status.as_public_timeline(current_account, truthy_param?(:local)) + Status.as_public_timeline(current_account, truthy_param?(:remote) ? :remote : truthy_param?(:local)) end def insert_pagination_headers @@ -49,7 +47,7 @@ class Api::V1::Timelines::PublicController < Api::BaseController end def pagination_params(core_params) - params.slice(:local, :limit, :only_media).permit(:local, :limit, :only_media).merge(core_params) + params.slice(:local, :remote, :limit, :only_media).permit(:local, :remote, :limit, :only_media).merge(core_params) end def next_path diff --git a/app/controllers/api/v1/timelines/tag_controller.rb b/app/controllers/api/v1/timelines/tag_controller.rb index 9adc4ad29..2d6ad5a80 100644 --- a/app/controllers/api/v1/timelines/tag_controller.rb +++ b/app/controllers/api/v1/timelines/tag_controller.rb @@ -4,8 +4,6 @@ class Api::V1::Timelines::TagController < Api::BaseController before_action :load_tag after_action :insert_pagination_headers, unless: -> { @statuses.empty? } - respond_to :json - def show @statuses = load_statuses render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) diff --git a/app/controllers/api/v1/trends_controller.rb b/app/controllers/api/v1/trends_controller.rb index bcea9857e..c875e9041 100644 --- a/app/controllers/api/v1/trends_controller.rb +++ b/app/controllers/api/v1/trends_controller.rb @@ -3,8 +3,6 @@ class Api::V1::TrendsController < Api::BaseController before_action :set_tags - respond_to :json - def index render json: @tags, each_serializer: REST::TagSerializer end diff --git a/app/controllers/api/v2/media_controller.rb b/app/controllers/api/v2/media_controller.rb new file mode 100644 index 000000000..0c1baf01d --- /dev/null +++ b/app/controllers/api/v2/media_controller.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class Api::V2::MediaController < Api::V1::MediaController + def create + @media_attachment = current_account.media_attachments.create!({ delay_processing: true }.merge(media_attachment_params)) + render json: @media_attachment, serializer: REST::MediaAttachmentSerializer, status: 202 + rescue Paperclip::Errors::NotIdentifiedByImageMagickError + render json: file_type_error, status: 422 + rescue Paperclip::Error + render json: processing_error, status: 500 + end +end diff --git a/app/controllers/api/v2/search_controller.rb b/app/controllers/api/v2/search_controller.rb index cbd9b551d..f17431dd1 100644 --- a/app/controllers/api/v2/search_controller.rb +++ b/app/controllers/api/v2/search_controller.rb @@ -8,8 +8,6 @@ class Api::V2::SearchController < Api::BaseController before_action -> { doorkeeper_authorize! :read, :'read:search' } before_action :require_user! - respond_to :json - def index @search = Search.new(search_results) render json: @search, serializer: REST::SearchSerializer diff --git a/app/controllers/api/web/embeds_controller.rb b/app/controllers/api/web/embeds_controller.rb index 6231733b7..741ba910f 100644 --- a/app/controllers/api/web/embeds_controller.rb +++ b/app/controllers/api/web/embeds_controller.rb @@ -1,21 +1,25 @@ # frozen_string_literal: true class Api::Web::EmbedsController < Api::Web::BaseController - respond_to :json - before_action :require_user! 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..7916b82fa 100644 --- a/app/controllers/api/web/push_subscriptions_controller.rb +++ b/app/controllers/api/web/push_subscriptions_controller.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true class Api::Web::PushSubscriptionsController < Api::Web::BaseController - respond_to :json - before_action :require_user! def create @@ -19,6 +17,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 +57,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/api/web/settings_controller.rb b/app/controllers/api/web/settings_controller.rb index e3178bf48..3d65e46ed 100644 --- a/app/controllers/api/web/settings_controller.rb +++ b/app/controllers/api/web/settings_controller.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true class Api::Web::SettingsController < Api::Web::BaseController - respond_to :json - before_action :require_user! def update diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index bd3d13774..973db6aca 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -24,10 +24,12 @@ 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 rescue_from Mastodon::RaceConditionError, with: :service_unavailable + rescue_from Mastodon::RateLimitExceededError, with: :too_many_requests before_action :store_current_location, except: :raise_not_found, unless: :devise_controller? before_action :require_functional!, if: :user_signed_in? @@ -110,6 +112,10 @@ class ApplicationController < ActionController::Base respond_with_error(503) end + def too_many_requests + respond_with_error(429) + end + def single_user_mode? @single_user_mode ||= Rails.configuration.x.single_user_mode && Account.where('id > 0').exists? end @@ -137,8 +143,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 019caf9c1..78feb1631 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 @@ -33,7 +41,6 @@ class Auth::RegistrationsController < Devise::RegistrationsController resource.locale = I18n.locale resource.invite_code = params[:invite_code] if resource.invite_code.blank? - resource.agreement = true resource.current_sign_in_ip = request.remote_ip resource.build_account if resource.account.nil? @@ -41,7 +48,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController def configure_sign_up_params devise_parameter_sanitizer.permit(:sign_up) do |u| - u.permit({ account_attributes: [:username], invite_request_attributes: [:text] }, :email, :password, :password_confirmation, :invite_code) + u.permit({ account_attributes: [:username], invite_request_attributes: [:text] }, :email, :password, :password_confirmation, :invite_code, :agreement) end end @@ -109,4 +116,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/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb index f48b17c79..e95909447 100644 --- a/app/controllers/auth/sessions_controller.rb +++ b/app/controllers/auth/sessions_controller.rb @@ -111,6 +111,13 @@ class Auth::SessionsController < Devise::SessionsController render :two_factor end + def require_no_authentication + super + # Delete flash message that isn't entirely useful and may be confusing in + # most cases because /web doesn't display/clear flash messages. + flash.delete(:alert) if flash[:alert] == I18n.t('devise.failure.already_authenticated') + end + private def set_instance_presenter diff --git a/app/controllers/authorize_interactions_controller.rb b/app/controllers/authorize_interactions_controller.rb index e27366ea3..29c0288d0 100644 --- a/app/controllers/authorize_interactions_controller.rb +++ b/app/controllers/authorize_interactions_controller.rb @@ -20,7 +20,7 @@ class AuthorizeInteractionsController < ApplicationController end def create - if @resource.is_a?(Account) && FollowService.new.call(current_account, @resource) + if @resource.is_a?(Account) && FollowService.new.call(current_account, @resource, with_rate_limit: true) render :success else render :error diff --git a/app/controllers/concerns/localized.rb b/app/controllers/concerns/localized.rb index b43859d9d..d1384ed56 100644 --- a/app/controllers/concerns/localized.rb +++ b/app/controllers/concerns/localized.rb @@ -28,18 +28,6 @@ module Localized end def request_locale - preferred_locale || compatible_locale - end - - def preferred_locale - http_accept_language.preferred_language_from(available_locales) - end - - def compatible_locale - http_accept_language.compatible_language_from(available_locales) - end - - def available_locales - I18n.available_locales.reverse + http_accept_language.language_region_compatible_from(I18n.available_locales) 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/rate_limit_headers.rb b/app/controllers/concerns/rate_limit_headers.rb index b79c558d8..86fe58a71 100644 --- a/app/controllers/concerns/rate_limit_headers.rb +++ b/app/controllers/concerns/rate_limit_headers.rb @@ -3,6 +3,20 @@ module RateLimitHeaders extend ActiveSupport::Concern + class_methods do + def override_rate_limit_headers(method_name, options = {}) + around_action(only: method_name, if: :current_account) do |_controller, block| + begin + block.call + ensure + rate_limiter = RateLimiter.new(current_account, options) + rate_limit_headers = rate_limiter.to_headers + response.headers.merge!(rate_limit_headers) unless response.headers['X-RateLimit-Remaining'].present? && rate_limit_headers['X-RateLimit-Remaining'].to_i > response.headers['X-RateLimit-Remaining'].to_i + end + end + end + end + included do before_action :set_rate_limit_headers, if: :rate_limited_request? end @@ -44,7 +58,7 @@ module RateLimitHeaders end def api_throttle_data - most_limited_type, = request.env['rack.attack.throttle_data'].min_by { |_, v| v[:limit] } + most_limited_type, = request.env['rack.attack.throttle_data'].min_by { |_, v| v[:limit] - v[:count] } request.env['rack.attack.throttle_data'][most_limited_type] 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..14e22dd1e 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 @@ -29,7 +28,8 @@ class FollowerAccountsController < ApplicationController render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, - content_type: 'application/activity+json' + content_type: 'application/activity+json', + fields: restrict_fields_to end end end @@ -37,7 +37,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? @@ -68,4 +72,12 @@ class FollowerAccountsController < ApplicationController ) end end + + def restrict_fields_to + if page_requested? || !@account.user_hides_network? + # Return all fields + else + %i(id type totalItems) + end + end end diff --git a/app/controllers/following_accounts_controller.rb b/app/controllers/following_accounts_controller.rb index 968de980d..95849ffb9 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 @@ -29,7 +28,8 @@ class FollowingAccountsController < ApplicationController render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, - content_type: 'application/activity+json' + content_type: 'application/activity+json', + fields: restrict_fields_to end end end @@ -37,7 +37,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? @@ -68,4 +72,12 @@ class FollowingAccountsController < ApplicationController ) end end + + def restrict_fields_to + if page_requested? || !@account.user_hides_network? + # Return all fields + else + %i(id type totalItems) + end + end end diff --git a/app/controllers/media_controller.rb b/app/controllers/media_controller.rb index 05cf09c28..1d166d6e7 100644 --- a/app/controllers/media_controller.rb +++ b/app/controllers/media_controller.rb @@ -33,7 +33,7 @@ class MediaController < ApplicationController def verify_permitted_status! authorize @media_attachment.status, :show? rescue Mastodon::NotPermittedError - raise ActiveRecord::RecordNotFound + not_found end def check_playable 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/remote_interaction_controller.rb b/app/controllers/remote_interaction_controller.rb index 4073e7ac3..3b9202a5c 100644 --- a/app/controllers/remote_interaction_controller.rb +++ b/app/controllers/remote_interaction_controller.rb @@ -41,7 +41,7 @@ class RemoteInteractionController < ApplicationController @status = Status.find(params[:id]) authorize @status, :show? rescue Mastodon::NotPermittedError - raise ActiveRecord::RecordNotFound + not_found end def set_body_classes 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/identity_proofs_controller.rb b/app/controllers/settings/identity_proofs_controller.rb index a749d8020..3a90b7c4d 100644 --- a/app/controllers/settings/identity_proofs_controller.rb +++ b/app/controllers/settings/identity_proofs_controller.rb @@ -21,8 +21,7 @@ class Settings::IdentityProofsController < Settings::BaseController if current_account.username.casecmp(params[:username]).zero? render layout: 'auth' else - flash[:alert] = I18n.t('identity_proofs.errors.wrong_user', proving: params[:username], current: current_account.username) - redirect_to settings_identity_proofs_path + redirect_to settings_identity_proofs_path, alert: I18n.t('identity_proofs.errors.wrong_user', proving: params[:username], current: current_account.username) end end @@ -34,11 +33,16 @@ class Settings::IdentityProofsController < Settings::BaseController PostStatusService.new.call(current_user.account, text: post_params[:status_text]) if publish_proof? redirect_to @proof.on_success_path(params[:user_agent]) else - flash[:alert] = I18n.t('identity_proofs.errors.failed', provider: @proof.provider.capitalize) - redirect_to settings_identity_proofs_path + redirect_to settings_identity_proofs_path, alert: I18n.t('identity_proofs.errors.failed', provider: @proof.provider.capitalize) end end + def destroy + @proof = current_account.identity_proofs.find(params[:id]) + @proof.destroy! + redirect_to settings_identity_proofs_path, success: I18n.t('identity_proofs.removed') + end + private def check_required_params diff --git a/app/controllers/settings/imports_controller.rb b/app/controllers/settings/imports_controller.rb index 38f2e39c1..7b8c4ae23 100644 --- a/app/controllers/settings/imports_controller.rb +++ b/app/controllers/settings/imports_controller.rb @@ -29,6 +29,6 @@ class Settings::ImportsController < Settings::BaseController end def import_params - params.require(:import).permit(:data, :type) + params.require(:import).permit(:data, :type, :mode) end end diff --git a/app/controllers/settings/pictures_controller.rb b/app/controllers/settings/pictures_controller.rb new file mode 100644 index 000000000..73926707b --- /dev/null +++ b/app/controllers/settings/pictures_controller.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Settings + class PicturesController < BaseController + before_action :authenticate_user! + before_action :set_account + before_action :set_picture + + def destroy + if valid_picture + account_params = { + @picture => nil, + (@picture + '_remote_url') => nil, + } + + msg = UpdateAccountService.new.call(@account, account_params) ? I18n.t('generic.changes_saved_msg') : nil + redirect_to settings_profile_path, notice: msg, status: 303 + else + bad_request + end + end + + private + + def set_account + @account = current_account + end + + def set_picture + @picture = params[:id] + end + + def valid_picture + @picture == 'avatar' || @picture == 'header' + end + end +end diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb index 12b11c542..e0c878858 100644 --- a/app/controllers/settings/preferences_controller.rb +++ b/app/controllers/settings/preferences_controller.rb @@ -58,6 +58,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..d362b97dc 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? || @status.reblog? 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..da0add71a 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -10,6 +10,7 @@ class TagsController < ApplicationController before_action :require_signature!, if: -> { request.format == :json && authorized_fetch_mode? } before_action :authenticate_user!, if: :whitelist_mode? before_action :set_tag + before_action :set_local before_action :set_body_classes before_action :set_instance_presenter @@ -24,7 +25,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, nil, @local).limit(PAGE_SIZE) @statuses = cache_collection(@statuses, Status) render xml: RSS::TagSerializer.render(@tag, @statuses) @@ -33,7 +34,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, @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' @@ -47,6 +48,10 @@ class TagsController < ApplicationController @tag = Tag.usable.find_normalized!(params[:id]) end + def set_local + @local = truthy_param?(:local) + end + def set_body_classes @body_classes = 'with-modals' end @@ -57,10 +62,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..8e398c3b2 100644 --- a/app/helpers/admin/action_logs_helper.rb +++ b/app/helpers/admin/action_logs_helper.rb @@ -9,73 +9,8 @@ module Admin::ActionLogsHelper end end - def relevant_log_changes(log) - if log.target_type == 'CustomEmoji' && [:enable, :disable, :destroy].include?(log.action) - log.recorded_changes.slice('domain') - elsif log.target_type == 'CustomEmoji' && log.action == :update - log.recorded_changes.slice('domain', 'visible_in_picker') - elsif log.target_type == 'User' && [:promote, :demote].include?(log.action) - log.recorded_changes.slice('moderator', 'admin') - elsif log.target_type == 'User' && [:change_email].include?(log.action) - log.recorded_changes.slice('email', 'unconfirmed_email') - elsif log.target_type == 'DomainBlock' - log.recorded_changes.slice('severity', 'reject_media') - elsif log.target_type == 'Status' && log.action == :update - log.recorded_changes.slice('sensitive') - end - end - - def log_extra_attributes(hash) - safe_join(hash.to_a.map { |key, value| safe_join([content_tag(:span, key, class: 'diff-key'), '=', log_change(value)]) }, ' ') - end - - def log_change(val) - return content_tag(:span, val, class: 'diff-neutral') unless val.is_a?(Array) - safe_join([content_tag(:span, val.first, class: 'diff-old'), content_tag(:span, val.last, class: 'diff-new')], '→') - end - - def icon_for_log(log) - case log.target_type - when 'Account', 'User' - 'user' - when 'CustomEmoji' - 'file' - when 'Report' - 'flag' - when 'DomainBlock' - 'lock' - when 'EmailDomainBlock' - 'envelope' - when 'Status' - 'pencil' - when 'AccountWarning' - 'warning' - end - end - - def class_for_log_icon(log) - case log.action - when :enable, :unsuspend, :unsilence, :confirm, :promote, :resolve - 'positive' - when :create - opposite_verbs?(log) ? 'negative' : 'positive' - when :update, :reset_password, :disable_2fa, :memorialize, :change_email - 'neutral' - when :demote, :silence, :disable, :suspend, :remove_avatar, :remove_header, :reopen - 'negative' - when :destroy - opposite_verbs?(log) ? 'positive' : 'negative' - else - '' - end - end - private - def opposite_verbs?(log) - %w(DomainBlock EmailDomainBlock AccountWarning).include?(log.target_type) - end - def linkable_log_target(record) case record.class.name when 'Account' @@ -86,12 +21,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 truncate(record.text), edit_admin_announcement_path(record.id) end end @@ -99,7 +36,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 +46,8 @@ module Admin::ActionLogsHelper else I18n.t('admin.action_logs.deleted_status') end + when 'Announcement' + truncate(attributes['text'].is_a?(Array) ? attributes['text'].last : attributes['text']) 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..ba0ca9638 100644 --- a/app/helpers/admin/filter_helper.rb +++ b/app/helpers/admin/filter_helper.rb @@ -1,15 +1,17 @@ # 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, + Admin::ActionLogFilter::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/admin/settings_helper.rb b/app/helpers/admin/settings_helper.rb new file mode 100644 index 000000000..baf14ab25 --- /dev/null +++ b/app/helpers/admin/settings_helper.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Admin::SettingsHelper + def site_upload_delete_hint(hint, var) + upload = SiteUpload.find_by(var: var.to_s) + return hint unless upload + + link = link_to t('admin.site_uploads.delete'), admin_site_upload_path(upload), data: { method: :delete } + safe_join([hint, link], '
'.html_safe) + end +end 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/home_helper.rb b/app/helpers/home_helper.rb index b66e827fe..4da68500a 100644 --- a/app/helpers/home_helper.rb +++ b/app/helpers/home_helper.rb @@ -7,13 +7,13 @@ module HomeHelper } end - def account_link_to(account, button = '', size: 36, path: nil) + def account_link_to(account, button = '', path: nil) content_tag(:div, class: 'account') do content_tag(:div, class: 'account__wrapper') do section = if account.nil? content_tag(:div, class: 'account__display-name') do content_tag(:div, class: 'account__avatar-wrapper') do - content_tag(:div, '', class: 'account__avatar', style: "width: #{size}px; height: #{size}px; background-size: #{size}px #{size}px; background-image: url(#{full_asset_url('avatars/original/missing.png', skip_pipeline: true)})") + image_tag(full_asset_url('avatars/original/missing.png', skip_pipeline: true), class: 'account__avatar') end + content_tag(:span, class: 'display-name') do content_tag(:strong, t('about.contact_missing')) + @@ -23,7 +23,7 @@ module HomeHelper else link_to(path || ActivityPub::TagManager.instance.url_for(account), class: 'account__display-name') do content_tag(:div, class: 'account__avatar-wrapper') do - content_tag(:div, '', class: 'account__avatar', style: "width: #{size}px; height: #{size}px; background-size: #{size}px #{size}px; background-image: url(#{full_asset_url(current_account&.user&.setting_auto_play_gif ? account.avatar_original_url : account.avatar_static_url)})") + image_tag(full_asset_url(current_account&.user&.setting_auto_play_gif ? account.avatar_original_url : account.avatar_static_url), class: 'account__avatar') end + content_tag(:span, class: 'display-name') do content_tag(:bdi) do 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..87718dc05 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,8 @@ module SettingsHelper th: 'ไทย', tr: 'Türkçe', uk: 'Українська', + ur: 'اُردُو', + vi: 'Tiếng Việt', 'zh-CN': '简体中文', 'zh-HK': '繁體中文(香港)', 'zh-TW': '繁體中文(臺灣)', @@ -100,4 +106,13 @@ module SettingsHelper safe_join([image_tag(account.avatar.url, width: 15, height: 15, alt: display_name(account), class: 'avatar'), content_tag(:span, account.acct, class: 'username')], ' ') end end + + def picture_hint(hint, picture) + if picture.original_filename.nil? + hint + else + link = link_to t('generic.delete'), settings_profile_picture_path(picture.name.to_s), data: { method: :delete } + safe_join([hint, link], '
'.html_safe) + end + end end diff --git a/app/helpers/statuses_helper.rb b/app/helpers/statuses_helper.rb index 3fd724472..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([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([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([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([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' => '#hometownlogo'), 'viewBox' => '0 0 100 100') - 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/helpers/webfinger_helper.rb b/app/helpers/webfinger_helper.rb new file mode 100644 index 000000000..70c493210 --- /dev/null +++ b/app/helpers/webfinger_helper.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module WebfingerHelper + def webfinger!(uri) + hidden_service_uri = /\.(onion|i2p)(:\d+)?$/.match(uri) + + raise Mastodon::HostValidationError, 'Instance does not support hidden service connections' if !Rails.configuration.x.access_to_hidden_service && hidden_service_uri + + opts = { + ssl: !hidden_service_uri, + + headers: { + 'User-Agent': Mastodon::Version.user_agent, + }, + } + + Goldfinger::Client.new(uri, opts.merge(Rails.configuration.x.http_client_proxy)).finger + end +end 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/images/logo_transparent_white.svg b/app/javascript/images/logo_transparent_white.svg new file mode 100644 index 000000000..f061ffe4c --- /dev/null +++ b/app/javascript/images/logo_transparent_white.svg @@ -0,0 +1 @@ + diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js index d4a824e2c..cb2c682a4 100644 --- a/app/javascript/mastodon/actions/accounts.js +++ b/app/javascript/mastodon/actions/accounts.js @@ -106,7 +106,7 @@ export function fetchAccount(id) { dispatch, getState, db.transaction('accounts', 'read').objectStore('accounts').index('id'), - id + id, ).then(() => db.close(), error => { db.close(); throw error; @@ -396,6 +396,7 @@ export function fetchFollowersFail(id, error) { type: FOLLOWERS_FETCH_FAIL, id, error, + skipNotFound: true, }; }; @@ -482,6 +483,7 @@ export function fetchFollowingFail(id, error) { type: FOLLOWING_FETCH_FAIL, id, error, + skipNotFound: true, }; }; @@ -571,6 +573,7 @@ export function fetchRelationshipsFail(error) { type: RELATIONSHIPS_FETCH_FAIL, error, skipLoading: true, + skipNotFound: true, }; }; diff --git a/app/javascript/mastodon/actions/alerts.js b/app/javascript/mastodon/actions/alerts.js index cd36d8007..1670f9c10 100644 --- a/app/javascript/mastodon/actions/alerts.js +++ b/app/javascript/mastodon/actions/alerts.js @@ -34,11 +34,11 @@ export function showAlert(title = messages.unexpectedTitle, message = messages.u }; }; -export function showAlertForError(error) { +export function showAlertForError(error, skipNotFound = false) { if (error.response) { const { data, status, statusText, headers } = error.response; - if (status === 404 || status === 410) { + if (skipNotFound && (status === 404 || status === 410)) { // Skip these errors as they are reflected in the UI return { type: ALERT_NOOP }; } 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 2dcb81035..1127a95f5 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -207,10 +207,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; } @@ -231,12 +232,31 @@ export function uploadCompose(files) { // Account for disparity in size of original image and resized data total += file.size - f.size; - return api(getState).post('/api/v1/media', data, { + return api(getState).post('/api/v2/media', data, { onUploadProgress: function({ loaded }){ progress[i] = loaded; dispatch(uploadComposeProgress(progress.reduce((a, v) => a + v, 0), total)); }, - }).then(({ data }) => dispatch(uploadComposeSuccess(data, f))); + }).then(({ status, data }) => { + // If server-side processing of the media attachment has not completed yet, + // poll the server until it is, before showing the media attachment as uploaded + + if (status === 200) { + dispatch(uploadComposeSuccess(data, f)); + } else if (status === 202) { + const poll = () => { + api(getState).get(`/api/v1/media/${data.id}`).then(response => { + if (response.status === 200) { + dispatch(uploadComposeSuccess(response.data, f)); + } else if (response.status === 206) { + setTimeout(() => poll(), 1000); + } + }).catch(error => dispatch(uploadComposeFail(error))); + }; + + poll(); + } + }); }).catch(error => dispatch(uploadComposeFail(error))); }; }; diff --git a/app/javascript/mastodon/actions/identity_proofs.js b/app/javascript/mastodon/actions/identity_proofs.js index 449debf61..103983956 100644 --- a/app/javascript/mastodon/actions/identity_proofs.js +++ b/app/javascript/mastodon/actions/identity_proofs.js @@ -27,4 +27,5 @@ export const fetchAccountIdentityProofsFail = (accountId, err) => ({ type: IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL, accountId, err, + skipNotFound: true, }); 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..080d665f4 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,12 +66,14 @@ 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); export const connectCommunityStream = ({ onlyMedia } = {}) => connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`); -export const connectPublicStream = ({ onlyMedia } = {}) => connectTimelineStream(`public${onlyMedia ? ':media' : ''}`, `public${onlyMedia ? ':media' : ''}`); +export const connectPublicStream = ({ onlyMedia, onlyRemote } = {}) => connectTimelineStream(`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, `public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`); export const connectHashtagStream = (id, tag, accept) => connectTimelineStream(`hashtag:${id}`, `hashtag&tag=${tag}`, null, accept); export const connectDirectStream = () => connectTimelineStream('direct', 'direct'); export const connectListStream = id => connectTimelineStream(`list:${id}`, `list&list=${id}`); diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index bc2ac5e82..01f0fb015 100644 --- a/app/javascript/mastodon/actions/timelines.js +++ b/app/javascript/mastodon/actions/timelines.js @@ -42,7 +42,7 @@ export function updateTimeline(timeline, status, accept) { export function deleteFromTimelines(id) { return (dispatch, getState) => { const accountId = getState().getIn(['statuses', id, 'account']); - const references = getState().get('statuses').filter(status => status.get('reblog') === id).map(status => [status.get('id'), status.get('account')]); + const references = getState().get('statuses').filter(status => status.get('reblog') === id).map(status => status.get('id')); const reblogOf = getState().getIn(['statuses', id, 'reblog'], null); dispatch({ @@ -98,27 +98,28 @@ 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(); }); }; }; export const expandHomeTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }, done); -export const expandPublicTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`public${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { max_id: maxId, only_media: !!onlyMedia }, done); +export const expandPublicTimeline = ({ maxId, onlyMedia, onlyRemote } = {}, done = noOp) => expandTimeline(`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, max_id: maxId, only_media: !!onlyMedia }, done); export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }, done); export const expandAccountTimeline = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId }); export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true }); export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 }); export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done); -export const expandHashtagTimeline = (hashtag, { maxId, tags } = {}, done = noOp) => { +export const expandHashtagTimeline = (hashtag, { maxId, tags, local } = {}, done = noOp) => { return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, { max_id: maxId, any: parseTags(tags, 'any'), all: parseTags(tags, 'all'), none: parseTags(tags, 'none'), + local: local, }, done); }; @@ -149,6 +150,7 @@ export function expandTimelineFail(timeline, error, isLoadingMore) { timeline, error, skipLoading: !isLoadingMore, + skipNotFound: timeline.startsWith('account:'), }; }; diff --git a/app/javascript/mastodon/base_polyfills.js b/app/javascript/mastodon/base_polyfills.js index 997813a04..12096d902 100644 --- a/app/javascript/mastodon/base_polyfills.js +++ b/app/javascript/mastodon/base_polyfills.js @@ -6,6 +6,7 @@ import assign from 'object-assign'; import values from 'object.values'; import isNaN from 'is-nan'; import { decode as decodeBase64 } from './utils/base64'; +import promiseFinally from 'promise.prototype.finally'; if (!Array.prototype.includes) { includes.shim(); @@ -23,6 +24,8 @@ if (!Number.isNaN) { Number.isNaN = isNaN; } +promiseFinally.shim(); + if (!HTMLCanvasElement.prototype.toBlob) { const BASE64_MARKER = ';base64,'; diff --git a/app/javascript/mastodon/common.js b/app/javascript/mastodon/common.js index fba21316a..6818aa5d5 100644 --- a/app/javascript/mastodon/common.js +++ b/app/javascript/mastodon/common.js @@ -1,4 +1,4 @@ -import Rails from 'rails-ujs'; +import Rails from '@rails/ujs'; export function start() { require('font-awesome/css/font-awesome.css'); 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..1bb583583 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 = { @@ -75,13 +76,14 @@ class ColumnHeader extends React.PureComponent { handlePin = () => { if (!this.props.pinned) { - this.historyBack(); + this.context.router.history.replace('/'); } + this.props.onPin(); } 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 +174,8 @@ class ColumnHeader extends React.PureComponent { {(!collapsed || animating) && collapsedContent} + + {appendContent} ); diff --git a/app/javascript/mastodon/components/domain.js b/app/javascript/mastodon/components/domain.js index 85729ca94..697065d87 100644 --- a/app/javascript/mastodon/components/domain.js +++ b/app/javascript/mastodon/components/domain.js @@ -5,7 +5,7 @@ import { defineMessages, injectIntl } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; const messages = defineMessages({ - unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' }, + unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' }, }); export default @injectIntl diff --git a/app/javascript/mastodon/components/dropdown_menu.js b/app/javascript/mastodon/components/dropdown_menu.js index d423378c1..4734e0f3f 100644 --- a/app/javascript/mastodon/components/dropdown_menu.js +++ b/app/javascript/mastodon/components/dropdown_menu.js @@ -46,7 +46,7 @@ class DropdownMenu extends React.PureComponent { document.addEventListener('keydown', this.handleKeyDown, false); document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); if (this.focusedItem && this.props.openedViaKeyboard) { - this.focusedItem.focus(); + this.focusedItem.focus({ preventScroll: true }); } this.setState({ mounted: true }); } @@ -68,20 +68,14 @@ class DropdownMenu extends React.PureComponent { handleKeyDown = e => { const items = Array.from(this.node.getElementsByTagName('a')); const index = items.indexOf(document.activeElement); - let element; + let element = null; switch(e.key) { case 'ArrowDown': - element = items[index+1]; - if (element) { - element.focus(); - } + element = items[index+1] || items[0]; break; case 'ArrowUp': - element = items[index-1]; - if (element) { - element.focus(); - } + element = items[index-1] || items[items.length-1]; break; case 'Tab': if (e.shiftKey) { @@ -89,28 +83,23 @@ class DropdownMenu extends React.PureComponent { } else { element = items[index+1] || items[0]; } - if (element) { - element.focus(); - e.preventDefault(); - e.stopPropagation(); - } break; case 'Home': element = items[0]; - if (element) { - element.focus(); - } break; case 'End': element = items[items.length-1]; - if (element) { - element.focus(); - } break; case 'Escape': this.props.onClose(); break; } + + if (element) { + element.focus(); + e.preventDefault(); + e.stopPropagation(); + } } handleItemKeyPress = e => { @@ -143,7 +132,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..ca3012276 100644 --- a/app/javascript/mastodon/components/error_boundary.js +++ b/app/javascript/mastodon/components/error_boundary.js @@ -2,6 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage } from 'react-intl'; import { version, source_url } from 'mastodon/initial_state'; +import StackTrace from 'stacktrace-js'; export default class ErrorBoundary extends React.PureComponent { @@ -11,24 +12,42 @@ export default class ErrorBoundary extends React.PureComponent { state = { hasError: false, + errorMessage: undefined, stackTrace: undefined, + mappedStackTrace: undefined, componentStack: undefined, }; componentDidCatch (error, info) { this.setState({ hasError: true, + errorMessage: error.toString(), stackTrace: error.stack, componentStack: info && info.componentStack, - copied: false, + mappedStackTrace: undefined, + }); + + StackTrace.fromError(error).then((stackframes) => { + this.setState({ + mappedStackTrace: stackframes.map((sf) => sf.toString()).join('\n'), + }); + }).catch(() => { + this.setState({ + mappedStackTrace: undefined, + }); }); } handleCopyStackTrace = () => { - const { stackTrace } = this.state; + const { errorMessage, stackTrace, mappedStackTrace } = this.state; const textarea = document.createElement('textarea'); - textarea.textContent = stackTrace; + let contents = [errorMessage, stackTrace]; + if (mappedStackTrace) { + contents.push(mappedStackTrace); + } + + textarea.textContent = contents.join('\n\n\n'); textarea.style.position = 'fixed'; document.body.appendChild(textarea); @@ -58,7 +77,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/intersection_observer_article.js b/app/javascript/mastodon/components/intersection_observer_article.js index e453730ba..124b34b02 100644 --- a/app/javascript/mastodon/components/intersection_observer_article.js +++ b/app/javascript/mastodon/components/intersection_observer_article.js @@ -44,7 +44,7 @@ export default class IntersectionObserverArticle extends React.Component { intersectionObserverWrapper.observe( id, this.node, - this.handleIntersection + this.handleIntersection, ); this.componentMounted = true; diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js index e8dd79af9..283d7e0a5 100644 --- a/app/javascript/mastodon/components/media_gallery.js +++ b/app/javascript/mastodon/components/media_gallery.js @@ -6,11 +6,11 @@ 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({ - toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' }, + toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Hide media' }, }); class Item extends React.PureComponent { @@ -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); @@ -290,13 +297,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; @@ -305,7 +312,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']); } @@ -318,10 +325,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..41c99710f 100644 --- a/app/javascript/mastodon/components/poll.js +++ b/app/javascript/mastodon/components/poll.js @@ -4,7 +4,6 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import classNames from 'classnames'; -import { vote, fetchPoll } from 'mastodon/actions/polls'; import Motion from 'mastodon/features/ui/util/optional_motion'; import spring from 'react-motion/lib/spring'; import escapeTextContentForBrowser from 'escape-html'; @@ -28,8 +27,9 @@ class Poll extends ImmutablePureComponent { static propTypes = { poll: ImmutablePropTypes.map, intl: PropTypes.object.isRequired, - dispatch: PropTypes.func, disabled: PropTypes.bool, + refresh: PropTypes.func, + onVote: PropTypes.func, }; state = { @@ -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,14 +81,26 @@ 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; } - this.props.dispatch(vote(this.props.poll.get('id'), Object.keys(this.state.selected))); + this.props.onVote(Object.keys(this.state.selected)); }; handleRefresh = () => { @@ -97,7 +108,7 @@ class Poll extends ImmutablePureComponent { return; } - this.props.dispatch(fetchPoll(this.props.poll.get('id'))); + this.props.refresh(); }; renderOption (option, optionIndex, showResults) { @@ -116,15 +127,7 @@ class Poll extends ImmutablePureComponent { return (
  • - {showResults && ( - - {({ width }) => - - } - - )} - -
  • ); } 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 9dbe76803..22cde6e5c 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
    ; @@ -210,8 +176,25 @@ class Status extends ImmutablePureComponent { return
    ; } - handleOpenVideo = (media, startTime) => { - this.props.onOpenVideo(media, startTime); + handleOpenVideo = (media, options) => { + this.props.onOpenVideo(media, options); + } + + 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]), { startTime: 0 }); + } else { + onOpenMedia(status.get('media_attachments'), 0); + } + } } handleHotkeyReply = e => { @@ -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} - {showThread && status.get('in_reply_to_id') && status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) && ( - - )} -
    diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js index c21ba218c..774c74e00 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'; @@ -24,6 +25,8 @@ const messages = defineMessages({ cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, local_only: { id: 'status.local_only', defaultMessage: 'This post is only visible by other users of your instance' }, 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' }, @@ -34,6 +37,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: 'Block domain {domain}' }, + unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' }, + unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, + unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, }); const obfuscatedCount = count => { @@ -46,7 +53,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 = { @@ -55,6 +67,7 @@ class StatusActionBar extends ImmutablePureComponent { static propTypes = { status: ImmutablePropTypes.map.isRequired, + relationship: ImmutablePropTypes.map, onReply: PropTypes.func, onFavourite: PropTypes.func, onReblog: PropTypes.func, @@ -62,11 +75,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, }; @@ -75,6 +93,7 @@ class StatusActionBar extends ImmutablePureComponent { // evaluate to false. See react-immutable-pure-component for usage. updateOnProps = [ 'status', + 'relationship', 'withDismiss', ] @@ -115,6 +134,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); } @@ -136,11 +159,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 = () => { @@ -179,11 +230,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'); const federated = !status.get('local_only'); let menu = []; @@ -198,6 +250,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) { @@ -217,16 +270,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')}` }); } } @@ -245,7 +321,7 @@ class StatusActionBar extends ImmutablePureComponent { replyTitle = intl.formatMessage(messages.replyAll); } - const shareButton = ('share' in navigator) && status.get('visibility') === 'public' && federated && ( + const shareButton = ('share' in navigator) && publicStatus && federated && ( ); @@ -255,6 +331,7 @@ class StatusActionBar extends ImmutablePureComponent { {shareButton} +
    diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js index 321f7621a..77ccc6241 100644 --- a/app/javascript/mastodon/components/status_content.js +++ b/app/javascript/mastodon/components/status_content.js @@ -20,14 +20,15 @@ export default class StatusContent extends React.PureComponent { static propTypes = { status: ImmutablePropTypes.map.isRequired, expanded: PropTypes.bool, + showThread: PropTypes.bool, 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 +60,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 +181,8 @@ 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 renderViewThread = this.props.showThread && status.get('in_reply_to_id') && status.get('in_reply_to_account_id') === status.getIn(['account', 'id']); const content = { __html: status.get('contentHtml') }; const spoilerContent = { __html: status.get('spoilerHtml') }; @@ -185,13 +190,19 @@ 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'))) { directionStyle.direction = 'rtl'; } + const showThreadButton = ( + + ); + const readMoreButton = ( - + +
    diff --git a/app/javascript/mastodon/features/blocks/index.js b/app/javascript/mastodon/features/blocks/index.js index 051431ed2..107deb841 100644 --- a/app/javascript/mastodon/features/blocks/index.js +++ b/app/javascript/mastodon/features/blocks/index.js @@ -19,6 +19,7 @@ const messages = defineMessages({ const mapStateToProps = state => ({ accountIds: state.getIn(['user_lists', 'blocks', 'items']), hasMore: !!state.getIn(['user_lists', 'blocks', 'next']), + isLoading: state.getIn(['user_lists', 'blocks', 'isLoading'], true), }); export default @connect(mapStateToProps) @@ -31,6 +32,7 @@ class Blocks extends ImmutablePureComponent { shouldUpdateScroll: PropTypes.func, accountIds: ImmutablePropTypes.list, hasMore: PropTypes.bool, + isLoading: PropTypes.bool, intl: PropTypes.object.isRequired, multiColumn: PropTypes.bool, }; @@ -44,7 +46,7 @@ class Blocks extends ImmutablePureComponent { }, 300, { leading: true }); render () { - const { intl, accountIds, shouldUpdateScroll, hasMore, multiColumn } = this.props; + const { intl, accountIds, shouldUpdateScroll, hasMore, multiColumn, isLoading } = this.props; if (!accountIds) { return ( @@ -63,12 +65,13 @@ class Blocks extends ImmutablePureComponent { scrollKey='blocks' onLoadMore={this.handleLoadMore} hasMore={hasMore} + isLoading={isLoading} shouldUpdateScroll={shouldUpdateScroll} emptyMessage={emptyMessage} bindToDocument={!multiColumn} > {accountIds.map(id => - + , )} 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/action_bar.js b/app/javascript/mastodon/features/compose/components/action_bar.js index b33deb1ab..4ff0b7b94 100644 --- a/app/javascript/mastodon/features/compose/components/action_bar.js +++ b/app/javascript/mastodon/features/compose/components/action_bar.js @@ -16,6 +16,7 @@ const messages = defineMessages({ mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' }, filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' }, logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }, + bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' }, }); export default @injectIntl @@ -42,6 +43,7 @@ class ActionBar extends React.PureComponent { menu.push(null); menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' }); menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' }); + menu.push({ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' }); menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' }); menu.push(null); menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' }); 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..1e911efff 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,7 +351,7 @@ 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; @@ -360,7 +361,7 @@ class EmojiPickerDropdown extends React.PureComponent { 🙂
    diff --git a/app/javascript/mastodon/features/compose/components/poll_form.js b/app/javascript/mastodon/features/compose/components/poll_form.js index 211601d52..db49f90eb 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}}' }, @@ -25,6 +27,7 @@ class Option extends React.PureComponent { title: PropTypes.string.isRequired, index: PropTypes.number.isRequired, isPollMultiple: PropTypes.bool, + autoFocus: PropTypes.bool, onChange: PropTypes.func.isRequired, onRemove: PropTypes.func.isRequired, onToggleMultiple: PropTypes.func.isRequired, @@ -50,6 +53,12 @@ class Option extends React.PureComponent { e.stopPropagation(); }; + handleCheckboxKeypress = e => { + if (e.key === 'Enter' || e.key === ' ') { + this.handleToggleMultiple(e); + } + } + onSuggestionsClearRequested = () => { this.props.onClearSuggestions(); } @@ -63,21 +72,24 @@ class Option extends React.PureComponent { } render () { - const { isPollMultiple, title, index, intl } = this.props; + const { isPollMultiple, title, index, autoFocus, intl } = this.props; return (
  • -