diff --git a/.browserslistrc b/.browserslistrc index 54dd3aaf34..0376af4bcc 100644 --- a/.browserslistrc +++ b/.browserslistrc @@ -1,7 +1,9 @@ [production] defaults -not IE 11 +> 0.2% +ios >= 15.6 not dead +not OperaMini all [development] supports es6-module diff --git a/.devcontainer/codespaces/devcontainer.json b/.devcontainer/codespaces/devcontainer.json index b32e4026d2..ca9156fdaa 100644 --- a/.devcontainer/codespaces/devcontainer.json +++ b/.devcontainer/codespaces/devcontainer.json @@ -5,7 +5,7 @@ "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", "features": { - "ghcr.io/devcontainers/features/sshd:1": {}, + "ghcr.io/devcontainers/features/sshd:1": {} }, "runServices": ["app", "db", "redis"], @@ -15,16 +15,16 @@ "portsAttributes": { "3000": { "label": "web", - "onAutoForward": "notify", + "onAutoForward": "notify" }, "4000": { "label": "stream", - "onAutoForward": "silent", - }, + "onAutoForward": "silent" + } }, "otherPortsAttributes": { - "onAutoForward": "silent", + "onAutoForward": "silent" }, "remoteEnv": { @@ -33,7 +33,7 @@ "STREAMING_API_BASE_URL": "https://${localEnv:CODESPACE_NAME}-4000.app.github.dev", "DISABLE_FORGERY_REQUEST_PROTECTION": "true", "ES_ENABLED": "", - "LIBRE_TRANSLATE_ENDPOINT": "", + "LIBRE_TRANSLATE_ENDPOINT": "" }, "onCreateCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}", @@ -43,7 +43,7 @@ "customizations": { "vscode": { "settings": {}, - "extensions": ["EditorConfig.EditorConfig", "webben.browserslist"], - }, - }, + "extensions": ["EditorConfig.EditorConfig", "webben.browserslist"] + } + } } diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index ed71235b3b..fa8d6542c1 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -5,7 +5,7 @@ "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", "features": { - "ghcr.io/devcontainers/features/sshd:1": {}, + "ghcr.io/devcontainers/features/sshd:1": {} }, "forwardPorts": [3000, 4000], @@ -14,17 +14,17 @@ "3000": { "label": "web", "onAutoForward": "notify", - "requireLocalPort": true, + "requireLocalPort": true }, "4000": { "label": "stream", "onAutoForward": "silent", - "requireLocalPort": true, - }, + "requireLocalPort": true + } }, "otherPortsAttributes": { - "onAutoForward": "silent", + "onAutoForward": "silent" }, "onCreateCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}", @@ -34,7 +34,7 @@ "customizations": { "vscode": { "settings": {}, - "extensions": ["EditorConfig.EditorConfig", "webben.browserslist"], - }, - }, + "extensions": ["EditorConfig.EditorConfig", "webben.browserslist"] + } + } } diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index ecdf9f5f53..d14af5d7d9 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -70,7 +70,7 @@ services: hard: -1 libretranslate: - image: libretranslate/libretranslate:v1.5.5 + image: libretranslate/libretranslate:v1.5.6 restart: unless-stopped volumes: - lt-data:/home/libretranslate/.local diff --git a/.eslintrc.js b/.eslintrc.js index ae13b0711c..87a1af483b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -123,7 +123,7 @@ module.exports = defineConfig({ 'react/react-in-jsx-scope': 'off', // not needed with new JSX transform 'react/self-closing-comp': 'error', - // recommended values found in https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/src/index.js + // recommended values found in https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/v6.8.0/src/index.js#L46 'jsx-a11y/accessible-emoji': 'warn', 'jsx-a11y/click-events-have-key-events': 'off', 'jsx-a11y/label-has-associated-control': 'off', @@ -176,7 +176,7 @@ module.exports = defineConfig({ }, ], - // See https://github.com/import-js/eslint-plugin-import/blob/main/config/recommended.js + // See https://github.com/import-js/eslint-plugin-import/blob/v2.29.1/config/recommended.js 'import/extensions': [ 'error', 'always', @@ -355,7 +355,6 @@ module.exports = defineConfig({ 'plugin:import/typescript', 'plugin:promise/recommended', 'plugin:jsdoc/recommended-typescript', - 'plugin:prettier/recommended', ], parserOptions: { @@ -364,6 +363,9 @@ module.exports = defineConfig({ }, rules: { + // Disable formatting rules that have been enabled in the base config + 'indent': 'off', + 'import/consistent-type-specifier-style': ['error', 'prefer-top-level'], '@typescript-eslint/consistent-type-definitions': ['warn', 'interface'], diff --git a/.github/codecov.yml b/.github/codecov.yml index 5532c49618..9d6413a106 100644 --- a/.github/codecov.yml +++ b/.github/codecov.yml @@ -1,3 +1,4 @@ +comment: false # Do not leave PR comments coverage: status: project: @@ -8,6 +9,3 @@ coverage: default: # Github status check is not blocking informational: true -comment: - # Only write a comment in PR if there are changes - require_changes: true diff --git a/.github/workflows/crowdin-download.yml b/.github/workflows/crowdin-download.yml index 34f65a02c2..d5023c53e7 100644 --- a/.github/workflows/crowdin-download.yml +++ b/.github/workflows/crowdin-download.yml @@ -53,7 +53,7 @@ jobs: # Create or update the pull request - name: Create Pull Request - uses: peter-evans/create-pull-request@v6.0.0 + uses: peter-evans/create-pull-request@v6.0.2 with: commit-message: 'New Crowdin translations' title: 'New Crowdin Translations (automated)' diff --git a/.github/workflows/format-check.yml b/.github/workflows/format-check.yml new file mode 100644 index 0000000000..2d483b5022 --- /dev/null +++ b/.github/workflows/format-check.yml @@ -0,0 +1,18 @@ +name: Check formatting +on: + push: + pull_request: + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - name: Clone repository + uses: actions/checkout@v4 + + - name: Set up Javascript environment + uses: ./.github/actions/setup-javascript + + - name: Check formatting with Prettier + run: yarn format:check diff --git a/.github/workflows/lint-css.yml b/.github/workflows/lint-css.yml index 7229bec582..e5f4874877 100644 --- a/.github/workflows/lint-css.yml +++ b/.github/workflows/lint-css.yml @@ -43,4 +43,4 @@ jobs: - run: echo "::add-matcher::.github/stylelint-matcher.json" - name: Stylelint - run: yarn lint:sass + run: yarn lint:css diff --git a/.github/workflows/lint-haml.yml b/.github/workflows/lint-haml.yml index 8dcab845ee..25615b720d 100644 --- a/.github/workflows/lint-haml.yml +++ b/.github/workflows/lint-haml.yml @@ -36,4 +36,4 @@ jobs: - name: Run haml-lint run: | echo "::add-matcher::.github/workflows/haml-lint-problem-matcher.json" - bundle exec haml-lint + bundle exec haml-lint --reporter github diff --git a/.github/workflows/lint-json.yml b/.github/workflows/lint-json.yml deleted file mode 100644 index 7796bf92c4..0000000000 --- a/.github/workflows/lint-json.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: JSON Linting -on: - push: - branches-ignore: - - 'dependabot/**' - - 'renovate/**' - paths: - - 'package.json' - - 'yarn.lock' - - '.nvmrc' - - '.prettier*' - - '**/*.json' - - '.github/workflows/lint-json.yml' - - '!app/javascript/mastodon/locales/*.json' - - pull_request: - paths: - - 'package.json' - - 'yarn.lock' - - '.nvmrc' - - '.prettier*' - - '**/*.json' - - '.github/workflows/lint-json.yml' - - '!app/javascript/mastodon/locales/*.json' - -jobs: - lint: - runs-on: ubuntu-latest - - steps: - - name: Clone repository - uses: actions/checkout@v4 - - - name: Set up Javascript environment - uses: ./.github/actions/setup-javascript - - - name: Prettier - run: yarn lint:json diff --git a/.github/workflows/lint-md.yml b/.github/workflows/lint-md.yml deleted file mode 100644 index 51c59937a3..0000000000 --- a/.github/workflows/lint-md.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Markdown Linting -on: - push: - branches-ignore: - - 'dependabot/**' - - 'renovate/**' - paths: - - '.github/workflows/lint-md.yml' - - '.nvmrc' - - '.prettier*' - - '**/*.md' - - '!AUTHORS.md' - - 'package.json' - - 'yarn.lock' - - pull_request: - paths: - - '.github/workflows/lint-md.yml' - - '.nvmrc' - - '.prettier*' - - '**/*.md' - - '!AUTHORS.md' - - 'package.json' - - 'yarn.lock' - -jobs: - lint: - runs-on: ubuntu-latest - - steps: - - name: Clone repository - uses: actions/checkout@v4 - - - name: Set up Javascript environment - uses: ./.github/actions/setup-javascript - - - name: Prettier - run: yarn lint:md diff --git a/.github/workflows/lint-yml.yml b/.github/workflows/lint-yml.yml deleted file mode 100644 index 908bdef5cc..0000000000 --- a/.github/workflows/lint-yml.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: YML Linting -on: - push: - branches-ignore: - - 'dependabot/**' - - 'renovate/**' - paths: - - 'package.json' - - 'yarn.lock' - - '.nvmrc' - - '.prettier*' - - '**/*.yaml' - - '**/*.yml' - - '.github/workflows/lint-yml.yml' - - '!config/locales/*.yml' - - pull_request: - paths: - - 'package.json' - - 'yarn.lock' - - '.nvmrc' - - '.prettier*' - - '**/*.yaml' - - '**/*.yml' - - '.github/workflows/lint-yml.yml' - - '!config/locales/*.yml' - -jobs: - lint: - runs-on: ubuntu-latest - - steps: - - name: Clone repository - uses: actions/checkout@v4 - - - name: Set up Javascript environment - uses: ./.github/actions/setup-javascript - - - name: Prettier - run: yarn lint:yml diff --git a/.haml-lint.yml b/.haml-lint.yml index 8cfcaec8d9..b94eb8b0df 100644 --- a/.haml-lint.yml +++ b/.haml-lint.yml @@ -14,3 +14,5 @@ linters: enabled: true LineLength: max: 320 + ViewLength: + max: 200 # Override default value of 100 inherited from rubocop diff --git a/.haml-lint_todo.yml b/.haml-lint_todo.yml deleted file mode 100644 index af2d2e8f4e..0000000000 --- a/.haml-lint_todo.yml +++ /dev/null @@ -1,13 +0,0 @@ -# This configuration was generated by -# `haml-lint --auto-gen-config` -# on 2024-01-09 11:30:07 -0500 using Haml-Lint version 0.53.0. -# The point is for the user to remove these configuration records -# one by one as the lints are removed from the code base. -# Note that changes in the inspected code, or installation of new -# versions of Haml-Lint, may require this file to be generated again. - -linters: - # Offense count: 1 - LineLength: - exclude: - - 'app/views/admin/roles/_form.html.haml' diff --git a/.prettierignore b/.prettierignore index 66ca9ee5b0..ad88ad3f97 100644 --- a/.prettierignore +++ b/.prettierignore @@ -54,6 +54,13 @@ # Ignore Docker option files docker-compose.override.yml +# Ignore public +/public/assets +/public/emoji +/public/packs +/public/packs-test +/public/system + # Ignore emoji map file /app/javascript/mastodon/features/emoji/emoji_map.json @@ -74,6 +81,7 @@ app/javascript/styles/mastodon/reset.scss # Ignore the generated AUTHORS.md AUTHORS.md +# Process a few selected JS files !lint-staged.config.js # Ignore glitch-soc emoji map file diff --git a/Dockerfile b/Dockerfile index 119c266b89..8778133e0d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,7 +20,7 @@ FROM docker.io/ruby:${RUBY_VERSION}-slim-${DEBIAN_VERSION} as ruby # Resulting version string is vX.X.X-MASTODON_VERSION_PRERELEASE+MASTODON_VERSION_METADATA # Example: v4.2.0-nightly.2023.11.09+something -# Overwrite existance of 'alpha.0' in version.rb [--build-arg MASTODON_VERSION_PRERELEASE="nightly.2023.11.09"] +# Overwrite existence of 'alpha.0' in version.rb [--build-arg MASTODON_VERSION_PRERELEASE="nightly.2023.11.09"] ARG MASTODON_VERSION_PRERELEASE="" # Append build metadata or fork information to version.rb [--build-arg MASTODON_VERSION_METADATA="something"] ARG MASTODON_VERSION_METADATA="" @@ -29,7 +29,7 @@ ARG MASTODON_VERSION_METADATA="" # See: https://docs.joinmastodon.org/admin/config/#rails_serve_static_files ARG RAILS_SERVE_STATIC_FILES="true" # Allow to use YJIT compiler -# See: https://github.com/ruby/ruby/blob/master/doc/yjit/yjit.md +# See: https://github.com/ruby/ruby/blob/v3_2_3/doc/yjit/yjit.md ARG RUBY_YJIT_ENABLE="1" # Timezone used by the Docker container and runtime, change with [--build-arg TZ=Europe/Berlin] ARG TZ="Etc/UTC" diff --git a/Gemfile b/Gemfile index c137cd2bce..f2cf820b21 100644 --- a/Gemfile +++ b/Gemfile @@ -59,6 +59,7 @@ gem 'http', '~> 5.1' gem 'http_accept_language', '~> 2.1' gem 'httplog', '~> 1.6.2' gem 'idn-ruby', require: 'idn' +gem 'inline_svg' gem 'kaminari', '~> 1.2' gem 'link_header', '~> 0.0' gem 'mime-types', '~> 3.5.0', require: 'mime/types/columnar' @@ -112,7 +113,7 @@ group :test do # RSpec helpers for email specs gem 'email_spec' - # Extra RSpec extenion methods and helpers for sidekiq + # Extra RSpec extension methods and helpers for sidekiq gem 'rspec-sidekiq', '~> 4.0' # Browser integration testing diff --git a/Gemfile.lock b/Gemfile.lock index 734ed6ec8a..9c5bb940bc 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -333,7 +333,7 @@ GEM http-form_data (2.3.0) http_accept_language (2.1.1) httpclient (2.8.3) - httplog (1.6.2) + httplog (1.6.3) rack (>= 2.0) rainbow (>= 2.0.0) i18n (1.14.1) @@ -350,14 +350,17 @@ GEM rainbow (>= 2.2.2, < 4.0) terminal-table (>= 1.5.1) idn-ruby (0.1.5) + inline_svg (1.9.0) + activesupport (>= 3.0) + nokogiri (>= 1.6) io-console (0.7.2) - irb (1.11.2) + irb (1.12.0) rdoc reline (>= 0.4.2) jmespath (1.6.2) json (2.7.1) json-canonicalization (1.0.0) - json-jwt (1.15.3) + json-jwt (1.15.3.1) activesupport (>= 4.2) aes_key_wrap bindata @@ -372,7 +375,7 @@ GEM json-ld-preloaded (3.3.0) json-ld (~> 3.3) rdf (~> 3.3) - json-schema (4.1.1) + json-schema (4.2.0) addressable (>= 2.8) jsonapi-renderer (0.2.2) jwt (2.7.1) @@ -455,7 +458,7 @@ GEM net-smtp (0.4.0.1) net-protocol nio4r (2.5.9) - nokogiri (1.16.2) + nokogiri (1.16.3) mini_portile2 (~> 2.8.2) racc (~> 1.4) nsa (0.3.0) @@ -465,11 +468,11 @@ GEM statsd-ruby (~> 1.4, >= 1.4.0) oj (3.16.3) bigdecimal (>= 3.0) - omniauth (2.1.1) + omniauth (2.1.2) hashie (>= 3.4.6) rack (>= 2.2.3) rack-protection - omniauth-cas (3.0.0.beta.1) + omniauth-cas (3.0.0) addressable (~> 2.8) nokogiri (~> 1.12) omniauth (~> 2.1) @@ -505,7 +508,7 @@ GEM parslet (2.0.0) pastel (0.8.0) tty-color (~> 0.5) - pg (1.5.5) + pg (1.5.6) pghero (3.4.1) activerecord (>= 6) posix-spawn (0.3.15) @@ -535,7 +538,7 @@ GEM rack (2.2.8.1) rack-attack (6.7.0) rack (>= 1.0, < 4) - rack-cors (2.0.1) + rack-cors (2.0.2) rack (>= 2.0.0) rack-oauth2 (1.21.3) activesupport @@ -543,8 +546,9 @@ GEM httpclient json-jwt (>= 1.11.0) rack (>= 2.1.0) - rack-protection (3.0.5) - rack + rack-protection (3.2.0) + base64 (>= 0.1.0) + rack (~> 2.2, >= 2.2.4) rack-proxy (0.7.6) rack rack-session (1.0.2) @@ -606,7 +610,7 @@ GEM redlock (1.3.2) redis (>= 3.0.0, < 6.0) regexp_parser (2.9.0) - reline (0.4.2) + reline (0.4.3) io-console (~> 0.5) request_store (1.5.1) rack (>= 1.4) @@ -621,16 +625,16 @@ GEM chunky_png (~> 1.0) rqrcode_core (~> 1.0) rqrcode_core (1.2.0) - rspec-core (3.12.2) - rspec-support (~> 3.12.0) - rspec-expectations (3.12.3) + rspec-core (3.13.0) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.0) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.12.0) + rspec-support (~> 3.13.0) rspec-github (2.4.0) rspec-core (~> 3.0) - rspec-mocks (3.12.6) + rspec-mocks (3.13.0) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.12.0) + rspec-support (~> 3.13.0) rspec-rails (6.1.1) actionpack (>= 6.1) activesupport (>= 6.1) @@ -644,7 +648,7 @@ GEM rspec-expectations (~> 3.0) rspec-mocks (~> 3.0) sidekiq (>= 5, < 8) - rspec-support (3.12.1) + rspec-support (3.13.1) rubocop (1.60.2) json (~> 2.3) language_server-protocol (>= 3.17.0) @@ -743,8 +747,8 @@ GEM unicode-display_width (>= 1.1.1, < 3) terrapin (1.0.1) climate_control - test-prof (1.3.1) - thor (1.3.0) + test-prof (1.3.2) + thor (1.3.1) tilt (2.3.0) timeout (0.4.1) tpm-key_attestation (0.12.0) @@ -864,6 +868,7 @@ DEPENDENCIES httplog (~> 1.6.2) i18n-tasks (~> 1.0) idn-ruby + inline_svg irb (~> 1.8) json-ld json-ld-preloaded (~> 3.2) diff --git a/README.md b/README.md index f878752fe3..b28b13db85 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,154 @@ # Mastodon Glitch Edition -> Now with automated deploys! +[![Ruby Testing](https://github.com/glitch-soc/mastodon/actions/workflows/test-ruby.yml/badge.svg)](https://github.com/glitch-soc/mastodon/actions/workflows/test-ruby.yml) +[![Crowdin](https://badges.crowdin.net/glitch-soc/localized.svg)][glitch-crowdin] -[![Build Status](https://img.shields.io/circleci/project/github/glitch-soc/mastodon.svg)][circleci] -[![Code Climate](https://img.shields.io/codeclimate/maintainability/glitch-soc/mastodon.svg)][code_climate] - -[circleci]: https://circleci.com/gh/glitch-soc/mastodon -[code_climate]: https://codeclimate.com/github/glitch-soc/mastodon +[glitch-crowdin]: https://crowdin.com/project/glitch-soc So here's the deal: we all work on this code, and anyone who uses that does so absolutely at their own risk. can you dig it? - You can view documentation for this project at [glitch-soc.github.io/docs/](https://glitch-soc.github.io/docs/). - And contributing guidelines are available [here](CONTRIBUTING.md) and [here](https://glitch-soc.github.io/docs/contributing/). + +Mastodon Glitch Edition is a fork of [Mastodon](https://github.com/mastodon/mastodon). Upstream's README file is reproduced below. + +--- + +

+ + + Mastodon +

+ +[![GitHub release](https://img.shields.io/github/release/mastodon/mastodon.svg)][releases] +[![Ruby Testing](https://github.com/mastodon/mastodon/actions/workflows/test-ruby.yml/badge.svg)](https://github.com/mastodon/mastodon/actions/workflows/test-ruby.yml) +[![Crowdin](https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg)][crowdin] + +[releases]: https://github.com/mastodon/mastodon/releases +[crowdin]: https://crowdin.com/project/mastodon + +Mastodon is a **free, open-source social network server** based on ActivityPub where users can follow friends and discover new ones. On Mastodon, users can publish anything they want: links, pictures, text, and video. All Mastodon servers are interoperable as a federated network (users on one server can seamlessly communicate with users from another one, including non-Mastodon software that implements ActivityPub!) + +Click below to **learn more** in a video: + +[![Screenshot](https://blog.joinmastodon.org/2018/06/why-activitypub-is-the-future/ezgif-2-60f1b00403.gif)][youtube_demo] + +[youtube_demo]: https://www.youtube.com/watch?v=IPSbNdBmWKE + +## Navigation + +- [Project homepage 🐘](https://joinmastodon.org) +- [Support the development via Patreon][patreon] +- [View sponsors](https://joinmastodon.org/sponsors) +- [Blog](https://blog.joinmastodon.org) +- [Documentation](https://docs.joinmastodon.org) +- [Roadmap](https://joinmastodon.org/roadmap) +- [Official Docker image](https://github.com/mastodon/mastodon/pkgs/container/mastodon) +- [Browse Mastodon servers](https://joinmastodon.org/communities) +- [Browse Mastodon apps](https://joinmastodon.org/apps) + +[patreon]: https://www.patreon.com/mastodon + +## Features + + + +### No vendor lock-in: Fully interoperable with any conforming platform + +It doesn't have to be Mastodon; whatever implements ActivityPub is part of the social network! [Learn more](https://blog.joinmastodon.org/2018/06/why-activitypub-is-the-future/) + +### Real-time, chronological timeline updates + +Updates of people you're following appear in real-time in the UI via WebSockets. There's a firehose view as well! + +### Media attachments like images and short videos + +Upload and view images and WebM/MP4 videos attached to the updates. Videos with no audio track are treated like GIFs; normal videos loop continuously! + +### Safety and moderation tools + +Mastodon includes private posts, locked accounts, phrase filtering, muting, blocking, and all sorts of other features, along with a reporting and moderation system. [Learn more](https://blog.joinmastodon.org/2018/07/cage-the-mastodon/) + +### OAuth2 and a straightforward REST API + +Mastodon acts as an OAuth2 provider, so 3rd party apps can use the REST and Streaming APIs. This results in a rich app ecosystem with a lot of choices! + +## Deployment + +### Tech stack + +- **Ruby on Rails** powers the REST API and other web pages +- **React.js** and Redux are used for the dynamic parts of the interface +- **Node.js** powers the streaming API + +### Requirements + +- **PostgreSQL** 12+ +- **Redis** 4+ +- **Ruby** 3.0+ +- **Node.js** 16+ + +The repository includes deployment configurations for **Docker and docker-compose** as well as specific platforms like **Heroku**, **Scalingo**, and **Nanobox**. For Helm charts, reference the [mastodon/chart repository](https://github.com/mastodon/chart). The [**standalone** installation guide](https://docs.joinmastodon.org/admin/install/) is available in the documentation. + +## Development + +### Vagrant + +A **Vagrant** configuration is included for development purposes. To use it, complete the following steps: + +- Install Vagrant and Virtualbox +- Install the `vagrant-hostsupdater` plugin: `vagrant plugin install vagrant-hostsupdater` +- Run `vagrant up` +- Run `vagrant ssh -c "cd /vagrant && bin/dev"` +- Open `http://mastodon.local` in your browser + +### MacOS + +To set up **MacOS** for native development, complete the following steps: + +- Use a Ruby version manager to install the specified version from `.ruby-version` +- Run `brew install postgresql@14 redis imagemagick libidn` to install required dependencies +- Navigate to Mastodon's root directory and run `brew install nvm` then `nvm use` to use the version from `.nvmrc` +- Run `corepack enable && corepack prepare` +- Run `bundle exec rails db:setup` (optionally prepend `RAILS_ENV=development` to target the dev environment) +- Finally, run `bin/dev` which will launch the local services via `overmind` (if installed) or `foreman` + +### Docker + +For development with **Docker**, complete the following steps: + +- Install Docker Desktop +- Run `docker compose -f .devcontainer/docker-compose.yml up -d` +- Run `docker compose -f .devcontainer/docker-compose.yml exec app .devcontainer/post-create.sh` +- Finally, run `docker compose -f .devcontainer/docker-compose.yml exec app bin/dev` + +If you are using an IDE with [support for the Development Container specification](https://containers.dev/supporting), it will run the above `docker compose` commands automatically. For **Visual Studio Code** this requires the [Dev Container extension](https://containers.dev/supporting#dev-containers). + +### GitHub Codespaces + +To get you coding in just a few minutes, GitHub Codespaces provides a web-based version of Visual Studio Code and a cloud-hosted development environment fully configured with the software needed for this project.. + +- Click this button to create a new codespace:
+ [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://github.com/codespaces/new?hide_repo_select=true&ref=main&repo=52281283&devcontainer_path=.devcontainer%2Fcodespaces%2Fdevcontainer.json) +- Wait for the environment to build. This will take a few minutes. +- When the editor is ready, run `bin/dev` in the terminal. +- After a few seconds, a popup will appear with a button labeled _Open in Browser_. This will open Mastodon. +- On the _Ports_ tab, right click on the “stream” row and select _Port visibility_ → _Public_. + +## Contributing + +Mastodon is **free, open-source software** licensed under **AGPLv3**. + +You can open issues for bugs you've found or features you think are missing. You can also submit pull requests to this repository or submit translations using Crowdin. To get started, take a look at [CONTRIBUTING.md](CONTRIBUTING.md). If your contributions are accepted into Mastodon, you can request to be paid through [our OpenCollective](https://opencollective.com/mastodon). + +**IRC channel**: #mastodon on irc.libera.chat + +## License + +Copyright (C) 2016-2024 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. + +This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License along with this program. If not, see . diff --git a/Vagrantfile b/Vagrantfile index 6f0f511095..12bd1ba67a 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -188,7 +188,7 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| config.vm.post_up_message = < { 'Signature' if authorized_fetch_mode? } before_action :require_account_signature!, if: :authorized_fetch_mode? diff --git a/app/controllers/activitypub/followers_synchronizations_controller.rb b/app/controllers/activitypub/followers_synchronizations_controller.rb index d2942104e5..392dd36bcd 100644 --- a/app/controllers/activitypub/followers_synchronizations_controller.rb +++ b/app/controllers/activitypub/followers_synchronizations_controller.rb @@ -1,9 +1,6 @@ # frozen_string_literal: true class ActivityPub::FollowersSynchronizationsController < ActivityPub::BaseController - include SignatureVerification - include AccountOwnedConcern - vary_by -> { 'Signature' if authorized_fetch_mode? } before_action :require_account_signature! diff --git a/app/controllers/activitypub/inboxes_controller.rb b/app/controllers/activitypub/inboxes_controller.rb index e8b0f47cde..49cfc8ad1c 100644 --- a/app/controllers/activitypub/inboxes_controller.rb +++ b/app/controllers/activitypub/inboxes_controller.rb @@ -1,9 +1,7 @@ # frozen_string_literal: true class ActivityPub::InboxesController < ActivityPub::BaseController - include SignatureVerification include JsonLdHelper - include AccountOwnedConcern before_action :skip_unknown_actor_activity before_action :require_actor_signature! diff --git a/app/controllers/activitypub/outboxes_controller.rb b/app/controllers/activitypub/outboxes_controller.rb index bf10ba762a..8079e011dd 100644 --- a/app/controllers/activitypub/outboxes_controller.rb +++ b/app/controllers/activitypub/outboxes_controller.rb @@ -3,9 +3,6 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController LIMIT = 20 - include SignatureVerification - include AccountOwnedConcern - vary_by -> { 'Signature' if authorized_fetch_mode? || page_requested? } before_action :require_account_signature!, if: :authorized_fetch_mode? diff --git a/app/controllers/activitypub/replies_controller.rb b/app/controllers/activitypub/replies_controller.rb index c38ff89d1c..3f43e89a5e 100644 --- a/app/controllers/activitypub/replies_controller.rb +++ b/app/controllers/activitypub/replies_controller.rb @@ -1,9 +1,7 @@ # frozen_string_literal: true class ActivityPub::RepliesController < ActivityPub::BaseController - include SignatureVerification include Authorization - include AccountOwnedConcern DESCENDANTS_LIMIT = 60 diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb index 9beb8fde6b..d3be7817ff 100644 --- a/app/controllers/admin/accounts_controller.rb +++ b/app/controllers/admin/accounts_controller.rb @@ -128,7 +128,7 @@ module Admin def unblock_email authorize @account, :unblock_email? - CanonicalEmailBlock.where(reference_account: @account).delete_all + CanonicalEmailBlock.matching_account(@account).delete_all log_action :unblock_email, @account diff --git a/app/controllers/admin/rules_controller.rb b/app/controllers/admin/rules_controller.rb index d31aec6ea8..b8def22ba3 100644 --- a/app/controllers/admin/rules_controller.rb +++ b/app/controllers/admin/rules_controller.rb @@ -53,7 +53,7 @@ module Admin end def resource_params - params.require(:rule).permit(:text, :priority) + params.require(:rule).permit(:text, :hint, :priority) end end end diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index 98fa1897ef..f87d596ce3 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -8,6 +8,7 @@ class Api::BaseController < ApplicationController include Api::AccessTokenTrackingConcern include Api::CachingConcern include Api::ContentSecurityPolicy + include Api::ErrorHandling skip_before_action :require_functional!, unless: :limited_federation_mode? @@ -18,51 +19,6 @@ class Api::BaseController < ApplicationController protect_from_forgery with: :null_session - rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e| - render json: { error: e.to_s }, status: 422 - end - - rescue_from ActiveRecord::RecordNotUnique do - render json: { error: 'Duplicate record' }, status: 422 - end - - rescue_from Date::Error do - render json: { error: 'Invalid date supplied' }, status: 422 - end - - rescue_from ActiveRecord::RecordNotFound do - render json: { error: 'Record not found' }, status: 404 - end - - rescue_from HTTP::Error, Mastodon::UnexpectedResponseError do - render json: { error: 'Remote data could not be fetched' }, status: 503 - end - - rescue_from OpenSSL::SSL::SSLError do - render json: { error: 'Remote SSL certificate could not be verified' }, status: 503 - end - - rescue_from Mastodon::NotPermittedError do - render json: { error: 'This action is not allowed' }, status: 403 - end - - rescue_from Seahorse::Client::NetworkingError do |e| - Rails.logger.warn "Storage server error: #{e}" - render json: { error: 'There was a temporary problem serving your request, please try again' }, status: 503 - end - - rescue_from Mastodon::RaceConditionError, Stoplight::Error::RedLight do - 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, Mastodon::InvalidParameterError do |e| - render json: { error: e.to_s }, status: 400 - end - def doorkeeper_unauthorized_render_options(error: nil) { json: { error: error.try(:description) || 'Not authorized' } } end @@ -73,6 +29,14 @@ class Api::BaseController < ApplicationController protected + def pagination_max_id + pagination_collection.last.id + end + + def pagination_since_id + pagination_collection.first.id + end + def set_pagination_headers(next_path = nil, prev_path = nil) links = [] links << [next_path, [%w(rel next)]] if next_path @@ -140,6 +104,10 @@ class Api::BaseController < ApplicationController private + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + def pagination_options_invalid? params.slice(:limit, :offset).values.map(&:to_i).any?(&:negative?) end diff --git a/app/controllers/api/v1/accounts/follower_accounts_controller.rb b/app/controllers/api/v1/accounts/follower_accounts_controller.rb index f60181f1eb..449866fa55 100644 --- a/app/controllers/api/v1/accounts/follower_accounts_controller.rb +++ b/app/controllers/api/v1/accounts/follower_accounts_controller.rb @@ -41,10 +41,6 @@ class Api::V1::Accounts::FollowerAccountsController < Api::BaseController ) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_account_followers_url pagination_params(max_id: pagination_max_id) if records_continue? end diff --git a/app/controllers/api/v1/accounts/following_accounts_controller.rb b/app/controllers/api/v1/accounts/following_accounts_controller.rb index 3ab8c1efd6..c4f4313f8f 100644 --- a/app/controllers/api/v1/accounts/following_accounts_controller.rb +++ b/app/controllers/api/v1/accounts/following_accounts_controller.rb @@ -41,10 +41,6 @@ class Api::V1::Accounts::FollowingAccountsController < Api::BaseController ) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_account_following_index_url pagination_params(max_id: pagination_max_id) if records_continue? end diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb index fe4279302f..35ea9c8ec1 100644 --- a/app/controllers/api/v1/accounts/statuses_controller.rb +++ b/app/controllers/api/v1/accounts/statuses_controller.rb @@ -4,7 +4,7 @@ class Api::V1::Accounts::StatusesController < Api::BaseController before_action -> { authorize_if_got_token! :read, :'read:statuses' } before_action :set_account - after_action :insert_pagination_headers, unless: -> { truthy_param?(:pinned) } + after_action :insert_pagination_headers def index cache_if_unauthenticated! @@ -35,10 +35,6 @@ class Api::V1::Accounts::StatusesController < Api::BaseController params.slice(:limit, *AccountStatusesFilter::KEYS).permit(:limit, *AccountStatusesFilter::KEYS).merge(core_params) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_account_statuses_url pagination_params(max_id: pagination_max_id) if records_continue? end @@ -51,11 +47,7 @@ class Api::V1::Accounts::StatusesController < Api::BaseController @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT) end - def pagination_max_id - @statuses.last.id - end - - def pagination_since_id - @statuses.first.id + def pagination_collection + @statuses end end diff --git a/app/controllers/api/v1/admin/accounts_controller.rb b/app/controllers/api/v1/admin/accounts_controller.rb index ff9cae6398..ff6f41e01d 100644 --- a/app/controllers/api/v1/admin/accounts_controller.rb +++ b/app/controllers/api/v1/admin/accounts_controller.rb @@ -125,10 +125,6 @@ class Api::V1::Admin::AccountsController < Api::BaseController translated_params end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_admin_accounts_url(pagination_params(max_id: pagination_max_id)) if records_continue? end @@ -137,12 +133,8 @@ class Api::V1::Admin::AccountsController < Api::BaseController api_v1_admin_accounts_url(pagination_params(min_id: pagination_since_id)) unless @accounts.empty? end - def pagination_max_id - @accounts.last.id - end - - def pagination_since_id - @accounts.first.id + def pagination_collection + @accounts end def records_continue? diff --git a/app/controllers/api/v1/admin/canonical_email_blocks_controller.rb b/app/controllers/api/v1/admin/canonical_email_blocks_controller.rb index 7b192b979f..701f668de6 100644 --- a/app/controllers/api/v1/admin/canonical_email_blocks_controller.rb +++ b/app/controllers/api/v1/admin/canonical_email_blocks_controller.rb @@ -65,10 +65,6 @@ class Api::V1::Admin::CanonicalEmailBlocksController < Api::BaseController @canonical_email_block = CanonicalEmailBlock.find(params[:id]) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_admin_canonical_email_blocks_url(pagination_params(max_id: pagination_max_id)) if records_continue? end @@ -77,12 +73,8 @@ class Api::V1::Admin::CanonicalEmailBlocksController < Api::BaseController api_v1_admin_canonical_email_blocks_url(pagination_params(min_id: pagination_since_id)) unless @canonical_email_blocks.empty? end - def pagination_max_id - @canonical_email_blocks.last.id - end - - def pagination_since_id - @canonical_email_blocks.first.id + def pagination_collection + @canonical_email_blocks end def records_continue? diff --git a/app/controllers/api/v1/admin/domain_allows_controller.rb b/app/controllers/api/v1/admin/domain_allows_controller.rb index dd54d67106..a7ae84e306 100644 --- a/app/controllers/api/v1/admin/domain_allows_controller.rb +++ b/app/controllers/api/v1/admin/domain_allows_controller.rb @@ -61,10 +61,6 @@ class Api::V1::Admin::DomainAllowsController < Api::BaseController DomainAllow.all end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_admin_domain_allows_url(pagination_params(max_id: pagination_max_id)) if records_continue? end @@ -73,12 +69,8 @@ class Api::V1::Admin::DomainAllowsController < Api::BaseController api_v1_admin_domain_allows_url(pagination_params(min_id: pagination_since_id)) unless @domain_allows.empty? end - def pagination_max_id - @domain_allows.last.id - end - - def pagination_since_id - @domain_allows.first.id + def pagination_collection + @domain_allows end def records_continue? diff --git a/app/controllers/api/v1/admin/domain_blocks_controller.rb b/app/controllers/api/v1/admin/domain_blocks_controller.rb index 2538c7c7c2..b589d277d5 100644 --- a/app/controllers/api/v1/admin/domain_blocks_controller.rb +++ b/app/controllers/api/v1/admin/domain_blocks_controller.rb @@ -72,10 +72,6 @@ class Api::V1::Admin::DomainBlocksController < Api::BaseController params.permit(:severity, :reject_media, :reject_reports, :private_comment, :public_comment, :obfuscate) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_admin_domain_blocks_url(pagination_params(max_id: pagination_max_id)) if records_continue? end @@ -84,12 +80,8 @@ class Api::V1::Admin::DomainBlocksController < Api::BaseController api_v1_admin_domain_blocks_url(pagination_params(min_id: pagination_since_id)) unless @domain_blocks.empty? end - def pagination_max_id - @domain_blocks.last.id - end - - def pagination_since_id - @domain_blocks.first.id + def pagination_collection + @domain_blocks end def records_continue? diff --git a/app/controllers/api/v1/admin/email_domain_blocks_controller.rb b/app/controllers/api/v1/admin/email_domain_blocks_controller.rb index df54b9f0a4..bdedb9d040 100644 --- a/app/controllers/api/v1/admin/email_domain_blocks_controller.rb +++ b/app/controllers/api/v1/admin/email_domain_blocks_controller.rb @@ -58,10 +58,6 @@ class Api::V1::Admin::EmailDomainBlocksController < Api::BaseController params.permit(:domain, :allow_with_approval) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_admin_email_domain_blocks_url(pagination_params(max_id: pagination_max_id)) if records_continue? end @@ -70,12 +66,8 @@ class Api::V1::Admin::EmailDomainBlocksController < Api::BaseController api_v1_admin_email_domain_blocks_url(pagination_params(min_id: pagination_since_id)) unless @email_domain_blocks.empty? end - def pagination_max_id - @email_domain_blocks.last.id - end - - def pagination_since_id - @email_domain_blocks.first.id + def pagination_collection + @email_domain_blocks end def records_continue? diff --git a/app/controllers/api/v1/admin/ip_blocks_controller.rb b/app/controllers/api/v1/admin/ip_blocks_controller.rb index 61c1912344..3625781149 100644 --- a/app/controllers/api/v1/admin/ip_blocks_controller.rb +++ b/app/controllers/api/v1/admin/ip_blocks_controller.rb @@ -63,10 +63,6 @@ class Api::V1::Admin::IpBlocksController < Api::BaseController params.permit(:ip, :severity, :comment, :expires_in) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_admin_ip_blocks_url(pagination_params(max_id: pagination_max_id)) if records_continue? end @@ -75,12 +71,8 @@ class Api::V1::Admin::IpBlocksController < Api::BaseController api_v1_admin_ip_blocks_url(pagination_params(min_id: pagination_since_id)) unless @ip_blocks.empty? end - def pagination_max_id - @ip_blocks.last.id - end - - def pagination_since_id - @ip_blocks.first.id + def pagination_collection + @ip_blocks end def records_continue? diff --git a/app/controllers/api/v1/admin/reports_controller.rb b/app/controllers/api/v1/admin/reports_controller.rb index 7129a5f6ca..9b5beeab67 100644 --- a/app/controllers/api/v1/admin/reports_controller.rb +++ b/app/controllers/api/v1/admin/reports_controller.rb @@ -89,10 +89,6 @@ class Api::V1::Admin::ReportsController < Api::BaseController params.permit(*FILTER_PARAMS) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_admin_reports_url(pagination_params(max_id: pagination_max_id)) if records_continue? end @@ -101,12 +97,8 @@ class Api::V1::Admin::ReportsController < Api::BaseController api_v1_admin_reports_url(pagination_params(min_id: pagination_since_id)) unless @reports.empty? end - def pagination_max_id - @reports.last.id - end - - def pagination_since_id - @reports.first.id + def pagination_collection + @reports end def records_continue? diff --git a/app/controllers/api/v1/admin/tags_controller.rb b/app/controllers/api/v1/admin/tags_controller.rb index 6a7c9f5bf3..c754980720 100644 --- a/app/controllers/api/v1/admin/tags_controller.rb +++ b/app/controllers/api/v1/admin/tags_controller.rb @@ -44,10 +44,6 @@ class Api::V1::Admin::TagsController < Api::BaseController params.permit(:display_name, :trendable, :usable, :listable) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_admin_tags_url(pagination_params(max_id: pagination_max_id)) if records_continue? end @@ -56,12 +52,8 @@ class Api::V1::Admin::TagsController < Api::BaseController api_v1_admin_tags_url(pagination_params(min_id: pagination_since_id)) unless @tags.empty? end - def pagination_max_id - @tags.last.id - end - - def pagination_since_id - @tags.first.id + def pagination_collection + @tags end def records_continue? diff --git a/app/controllers/api/v1/admin/trends/links/preview_card_providers_controller.rb b/app/controllers/api/v1/admin/trends/links/preview_card_providers_controller.rb index 5d9fcc82c0..8bb5e22716 100644 --- a/app/controllers/api/v1/admin/trends/links/preview_card_providers_controller.rb +++ b/app/controllers/api/v1/admin/trends/links/preview_card_providers_controller.rb @@ -42,10 +42,6 @@ class Api::V1::Admin::Trends::Links::PreviewCardProvidersController < Api::BaseC @providers = PreviewCardProvider.all.to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_admin_trends_links_preview_card_providers_url(pagination_params(max_id: pagination_max_id)) if records_continue? end @@ -54,12 +50,8 @@ class Api::V1::Admin::Trends::Links::PreviewCardProvidersController < Api::BaseC api_v1_admin_trends_links_preview_card_providers_url(pagination_params(min_id: pagination_since_id)) unless @providers.empty? end - def pagination_max_id - @providers.last.id - end - - def pagination_since_id - @providers.first.id + def pagination_collection + @providers end def records_continue? diff --git a/app/controllers/api/v1/blocks_controller.rb b/app/controllers/api/v1/blocks_controller.rb index 0934622f88..234ab2e82c 100644 --- a/app/controllers/api/v1/blocks_controller.rb +++ b/app/controllers/api/v1/blocks_controller.rb @@ -28,10 +28,6 @@ class Api::V1::BlocksController < Api::BaseController ) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_blocks_url pagination_params(max_id: pagination_max_id) if records_continue? end @@ -40,12 +36,8 @@ class Api::V1::BlocksController < Api::BaseController api_v1_blocks_url pagination_params(since_id: pagination_since_id) unless paginated_blocks.empty? end - def pagination_max_id - paginated_blocks.last.id - end - - def pagination_since_id - paginated_blocks.first.id + def pagination_collection + paginated_blocks end def records_continue? diff --git a/app/controllers/api/v1/bookmarks_controller.rb b/app/controllers/api/v1/bookmarks_controller.rb index 498eb16f44..b6bb987b6b 100644 --- a/app/controllers/api/v1/bookmarks_controller.rb +++ b/app/controllers/api/v1/bookmarks_controller.rb @@ -31,10 +31,6 @@ class Api::V1::BookmarksController < Api::BaseController 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 @@ -43,12 +39,8 @@ class Api::V1::BookmarksController < Api::BaseController 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 + def pagination_collection + results end def records_continue? diff --git a/app/controllers/api/v1/conversations_controller.rb b/app/controllers/api/v1/conversations_controller.rb index 6a3567e624..a95c816e1c 100644 --- a/app/controllers/api/v1/conversations_controller.rb +++ b/app/controllers/api/v1/conversations_controller.rb @@ -53,10 +53,6 @@ class Api::V1::ConversationsController < Api::BaseController .to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_conversations_url pagination_params(max_id: pagination_max_id) if records_continue? end diff --git a/app/controllers/api/v1/crypto/encrypted_messages_controller.rb b/app/controllers/api/v1/crypto/encrypted_messages_controller.rb index 68cf4384f7..d3de220393 100644 --- a/app/controllers/api/v1/crypto/encrypted_messages_controller.rb +++ b/app/controllers/api/v1/crypto/encrypted_messages_controller.rb @@ -29,10 +29,6 @@ class Api::V1::Crypto::EncryptedMessagesController < Api::BaseController @encrypted_messages = @current_device.encrypted_messages.to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_crypto_encrypted_messages_url pagination_params(max_id: pagination_max_id) if records_continue? end @@ -41,12 +37,8 @@ class Api::V1::Crypto::EncryptedMessagesController < Api::BaseController api_v1_crypto_encrypted_messages_url pagination_params(min_id: pagination_since_id) unless @encrypted_messages.empty? end - def pagination_max_id - @encrypted_messages.last.id - end - - def pagination_since_id - @encrypted_messages.first.id + def pagination_collection + @encrypted_messages end def records_continue? diff --git a/app/controllers/api/v1/domain_blocks_controller.rb b/app/controllers/api/v1/domain_blocks_controller.rb index 34def3c44a..3dee2d176c 100644 --- a/app/controllers/api/v1/domain_blocks_controller.rb +++ b/app/controllers/api/v1/domain_blocks_controller.rb @@ -38,10 +38,6 @@ class Api::V1::DomainBlocksController < Api::BaseController current_account.domain_blocks end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_domain_blocks_url pagination_params(max_id: pagination_max_id) if records_continue? end @@ -50,12 +46,8 @@ class Api::V1::DomainBlocksController < Api::BaseController api_v1_domain_blocks_url pagination_params(since_id: pagination_since_id) unless @blocks.empty? end - def pagination_max_id - @blocks.last.id - end - - def pagination_since_id - @blocks.first.id + def pagination_collection + @blocks end def records_continue? diff --git a/app/controllers/api/v1/endorsements_controller.rb b/app/controllers/api/v1/endorsements_controller.rb index 2216a9860d..9a723d89e4 100644 --- a/app/controllers/api/v1/endorsements_controller.rb +++ b/app/controllers/api/v1/endorsements_controller.rb @@ -28,10 +28,6 @@ class Api::V1::EndorsementsController < Api::BaseController current_account.endorsed_accounts.includes(:account_stat, :user).without_suspended end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path return if unlimited? @@ -44,12 +40,8 @@ class Api::V1::EndorsementsController < Api::BaseController api_v1_endorsements_url pagination_params(since_id: pagination_since_id) unless @accounts.empty? end - def pagination_max_id - @accounts.last.id - end - - def pagination_since_id - @accounts.first.id + def pagination_collection + @accounts end def records_continue? diff --git a/app/controllers/api/v1/favourites_controller.rb b/app/controllers/api/v1/favourites_controller.rb index faf1bda96a..73da538f5c 100644 --- a/app/controllers/api/v1/favourites_controller.rb +++ b/app/controllers/api/v1/favourites_controller.rb @@ -31,10 +31,6 @@ class Api::V1::FavouritesController < Api::BaseController current_account.favourites end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_favourites_url pagination_params(max_id: pagination_max_id) if records_continue? end @@ -43,12 +39,8 @@ class Api::V1::FavouritesController < Api::BaseController api_v1_favourites_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 + def pagination_collection + results end def records_continue? diff --git a/app/controllers/api/v1/follow_requests_controller.rb b/app/controllers/api/v1/follow_requests_controller.rb index 87f6df5f94..7ffd7614bb 100644 --- a/app/controllers/api/v1/follow_requests_controller.rb +++ b/app/controllers/api/v1/follow_requests_controller.rb @@ -48,10 +48,6 @@ class Api::V1::FollowRequestsController < Api::BaseController ) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_follow_requests_url pagination_params(max_id: pagination_max_id) if records_continue? end diff --git a/app/controllers/api/v1/followed_tags_controller.rb b/app/controllers/api/v1/followed_tags_controller.rb index eae2bdc010..8888612b16 100644 --- a/app/controllers/api/v1/followed_tags_controller.rb +++ b/app/controllers/api/v1/followed_tags_controller.rb @@ -22,10 +22,6 @@ class Api::V1::FollowedTagsController < Api::BaseController ) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_followed_tags_url pagination_params(max_id: pagination_max_id) if records_continue? end @@ -34,12 +30,8 @@ class Api::V1::FollowedTagsController < Api::BaseController api_v1_followed_tags_url pagination_params(since_id: pagination_since_id) unless @results.empty? end - def pagination_max_id - @results.last.id - end - - def pagination_since_id - @results.first.id + def pagination_collection + @results end def records_continue? diff --git a/app/controllers/api/v1/lists/accounts_controller.rb b/app/controllers/api/v1/lists/accounts_controller.rb index 0604ad60fc..aecf391049 100644 --- a/app/controllers/api/v1/lists/accounts_controller.rb +++ b/app/controllers/api/v1/lists/accounts_controller.rb @@ -55,10 +55,6 @@ class Api::V1::Lists::AccountsController < Api::BaseController params.permit(account_ids: []) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path return if unlimited? @@ -71,12 +67,8 @@ class Api::V1::Lists::AccountsController < Api::BaseController api_v1_list_accounts_url pagination_params(since_id: pagination_since_id) unless @accounts.empty? end - def pagination_max_id - @accounts.last.id - end - - def pagination_since_id - @accounts.first.id + def pagination_collection + @accounts end def records_continue? diff --git a/app/controllers/api/v1/mutes_controller.rb b/app/controllers/api/v1/mutes_controller.rb index 2fb685ac39..dbfd7e103a 100644 --- a/app/controllers/api/v1/mutes_controller.rb +++ b/app/controllers/api/v1/mutes_controller.rb @@ -28,10 +28,6 @@ class Api::V1::MutesController < Api::BaseController ) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_mutes_url pagination_params(max_id: pagination_max_id) if records_continue? end @@ -40,12 +36,8 @@ class Api::V1::MutesController < Api::BaseController api_v1_mutes_url pagination_params(since_id: pagination_since_id) unless paginated_mutes.empty? end - def pagination_max_id - paginated_mutes.last.id - end - - def pagination_since_id - paginated_mutes.first.id + def pagination_collection + paginated_mutes end def records_continue? diff --git a/app/controllers/api/v1/notifications/policies_controller.rb b/app/controllers/api/v1/notifications/policies_controller.rb new file mode 100644 index 0000000000..1ec336f9a5 --- /dev/null +++ b/app/controllers/api/v1/notifications/policies_controller.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class Api::V1::Notifications::PoliciesController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:notifications' }, only: :show + before_action -> { doorkeeper_authorize! :write, :'write:notifications' }, only: :update + + before_action :require_user! + before_action :set_policy + + def show + render json: @policy, serializer: REST::NotificationPolicySerializer + end + + def update + @policy.update!(resource_params) + render json: @policy, serializer: REST::NotificationPolicySerializer + end + + private + + def set_policy + @policy = NotificationPolicy.find_or_initialize_by(account: current_account) + + with_read_replica do + @policy.summarize! + end + end + + def resource_params + params.permit( + :filter_not_following, + :filter_not_followers, + :filter_new_accounts, + :filter_private_mentions + ) + end +end diff --git a/app/controllers/api/v1/notifications/requests_controller.rb b/app/controllers/api/v1/notifications/requests_controller.rb new file mode 100644 index 0000000000..6a26cc0e8a --- /dev/null +++ b/app/controllers/api/v1/notifications/requests_controller.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +class Api::V1::Notifications::RequestsController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:notifications' }, only: :index + before_action -> { doorkeeper_authorize! :write, :'write:notifications' }, except: :index + + before_action :require_user! + before_action :set_request, except: :index + + after_action :insert_pagination_headers, only: :index + + def index + with_read_replica do + @requests = load_requests + @relationships = relationships + end + + render json: @requests, each_serializer: REST::NotificationRequestSerializer, relationships: @relationships + end + + def show + render json: @request, serializer: REST::NotificationRequestSerializer + end + + def accept + AcceptNotificationRequestService.new.call(@request) + render_empty + end + + def dismiss + @request.update!(dismissed: true) + render_empty + end + + private + + def load_requests + requests = NotificationRequest.where(account: current_account).where(dismissed: truthy_param?(:dismissed) || false).includes(:last_status, from_account: [:account_stat, :user]).to_a_paginated_by_id( + limit_param(DEFAULT_ACCOUNTS_LIMIT), + params_slice(:max_id, :since_id, :min_id) + ) + + NotificationRequest.preload_cache_collection(requests) do |statuses| + cache_collection(statuses, Status) + end + end + + def relationships + StatusRelationshipsPresenter.new(@requests.map(&:last_status), current_user&.account_id) + end + + def set_request + @request = NotificationRequest.where(account: current_account).find(params[:id]) + end + + def next_path + api_v1_notifications_requests_url pagination_params(max_id: pagination_max_id) unless @requests.empty? + end + + def prev_path + api_v1_notifications_requests_url pagination_params(min_id: pagination_since_id) unless @requests.empty? + end + + def pagination_max_id + @requests.last.id + end + + def pagination_since_id + @requests.first.id + end + + def pagination_params(core_params) + params.slice(:dismissed).permit(:dismissed).merge(core_params) + end +end diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb index b1814e16ab..9c75516159 100644 --- a/app/controllers/api/v1/notifications_controller.rb +++ b/app/controllers/api/v1/notifications_controller.rb @@ -58,7 +58,8 @@ class Api::V1::NotificationsController < Api::BaseController current_account.notifications.without_suspended.browserable( types: Array(browserable_params[:types]), exclude_types: Array(browserable_params[:exclude_types]), - from_account_id: browserable_params[:account_id] + from_account_id: browserable_params[:account_id], + include_filtered: truthy_param?(:include_filtered) ) end @@ -66,10 +67,6 @@ class Api::V1::NotificationsController < Api::BaseController @notifications.reject { |notification| notification.target_status.nil? }.map(&:target_status) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_notifications_url pagination_params(max_id: pagination_max_id) unless @notifications.empty? end @@ -78,19 +75,15 @@ class Api::V1::NotificationsController < Api::BaseController api_v1_notifications_url pagination_params(min_id: pagination_since_id) unless @notifications.empty? end - def pagination_max_id - @notifications.last.id - end - - def pagination_since_id - @notifications.first.id + def pagination_collection + @notifications end def browserable_params - params.permit(:account_id, types: [], exclude_types: []) + params.permit(:account_id, :include_filtered, types: [], exclude_types: []) end def pagination_params(core_params) - params.slice(:limit, :account_id, :types, :exclude_types).permit(:limit, :account_id, types: [], exclude_types: []).merge(core_params) + params.slice(:limit, :account_id, :types, :exclude_types, :include_filtered).permit(:limit, :account_id, :include_filtered, types: [], exclude_types: []).merge(core_params) end end diff --git a/app/controllers/api/v1/scheduled_statuses_controller.rb b/app/controllers/api/v1/scheduled_statuses_controller.rb index 2220b6d22e..1217ed014e 100644 --- a/app/controllers/api/v1/scheduled_statuses_controller.rb +++ b/app/controllers/api/v1/scheduled_statuses_controller.rb @@ -47,10 +47,6 @@ class Api::V1::ScheduledStatusesController < Api::BaseController params.slice(:limit).permit(:limit).merge(core_params) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_scheduled_statuses_url pagination_params(max_id: pagination_max_id) if records_continue? end @@ -63,11 +59,7 @@ class Api::V1::ScheduledStatusesController < Api::BaseController @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT) end - def pagination_max_id - @statuses.last.id - end - - def pagination_since_id - @statuses.first.id + def pagination_collection + @statuses 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 069ad37cb2..bbc8082e0c 100644 --- a/app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb +++ b/app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb @@ -34,10 +34,6 @@ class Api::V1::Statuses::FavouritedByAccountsController < Api::V1::Statuses::Bas ) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_status_favourited_by_index_url pagination_params(max_id: pagination_max_id) if records_continue? end 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 b8a997518d..eaa5ef7255 100644 --- a/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb +++ b/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb @@ -30,10 +30,6 @@ class Api::V1::Statuses::RebloggedByAccountsController < Api::V1::Statuses::Base ) end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def next_path api_v1_status_reblogged_by_index_url pagination_params(max_id: pagination_max_id) if records_continue? end diff --git a/app/controllers/api/v1/timelines/base_controller.rb b/app/controllers/api/v1/timelines/base_controller.rb index 173e173cc9..e79eba79ee 100644 --- a/app/controllers/api/v1/timelines/base_controller.rb +++ b/app/controllers/api/v1/timelines/base_controller.rb @@ -5,16 +5,8 @@ class Api::V1::Timelines::BaseController < Api::BaseController private - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - - def pagination_max_id - @statuses.last.id - end - - def pagination_since_id - @statuses.first.id + def pagination_collection + @statuses end def next_path_params diff --git a/app/controllers/api/v1/trends/links_controller.rb b/app/controllers/api/v1/trends/links_controller.rb index 57cfa0b7e4..8edf5bbcef 100644 --- a/app/controllers/api/v1/trends/links_controller.rb +++ b/app/controllers/api/v1/trends/links_controller.rb @@ -34,10 +34,6 @@ class Api::V1::Trends::LinksController < Api::BaseController scope end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def pagination_params(core_params) params.slice(:limit).permit(:limit).merge(core_params) end diff --git a/app/controllers/api/v1/trends/statuses_controller.rb b/app/controllers/api/v1/trends/statuses_controller.rb index c186864c3b..48bfe11991 100644 --- a/app/controllers/api/v1/trends/statuses_controller.rb +++ b/app/controllers/api/v1/trends/statuses_controller.rb @@ -32,10 +32,6 @@ class Api::V1::Trends::StatusesController < Api::BaseController scope end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def pagination_params(core_params) params.slice(:limit).permit(:limit).merge(core_params) end diff --git a/app/controllers/api/v1/trends/tags_controller.rb b/app/controllers/api/v1/trends/tags_controller.rb index 6cc8194def..0d8e27552e 100644 --- a/app/controllers/api/v1/trends/tags_controller.rb +++ b/app/controllers/api/v1/trends/tags_controller.rb @@ -30,10 +30,6 @@ class Api::V1::Trends::TagsController < Api::BaseController Trends.tags.query.allowed end - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - def pagination_params(core_params) params.slice(:limit).permit(:limit).merge(core_params) end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 00f5c7c11e..c76110dba7 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -131,7 +131,7 @@ class ApplicationController < ActionController::Base end def single_user_mode? - @single_user_mode ||= Rails.configuration.x.single_user_mode && Account.where('id > 0').exists? + @single_user_mode ||= Rails.configuration.x.single_user_mode && Account.without_internal.exists? end def use_seamless_external_login? @@ -180,7 +180,7 @@ class ApplicationController < ActionController::Base use_pack 'error' render 'errors/self_destruct', layout: 'auth', status: 410, formats: [:html] end - format.json { render json: { error: Rack::Utils::HTTP_STATUS_CODES[410] }, status: code } + format.json { render json: { error: Rack::Utils::HTTP_STATUS_CODES[410] }, status: 410 } end end diff --git a/app/controllers/concerns/api/error_handling.rb b/app/controllers/concerns/api/error_handling.rb new file mode 100644 index 0000000000..ad559fe2d7 --- /dev/null +++ b/app/controllers/concerns/api/error_handling.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Api::ErrorHandling + extend ActiveSupport::Concern + + included do + rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e| + render json: { error: e.to_s }, status: 422 + end + + rescue_from ActiveRecord::RecordNotUnique do + render json: { error: 'Duplicate record' }, status: 422 + end + + rescue_from Date::Error do + render json: { error: 'Invalid date supplied' }, status: 422 + end + + rescue_from ActiveRecord::RecordNotFound do + render json: { error: 'Record not found' }, status: 404 + end + + rescue_from HTTP::Error, Mastodon::UnexpectedResponseError do + render json: { error: 'Remote data could not be fetched' }, status: 503 + end + + rescue_from OpenSSL::SSL::SSLError do + render json: { error: 'Remote SSL certificate could not be verified' }, status: 503 + end + + rescue_from Mastodon::NotPermittedError do + render json: { error: 'This action is not allowed' }, status: 403 + end + + rescue_from Seahorse::Client::NetworkingError do |e| + Rails.logger.warn "Storage server error: #{e}" + render json: { error: 'There was a temporary problem serving your request, please try again' }, status: 503 + end + + rescue_from Mastodon::RaceConditionError, Stoplight::Error::RedLight do + 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, Mastodon::InvalidParameterError do |e| + render json: { error: e.to_s }, status: 400 + end + end +end diff --git a/app/controllers/instance_actors_controller.rb b/app/controllers/instance_actors_controller.rb index 8422d74bc3..f2b1eaa3e7 100644 --- a/app/controllers/instance_actors_controller.rb +++ b/app/controllers/instance_actors_controller.rb @@ -6,6 +6,8 @@ class InstanceActorsController < ActivityPub::BaseController serialization_scope nil before_action :set_account + + skip_before_action :authenticate_user! # From `AccountOwnedConcern` skip_before_action :require_functional! skip_before_action :update_user_sign_in @@ -16,6 +18,11 @@ class InstanceActorsController < ActivityPub::BaseController private + # Skips various `before_action` from `AccountOwnedConcern` + def account_required? + false + end + def set_account @account = Account.representative end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index daa27195e9..233c242e4a 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -28,14 +28,6 @@ module ApplicationHelper number_to_human(number, **options) end - def active_nav_class(*paths) - paths.any? { |path| current_page?(path) } ? 'active' : '' - end - - def show_landing_strip? - !user_signed_in? && !single_user_mode? - end - def open_registrations? Setting.registrations_mode == 'open' end @@ -122,7 +114,7 @@ module ApplicationHelper end def check_icon - content_tag(:svg, tag.path('fill-rule': 'evenodd', 'clip-rule': 'evenodd', d: 'M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z'), xmlns: 'http://www.w3.org/2000/svg', viewBox: '0 0 20 20', fill: 'currentColor') + inline_svg_tag 'check.svg' end def visibility_icon(status) @@ -214,7 +206,7 @@ module ApplicationHelper state_params[:moved_to_account] = current_account.moved_to_account end - state_params[:owner] = Account.local.without_suspended.where('id > 0').first if single_user_mode? + state_params[:owner] = Account.local.without_suspended.without_internal.first if single_user_mode? json = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(state_params), serializer: InitialStateSerializer).to_json # rubocop:disable Rails/OutputSafety diff --git a/app/helpers/branding_helper.rb b/app/helpers/branding_helper.rb index 2b9c233c23..f72d6df5d9 100644 --- a/app/helpers/branding_helper.rb +++ b/app/helpers/branding_helper.rb @@ -21,15 +21,4 @@ module BrandingHelper def render_logo image_pack_tag('logo.svg', alt: 'Mastodon', class: 'logo logo--icon') end - - def render_symbol(version = :icon) - path = case version - when :icon - 'logo-symbol-icon.svg' - when :wordmark - 'logo-symbol-wordmark.svg' - end - - render(file: Rails.root.join('app', 'javascript', 'images', path)).html_safe # rubocop:disable Rails/OutputSafety - end end diff --git a/app/helpers/context_helper.rb b/app/helpers/context_helper.rb index 1b79a089bc..412be4f48d 100644 --- a/app/helpers/context_helper.rb +++ b/app/helpers/context_helper.rb @@ -25,12 +25,21 @@ module ContextHelper memorial: { 'toot' => 'http://joinmastodon.org/ns#', 'memorial' => 'toot:memorial' }, voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' }, olm: { - 'toot' => 'http://joinmastodon.org/ns#', 'Device' => 'toot:Device', 'Ed25519Signature' => 'toot:Ed25519Signature', 'Ed25519Key' => 'toot:Ed25519Key', 'Curve25519Key' => 'toot:Curve25519Key', 'EncryptedMessage' => 'toot:EncryptedMessage', 'publicKeyBase64' => 'toot:publicKeyBase64', 'deviceId' => 'toot:deviceId', + 'toot' => 'http://joinmastodon.org/ns#', + 'Device' => 'toot:Device', + 'Ed25519Signature' => 'toot:Ed25519Signature', + 'Ed25519Key' => 'toot:Ed25519Key', + 'Curve25519Key' => 'toot:Curve25519Key', + 'EncryptedMessage' => 'toot:EncryptedMessage', + 'publicKeyBase64' => 'toot:publicKeyBase64', + 'deviceId' => 'toot:deviceId', 'claim' => { '@type' => '@id', '@id' => 'toot:claim' }, 'fingerprintKey' => { '@type' => '@id', '@id' => 'toot:fingerprintKey' }, 'identityKey' => { '@type' => '@id', '@id' => 'toot:identityKey' }, 'devices' => { '@type' => '@id', '@id' => 'toot:devices' }, - 'messageFranking' => 'toot:messageFranking', 'messageType' => 'toot:messageType', 'cipherText' => 'toot:cipherText' + 'messageFranking' => 'toot:messageFranking', + 'messageType' => 'toot:messageType', + 'cipherText' => 'toot:cipherText', }, suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' }, }.freeze diff --git a/app/helpers/languages_helper.rb b/app/helpers/languages_helper.rb index 87f0f288d3..9e1c0a7db1 100644 --- a/app/helpers/languages_helper.rb +++ b/app/helpers/languages_helper.rb @@ -109,6 +109,7 @@ module LanguagesHelper mn: ['Mongolian', 'Монгол хэл'].freeze, mr: ['Marathi', 'मराठी'].freeze, ms: ['Malay', 'Bahasa Melayu'].freeze, + 'ms-Arab': ['Jawi Malay', 'بهاس ملايو'].freeze, mt: ['Maltese', 'Malti'].freeze, my: ['Burmese', 'ဗမာစာ'].freeze, na: ['Nauru', 'Ekakairũ Naoero'].freeze, @@ -127,7 +128,7 @@ module LanguagesHelper om: ['Oromo', 'Afaan Oromoo'].freeze, or: ['Oriya', 'ଓଡ଼ିଆ'].freeze, os: ['Ossetian', 'ирон æвзаг'].freeze, - pa: ['Panjabi', 'ਪੰਜਾਬੀ'].freeze, + pa: ['Punjabi', 'ਪੰਜਾਬੀ'].freeze, pi: ['Pāli', 'पाऴि'].freeze, pl: ['Polish', 'Polski'].freeze, ps: ['Pashto', 'پښتو'].freeze, @@ -191,15 +192,20 @@ module LanguagesHelper chr: ['Cherokee', 'ᏣᎳᎩ ᎦᏬᏂᎯᏍᏗ'].freeze, ckb: ['Sorani (Kurdish)', 'سۆرانی'].freeze, cnr: ['Montenegrin', 'crnogorski'].freeze, + csb: ['Kashubian', 'Kaszëbsczi'].freeze, jbo: ['Lojban', 'la .lojban.'].freeze, kab: ['Kabyle', 'Taqbaylit'].freeze, ldn: ['Láadan', 'Láadan'].freeze, lfn: ['Lingua Franca Nova', 'lingua franca nova'].freeze, + moh: ['Mohawk', 'Kanienʼkéha'].freeze, + nds: ['Low German', 'Plattdüütsch'].freeze, + pdc: ['Pennsylvania Dutch', 'Pennsilfaani-Deitsch'].freeze, sco: ['Scots', 'Scots'].freeze, sma: ['Southern Sami', 'Åarjelsaemien Gïele'].freeze, smj: ['Lule Sami', 'Julevsámegiella'].freeze, szl: ['Silesian', 'ślůnsko godka'].freeze, tok: ['Toki Pona', 'toki pona'].freeze, + vai: ['Vai', 'ꕙꔤ'].freeze, xal: ['Kalmyk', 'Хальмг келн'].freeze, zba: ['Balaibalan', 'باليبلن'].freeze, zgh: ['Standard Moroccan Tamazight', 'ⵜⴰⵎⴰⵣⵉⵖⵜ'].freeze, diff --git a/app/helpers/statuses_helper.rb b/app/helpers/statuses_helper.rb index 286c53d834..ca693a8a78 100644 --- a/app/helpers/statuses_helper.rb +++ b/app/helpers/statuses_helper.rb @@ -4,14 +4,6 @@ module StatusesHelper EMBEDDED_CONTROLLER = 'statuses' EMBEDDED_ACTION = 'embed' - def link_to_newer(url) - link_to t('statuses.show_newer'), url, class: 'load-more load-gap' - end - - def link_to_older(url) - link_to t('statuses.show_older'), url, class: 'load-more load-gap' - end - def nothing_here(extra_classes = '') content_tag(:div, class: "nothing-here #{extra_classes}") do t('accounts.nothing_here') diff --git a/app/javascript/core/admin.js b/app/javascript/core/admin.js deleted file mode 100644 index d1c7572640..0000000000 --- a/app/javascript/core/admin.js +++ /dev/null @@ -1,232 +0,0 @@ -// This file will be loaded on admin pages, regardless of theme. - -import 'packs/public-path'; -import Rails from '@rails/ujs'; - -import ready from '../mastodon/ready'; - -const setAnnouncementEndsAttributes = (target) => { - const valid = target?.value && target?.validity?.valid; - const element = document.querySelector('input[type="datetime-local"]#announcement_ends_at'); - if (valid) { - element.classList.remove('optional'); - element.required = true; - element.min = target.value; - } else { - element.classList.add('optional'); - element.removeAttribute('required'); - element.removeAttribute('min'); - } -}; - -Rails.delegate(document, 'input[type="datetime-local"]#announcement_starts_at', 'change', ({ target }) => { - setAnnouncementEndsAttributes(target); -}); - -const batchCheckboxClassName = '.batch-checkbox input[type="checkbox"]'; - -const showSelectAll = () => { - const selectAllMatchingElement = document.querySelector('.batch-table__select-all'); - selectAllMatchingElement.classList.add('active'); -}; - -const hideSelectAll = () => { - const selectAllMatchingElement = document.querySelector('.batch-table__select-all'); - const hiddenField = document.querySelector('#select_all_matching'); - const selectedMsg = document.querySelector('.batch-table__select-all .selected'); - const notSelectedMsg = document.querySelector('.batch-table__select-all .not-selected'); - - selectAllMatchingElement.classList.remove('active'); - selectedMsg.classList.remove('active'); - notSelectedMsg.classList.add('active'); - hiddenField.value = '0'; -}; - -Rails.delegate(document, '#batch_checkbox_all', 'change', ({ target }) => { - const selectAllMatchingElement = document.querySelector('.batch-table__select-all'); - - [].forEach.call(document.querySelectorAll(batchCheckboxClassName), (content) => { - content.checked = target.checked; - }); - - if (selectAllMatchingElement) { - if (target.checked) { - showSelectAll(); - } else { - hideSelectAll(); - } - } -}); - -Rails.delegate(document, '.batch-table__select-all button', 'click', () => { - const hiddenField = document.querySelector('#select_all_matching'); - const active = hiddenField.value === '1'; - const selectedMsg = document.querySelector('.batch-table__select-all .selected'); - const notSelectedMsg = document.querySelector('.batch-table__select-all .not-selected'); - - if (active) { - hiddenField.value = '0'; - selectedMsg.classList.remove('active'); - notSelectedMsg.classList.add('active'); - } else { - hiddenField.value = '1'; - notSelectedMsg.classList.remove('active'); - selectedMsg.classList.add('active'); - } -}); - -Rails.delegate(document, batchCheckboxClassName, 'change', () => { - const checkAllElement = document.querySelector('#batch_checkbox_all'); - const selectAllMatchingElement = document.querySelector('.batch-table__select-all'); - - if (checkAllElement) { - checkAllElement.checked = [].every.call(document.querySelectorAll(batchCheckboxClassName), (content) => content.checked); - checkAllElement.indeterminate = !checkAllElement.checked && [].some.call(document.querySelectorAll(batchCheckboxClassName), (content) => content.checked); - - if (selectAllMatchingElement) { - if (checkAllElement.checked) { - showSelectAll(); - } else { - hideSelectAll(); - } - } - } -}); - -Rails.delegate(document, '.media-spoiler-show-button', 'click', () => { - [].forEach.call(document.querySelectorAll('button.media-spoiler'), (element) => { - element.click(); - }); -}); - -Rails.delegate(document, '.media-spoiler-hide-button', 'click', () => { - [].forEach.call(document.querySelectorAll('.spoiler-button.spoiler-button--visible button'), (element) => { - element.click(); - }); -}); - -Rails.delegate(document, '.filter-subset--with-select select', 'change', ({ target }) => { - target.form.submit(); -}); - -const onDomainBlockSeverityChange = (target) => { - const rejectMediaDiv = document.querySelector('.input.with_label.domain_block_reject_media'); - const rejectReportsDiv = document.querySelector('.input.with_label.domain_block_reject_reports'); - - if (rejectMediaDiv) { - rejectMediaDiv.style.display = (target.value === 'suspend') ? 'none' : 'block'; - } - - if (rejectReportsDiv) { - rejectReportsDiv.style.display = (target.value === 'suspend') ? 'none' : 'block'; - } -}; - -Rails.delegate(document, '#domain_block_severity', 'change', ({ target }) => onDomainBlockSeverityChange(target)); - -const onEnableBootstrapTimelineAccountsChange = (target) => { - const bootstrapTimelineAccountsField = document.querySelector('#form_admin_settings_bootstrap_timeline_accounts'); - - if (bootstrapTimelineAccountsField) { - bootstrapTimelineAccountsField.disabled = !target.checked; - if (target.checked) { - bootstrapTimelineAccountsField.parentElement.classList.remove('disabled'); - bootstrapTimelineAccountsField.parentElement.parentElement.classList.remove('disabled'); - } else { - bootstrapTimelineAccountsField.parentElement.classList.add('disabled'); - bootstrapTimelineAccountsField.parentElement.parentElement.classList.add('disabled'); - } - } -}; - -Rails.delegate(document, '#form_admin_settings_enable_bootstrap_timeline_accounts', 'change', ({ target }) => onEnableBootstrapTimelineAccountsChange(target)); - -const onChangeRegistrationMode = (target) => { - const enabled = target.value === 'approved'; - - [].forEach.call(document.querySelectorAll('.form_admin_settings_registrations_mode .warning-hint'), (warning_hint) => { - warning_hint.style.display = target.value === 'open' ? 'inline' : 'none'; - }); - - [].forEach.call(document.querySelectorAll('#form_admin_settings_require_invite_text'), (input) => { - input.disabled = !enabled; - if (enabled) { - let element = input; - do { - element.classList.remove('disabled'); - element = element.parentElement; - } while (element && !element.classList.contains('fields-group')); - } else { - let element = input; - do { - element.classList.add('disabled'); - element = element.parentElement; - } while (element && !element.classList.contains('fields-group')); - } - }); -}; - -const convertUTCDateTimeToLocal = (value) => { - const date = new Date(value + 'Z'); - const twoChars = (x) => (x.toString().padStart(2, '0')); - return `${date.getFullYear()}-${twoChars(date.getMonth()+1)}-${twoChars(date.getDate())}T${twoChars(date.getHours())}:${twoChars(date.getMinutes())}`; -}; - -const convertLocalDatetimeToUTC = (value) => { - const re = /^([0-9]{4,})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2})/; - const match = re.exec(value); - const date = new Date(match[1], match[2] - 1, match[3], match[4], match[5]); - const fullISO8601 = date.toISOString(); - return fullISO8601.slice(0, fullISO8601.indexOf('T') + 6); -}; - -Rails.delegate(document, '#form_admin_settings_registrations_mode', 'change', ({ target }) => onChangeRegistrationMode(target)); - -ready(() => { - const domainBlockSeverityInput = document.getElementById('domain_block_severity'); - if (domainBlockSeverityInput) onDomainBlockSeverityChange(domainBlockSeverityInput); - - const enableBootstrapTimelineAccounts = document.getElementById('form_admin_settings_enable_bootstrap_timeline_accounts'); - if (enableBootstrapTimelineAccounts) onEnableBootstrapTimelineAccountsChange(enableBootstrapTimelineAccounts); - - const registrationMode = document.getElementById('form_admin_settings_registrations_mode'); - if (registrationMode) onChangeRegistrationMode(registrationMode); - - const checkAllElement = document.querySelector('#batch_checkbox_all'); - if (checkAllElement) { - checkAllElement.checked = [].every.call(document.querySelectorAll(batchCheckboxClassName), (content) => content.checked); - checkAllElement.indeterminate = !checkAllElement.checked && [].some.call(document.querySelectorAll(batchCheckboxClassName), (content) => content.checked); - } - - document.querySelector('a#add-instance-button')?.addEventListener('click', (e) => { - const domain = document.querySelector('input[type="text"]#by_domain')?.value; - - if (domain) { - const url = new URL(event.target.href); - url.searchParams.set('_domain', domain); - e.target.href = url; - } - }); - - [].forEach.call(document.querySelectorAll('input[type="datetime-local"]'), element => { - if (element.value) { - element.value = convertUTCDateTimeToLocal(element.value); - } - if (element.placeholder) { - element.placeholder = convertUTCDateTimeToLocal(element.placeholder); - } - }); - - Rails.delegate(document, 'form', 'submit', ({ target }) => { - [].forEach.call(target.querySelectorAll('input[type="datetime-local"]'), element => { - if (element.value && element.validity.valid) { - element.value = convertLocalDatetimeToUTC(element.value); - } - }); - }); - - const announcementStartsAt = document.querySelector('input[type="datetime-local"]#announcement_starts_at'); - if (announcementStartsAt) { - setAnnouncementEndsAttributes(announcementStartsAt); - } -}); diff --git a/app/javascript/core/admin.ts b/app/javascript/core/admin.ts new file mode 100644 index 0000000000..0642affef0 --- /dev/null +++ b/app/javascript/core/admin.ts @@ -0,0 +1,340 @@ +// This file will be loaded on admin pages, regardless of theme. + +import 'packs/public-path'; + +import Rails from '@rails/ujs'; + +import ready from '../mastodon/ready'; + +const setAnnouncementEndsAttributes = (target: HTMLInputElement) => { + const valid = target.value && target.validity.valid; + const element = document.querySelector( + 'input[type="datetime-local"]#announcement_ends_at', + ); + + if (!element) return; + + if (valid) { + element.classList.remove('optional'); + element.required = true; + element.min = target.value; + } else { + element.classList.add('optional'); + element.removeAttribute('required'); + element.removeAttribute('min'); + } +}; + +Rails.delegate( + document, + 'input[type="datetime-local"]#announcement_starts_at', + 'change', + ({ target }) => { + if (target instanceof HTMLInputElement) + setAnnouncementEndsAttributes(target); + }, +); + +const batchCheckboxClassName = '.batch-checkbox input[type="checkbox"]'; + +const showSelectAll = () => { + const selectAllMatchingElement = document.querySelector( + '.batch-table__select-all', + ); + selectAllMatchingElement?.classList.add('active'); +}; + +const hideSelectAll = () => { + const selectAllMatchingElement = document.querySelector( + '.batch-table__select-all', + ); + const hiddenField = document.querySelector( + 'input#select_all_matching', + ); + const selectedMsg = document.querySelector( + '.batch-table__select-all .selected', + ); + const notSelectedMsg = document.querySelector( + '.batch-table__select-all .not-selected', + ); + + selectAllMatchingElement?.classList.remove('active'); + selectedMsg?.classList.remove('active'); + notSelectedMsg?.classList.add('active'); + if (hiddenField) hiddenField.value = '0'; +}; + +Rails.delegate(document, '#batch_checkbox_all', 'change', ({ target }) => { + if (!(target instanceof HTMLInputElement)) return; + + const selectAllMatchingElement = document.querySelector( + '.batch-table__select-all', + ); + + document + .querySelectorAll(batchCheckboxClassName) + .forEach((content) => { + content.checked = target.checked; + }); + + if (selectAllMatchingElement) { + if (target.checked) { + showSelectAll(); + } else { + hideSelectAll(); + } + } +}); + +Rails.delegate(document, '.batch-table__select-all button', 'click', () => { + const hiddenField = document.querySelector( + '#select_all_matching', + ); + + if (!hiddenField) return; + + const active = hiddenField.value === '1'; + const selectedMsg = document.querySelector( + '.batch-table__select-all .selected', + ); + const notSelectedMsg = document.querySelector( + '.batch-table__select-all .not-selected', + ); + + if (!selectedMsg || !notSelectedMsg) return; + + if (active) { + hiddenField.value = '0'; + selectedMsg.classList.remove('active'); + notSelectedMsg.classList.add('active'); + } else { + hiddenField.value = '1'; + notSelectedMsg.classList.remove('active'); + selectedMsg.classList.add('active'); + } +}); + +Rails.delegate(document, batchCheckboxClassName, 'change', () => { + const checkAllElement = document.querySelector( + 'input#batch_checkbox_all', + ); + const selectAllMatchingElement = document.querySelector( + '.batch-table__select-all', + ); + + if (checkAllElement) { + const allCheckboxes = Array.from( + document.querySelectorAll(batchCheckboxClassName), + ); + checkAllElement.checked = allCheckboxes.every((content) => content.checked); + checkAllElement.indeterminate = + !checkAllElement.checked && + allCheckboxes.some((content) => content.checked); + + if (selectAllMatchingElement) { + if (checkAllElement.checked) { + showSelectAll(); + } else { + hideSelectAll(); + } + } + } +}); + +Rails.delegate( + document, + '.filter-subset--with-select select', + 'change', + ({ target }) => { + if (target instanceof HTMLSelectElement) target.form?.submit(); + }, +); + +const onDomainBlockSeverityChange = (target: HTMLSelectElement) => { + const rejectMediaDiv = document.querySelector( + '.input.with_label.domain_block_reject_media', + ); + const rejectReportsDiv = document.querySelector( + '.input.with_label.domain_block_reject_reports', + ); + + if (rejectMediaDiv && rejectMediaDiv instanceof HTMLElement) { + rejectMediaDiv.style.display = + target.value === 'suspend' ? 'none' : 'block'; + } + + if (rejectReportsDiv && rejectReportsDiv instanceof HTMLElement) { + rejectReportsDiv.style.display = + target.value === 'suspend' ? 'none' : 'block'; + } +}; + +Rails.delegate(document, '#domain_block_severity', 'change', ({ target }) => { + if (target instanceof HTMLSelectElement) onDomainBlockSeverityChange(target); +}); + +const onEnableBootstrapTimelineAccountsChange = (target: HTMLInputElement) => { + const bootstrapTimelineAccountsField = + document.querySelector( + '#form_admin_settings_bootstrap_timeline_accounts', + ); + + if (bootstrapTimelineAccountsField) { + bootstrapTimelineAccountsField.disabled = !target.checked; + if (target.checked) { + bootstrapTimelineAccountsField.parentElement?.classList.remove( + 'disabled', + ); + bootstrapTimelineAccountsField.parentElement?.parentElement?.classList.remove( + 'disabled', + ); + } else { + bootstrapTimelineAccountsField.parentElement?.classList.add('disabled'); + bootstrapTimelineAccountsField.parentElement?.parentElement?.classList.add( + 'disabled', + ); + } + } +}; + +Rails.delegate( + document, + '#form_admin_settings_enable_bootstrap_timeline_accounts', + 'change', + ({ target }) => { + if (target instanceof HTMLInputElement) + onEnableBootstrapTimelineAccountsChange(target); + }, +); + +const onChangeRegistrationMode = (target: HTMLSelectElement) => { + const enabled = target.value === 'approved'; + + document + .querySelectorAll( + '.form_admin_settings_registrations_mode .warning-hint', + ) + .forEach((warning_hint) => { + warning_hint.style.display = target.value === 'open' ? 'inline' : 'none'; + }); + + document + .querySelectorAll( + 'input#form_admin_settings_require_invite_text', + ) + .forEach((input) => { + input.disabled = !enabled; + if (enabled) { + let element: HTMLElement | null = input; + do { + element.classList.remove('disabled'); + element = element.parentElement; + } while (element && !element.classList.contains('fields-group')); + } else { + let element: HTMLElement | null = input; + do { + element.classList.add('disabled'); + element = element.parentElement; + } while (element && !element.classList.contains('fields-group')); + } + }); +}; + +const convertUTCDateTimeToLocal = (value: string) => { + const date = new Date(value + 'Z'); + const twoChars = (x: number) => x.toString().padStart(2, '0'); + return `${date.getFullYear()}-${twoChars(date.getMonth() + 1)}-${twoChars(date.getDate())}T${twoChars(date.getHours())}:${twoChars(date.getMinutes())}`; +}; + +function convertLocalDatetimeToUTC(value: string) { + const date = new Date(value); + const fullISO8601 = date.toISOString(); + return fullISO8601.slice(0, fullISO8601.indexOf('T') + 6); +} + +Rails.delegate( + document, + '#form_admin_settings_registrations_mode', + 'change', + ({ target }) => { + if (target instanceof HTMLSelectElement) onChangeRegistrationMode(target); + }, +); + +ready(() => { + const domainBlockSeveritySelect = document.querySelector( + 'select#domain_block_severity', + ); + if (domainBlockSeveritySelect) + onDomainBlockSeverityChange(domainBlockSeveritySelect); + + const enableBootstrapTimelineAccounts = + document.querySelector( + 'input#form_admin_settings_enable_bootstrap_timeline_accounts', + ); + if (enableBootstrapTimelineAccounts) + onEnableBootstrapTimelineAccountsChange(enableBootstrapTimelineAccounts); + + const registrationMode = document.querySelector( + 'select#form_admin_settings_registrations_mode', + ); + if (registrationMode) onChangeRegistrationMode(registrationMode); + + const checkAllElement = document.querySelector( + 'input#batch_checkbox_all', + ); + if (checkAllElement) { + const allCheckboxes = Array.from( + document.querySelectorAll(batchCheckboxClassName), + ); + checkAllElement.checked = allCheckboxes.every((content) => content.checked); + checkAllElement.indeterminate = + !checkAllElement.checked && + allCheckboxes.some((content) => content.checked); + } + + document + .querySelector('a#add-instance-button') + ?.addEventListener('click', (e) => { + const domain = document.querySelector( + 'input[type="text"]#by_domain', + )?.value; + + if (domain && e.target instanceof HTMLAnchorElement) { + const url = new URL(e.target.href); + url.searchParams.set('_domain', domain); + e.target.href = url.toString(); + } + }); + + document + .querySelectorAll('input[type="datetime-local"]') + .forEach((element) => { + if (element.value) { + element.value = convertUTCDateTimeToLocal(element.value); + } + if (element.placeholder) { + element.placeholder = convertUTCDateTimeToLocal(element.placeholder); + } + }); + + Rails.delegate(document, 'form', 'submit', ({ target }) => { + if (target instanceof HTMLFormElement) + target + .querySelectorAll('input[type="datetime-local"]') + .forEach((element) => { + if (element.value && element.validity.valid) { + element.value = convertLocalDatetimeToUTC(element.value); + } + }); + }); + + const announcementStartsAt = document.querySelector( + 'input[type="datetime-local"]#announcement_starts_at', + ); + if (announcementStartsAt) { + setAnnouncementEndsAttributes(announcementStartsAt); + } +}).catch((reason) => { + throw reason; +}); diff --git a/app/javascript/core/embed.js b/app/javascript/core/embed.js deleted file mode 100644 index d1e8f6b108..0000000000 --- a/app/javascript/core/embed.js +++ /dev/null @@ -1,25 +0,0 @@ -// This file will be loaded on embed pages, regardless of theme. - -import 'packs/public-path'; - -window.addEventListener('message', e => { - const data = e.data || {}; - - if (!window.parent || data.type !== 'setHeight') { - return; - } - - function setEmbedHeight () { - window.parent.postMessage({ - type: 'setHeight', - id: data.id, - height: document.getElementsByTagName('html')[0].scrollHeight, - }, '*'); - } - - if (['interactive', 'complete'].includes(document.readyState)) { - setEmbedHeight(); - } else { - document.addEventListener('DOMContentLoaded', setEmbedHeight); - } -}); diff --git a/app/javascript/core/embed.ts b/app/javascript/core/embed.ts new file mode 100644 index 0000000000..6766cd7788 --- /dev/null +++ b/app/javascript/core/embed.ts @@ -0,0 +1,41 @@ +// This file will be loaded on embed pages, regardless of theme. + +import 'packs/public-path'; +import ready from '../mastodon/ready'; + +interface SetHeightMessage { + type: 'setHeight'; + id: string; + height: number; +} + +function isSetHeightMessage(data: unknown): data is SetHeightMessage { + if ( + data && + typeof data === 'object' && + 'type' in data && + data.type === 'setHeight' + ) + return true; + else return false; +} + +window.addEventListener('message', (e) => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- typings are not correct, it can be null in very rare cases + if (!e.data || !isSetHeightMessage(e.data) || !window.parent) return; + + const data = e.data; + + ready(() => { + window.parent.postMessage( + { + type: 'setHeight', + id: data.id, + height: document.getElementsByTagName('html')[0].scrollHeight, + }, + '*', + ); + }).catch((e) => { + console.error('Error in setHeightMessage postMessage', e); + }); +}); diff --git a/app/javascript/core/settings.js b/app/javascript/core/settings.js deleted file mode 100644 index 23367d2d31..0000000000 --- a/app/javascript/core/settings.js +++ /dev/null @@ -1,44 +0,0 @@ -// This file will be loaded on settings pages, regardless of theme. - -import 'packs/public-path'; -import Rails from '@rails/ujs'; - -Rails.delegate(document, '#edit_profile input[type=file]', 'change', ({ target }) => { - const avatar = document.getElementById(target.id + '-preview'); - const [file] = target.files || []; - const url = file ? URL.createObjectURL(file) : avatar.dataset.originalSrc; - - avatar.src = url; -}); - -Rails.delegate(document, '.input-copy input', 'click', ({ target }) => { - target.focus(); - target.select(); - target.setSelectionRange(0, target.value.length); -}); - -Rails.delegate(document, '.input-copy button', 'click', ({ target }) => { - const input = target.parentNode.querySelector('.input-copy__wrapper input'); - - const oldReadOnly = input.readonly; - - input.readonly = false; - input.focus(); - input.select(); - input.setSelectionRange(0, input.value.length); - - try { - if (document.execCommand('copy')) { - input.blur(); - target.parentNode.classList.add('copied'); - - setTimeout(() => { - target.parentNode.classList.remove('copied'); - }, 700); - } - } catch (err) { - console.error(err); - } - - input.readonly = oldReadOnly; -}); diff --git a/app/javascript/core/settings.ts b/app/javascript/core/settings.ts new file mode 100644 index 0000000000..ea6a99ec80 --- /dev/null +++ b/app/javascript/core/settings.ts @@ -0,0 +1,70 @@ +// This file will be loaded on settings pages, regardless of theme. + +import 'packs/public-path'; +import Rails from '@rails/ujs'; + +Rails.delegate( + document, + '#edit_profile input[type=file]', + 'change', + ({ target }) => { + if (!(target instanceof HTMLInputElement)) return; + + const avatar = document.querySelector( + `img#${target.id}-preview`, + ); + + if (!avatar) return; + + let file: File | undefined; + if (target.files) file = target.files[0]; + + const url = file ? URL.createObjectURL(file) : avatar.dataset.originalSrc; + + if (url) avatar.src = url; + }, +); + +Rails.delegate(document, '.input-copy input', 'click', ({ target }) => { + if (!(target instanceof HTMLInputElement)) return; + + target.focus(); + target.select(); + target.setSelectionRange(0, target.value.length); +}); + +Rails.delegate(document, '.input-copy button', 'click', ({ target }) => { + if (!(target instanceof HTMLButtonElement)) return; + + const input = target.parentNode?.querySelector( + '.input-copy__wrapper input', + ); + + if (!input) return; + + const oldReadOnly = input.readOnly; + + input.readOnly = false; + input.focus(); + input.select(); + input.setSelectionRange(0, input.value.length); + + try { + if (document.execCommand('copy')) { + input.blur(); + + const parent = target.parentElement; + + if (!parent) return; + parent.classList.add('copied'); + + setTimeout(() => { + parent.classList.remove('copied'); + }, 700); + } + } catch (err) { + console.error(err); + } + + input.readOnly = oldReadOnly; +}); diff --git a/app/javascript/core/theme.yml b/app/javascript/core/theme.yml index f6f653c0a9..12c23e2035 100644 --- a/app/javascript/core/theme.yml +++ b/app/javascript/core/theme.yml @@ -2,12 +2,12 @@ # theme. pack: about: - admin: admin.js + admin: admin.ts auth: auth.js common: filename: common.js stylesheet: true - embed: embed.js + embed: embed.ts error: home: inert: @@ -18,7 +18,7 @@ pack: stylesheet: true modal: public: - settings: settings.js + settings: settings.ts sign_up: share: remote_interaction_helper: remote_interaction_helper.ts diff --git a/app/javascript/flavours/glitch/actions/accounts.js b/app/javascript/flavours/glitch/actions/accounts.js index af10af9fe9..bb26035e97 100644 --- a/app/javascript/flavours/glitch/actions/accounts.js +++ b/app/javascript/flavours/glitch/actions/accounts.js @@ -66,11 +66,9 @@ export const FOLLOW_REQUESTS_EXPAND_SUCCESS = 'FOLLOW_REQUESTS_EXPAND_SUCCESS'; export const FOLLOW_REQUESTS_EXPAND_FAIL = 'FOLLOW_REQUESTS_EXPAND_FAIL'; export const FOLLOW_REQUEST_AUTHORIZE_REQUEST = 'FOLLOW_REQUEST_AUTHORIZE_REQUEST'; -export const FOLLOW_REQUEST_AUTHORIZE_SUCCESS = 'FOLLOW_REQUEST_AUTHORIZE_SUCCESS'; export const FOLLOW_REQUEST_AUTHORIZE_FAIL = 'FOLLOW_REQUEST_AUTHORIZE_FAIL'; export const FOLLOW_REQUEST_REJECT_REQUEST = 'FOLLOW_REQUEST_REJECT_REQUEST'; -export const FOLLOW_REQUEST_REJECT_SUCCESS = 'FOLLOW_REQUEST_REJECT_SUCCESS'; export const FOLLOW_REQUEST_REJECT_FAIL = 'FOLLOW_REQUEST_REJECT_FAIL'; export const PINNED_ACCOUNTS_FETCH_REQUEST = 'PINNED_ACCOUNTS_FETCH_REQUEST'; @@ -93,11 +91,6 @@ export * from './accounts_typed'; export function fetchAccount(id) { return (dispatch, getState) => { dispatch(fetchRelationships([id])); - - if (getState().getIn(['accounts', id], null) !== null) { - return; - } - dispatch(fetchAccountRequest(id)); api(getState).get(`/api/v1/accounts/${id}`).then(response => { diff --git a/app/javascript/flavours/glitch/actions/blocks.js b/app/javascript/flavours/glitch/actions/blocks.js index e293657ad3..54296d0905 100644 --- a/app/javascript/flavours/glitch/actions/blocks.js +++ b/app/javascript/flavours/glitch/actions/blocks.js @@ -12,8 +12,6 @@ export const BLOCKS_EXPAND_REQUEST = 'BLOCKS_EXPAND_REQUEST'; export const BLOCKS_EXPAND_SUCCESS = 'BLOCKS_EXPAND_SUCCESS'; export const BLOCKS_EXPAND_FAIL = 'BLOCKS_EXPAND_FAIL'; -export const BLOCKS_INIT_MODAL = 'BLOCKS_INIT_MODAL'; - export function fetchBlocks() { return (dispatch, getState) => { dispatch(fetchBlocksRequest()); @@ -90,11 +88,12 @@ export function expandBlocksFail(error) { export function initBlockModal(account) { return dispatch => { - dispatch({ - type: BLOCKS_INIT_MODAL, - account, - }); - - dispatch(openModal({ modalType: 'BLOCK' })); + dispatch(openModal({ + modalType: 'BLOCK', + modalProps: { + accountId: account.get('id'), + acct: account.get('acct'), + }, + })); }; } diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js index 06f0d79874..2646aa7b02 100644 --- a/app/javascript/flavours/glitch/actions/compose.js +++ b/app/javascript/flavours/glitch/actions/compose.js @@ -266,12 +266,14 @@ export function submitCompose(routerHistory, overridePrivacy = null) { insertIfOnline('direct'); } - dispatch(showAlert({ - message: statusId === null ? messages.published : messages.saved, - action: messages.open, - dismissAfter: 10000, - onClick: () => routerHistory.push(`/@${response.data.account.username}/${response.data.id}`), - })); + if (getState().getIn(['local_settings', 'show_published_toast'])) { + dispatch(showAlert({ + message: statusId === null ? messages.published : messages.saved, + action: messages.open, + dismissAfter: 10000, + onClick: () => routerHistory.push(`/@${response.data.account.username}/${response.data.id}`), + })); + } }).catch(function (error) { dispatch(submitComposeFail(error)); }); @@ -820,11 +822,12 @@ export function addPollOption(title) { }; } -export function changePollOption(index, title) { +export function changePollOption(index, title, maxOptions) { return { type: COMPOSE_POLL_OPTION_CHANGE, index, title, + maxOptions, }; } diff --git a/app/javascript/flavours/glitch/actions/domain_blocks.js b/app/javascript/flavours/glitch/actions/domain_blocks.js index 718002613f..55c0a6ce9d 100644 --- a/app/javascript/flavours/glitch/actions/domain_blocks.js +++ b/app/javascript/flavours/glitch/actions/domain_blocks.js @@ -1,6 +1,8 @@ import api, { getLinks } from '../api'; import { blockDomainSuccess, unblockDomainSuccess } from "./domain_blocks_typed"; +import { openModal } from './modal'; + export * from "./domain_blocks_typed"; @@ -150,3 +152,12 @@ export function expandDomainBlocksFail(error) { error, }; } + +export const initDomainBlockModal = account => dispatch => dispatch(openModal({ + modalType: 'DOMAIN_BLOCK', + modalProps: { + domain: account.get('acct').split('@')[1], + acct: account.get('acct'), + accountId: account.get('id'), + }, +})); diff --git a/app/javascript/flavours/glitch/actions/mutes.js b/app/javascript/flavours/glitch/actions/mutes.js index fb041078b8..99c113f414 100644 --- a/app/javascript/flavours/glitch/actions/mutes.js +++ b/app/javascript/flavours/glitch/actions/mutes.js @@ -12,10 +12,6 @@ export const MUTES_EXPAND_REQUEST = 'MUTES_EXPAND_REQUEST'; export const MUTES_EXPAND_SUCCESS = 'MUTES_EXPAND_SUCCESS'; export const MUTES_EXPAND_FAIL = 'MUTES_EXPAND_FAIL'; -export const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL'; -export const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS'; -export const MUTES_CHANGE_DURATION = 'MUTES_CHANGE_DURATION'; - export function fetchMutes() { return (dispatch, getState) => { dispatch(fetchMutesRequest()); @@ -92,26 +88,12 @@ export function expandMutesFail(error) { export function initMuteModal(account) { return dispatch => { - dispatch({ - type: MUTES_INIT_MODAL, - account, - }); - - dispatch(openModal({ modalType: 'MUTE' })); - }; -} - -export function toggleHideNotifications() { - return dispatch => { - dispatch({ type: MUTES_TOGGLE_HIDE_NOTIFICATIONS }); - }; -} - -export function changeMuteDuration(duration) { - return dispatch => { - dispatch({ - type: MUTES_CHANGE_DURATION, - duration, - }); + dispatch(openModal({ + modalType: 'MUTE', + modalProps: { + accountId: account.get('id'), + acct: account.get('acct'), + }, + })); }; } diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js index 4179cfa56a..5b274ff94c 100644 --- a/app/javascript/flavours/glitch/actions/notifications.js +++ b/app/javascript/flavours/glitch/actions/notifications.js @@ -57,6 +57,38 @@ export const NOTIFICATIONS_MARK_AS_READ = 'NOTIFICATIONS_MARK_AS_READ'; export const NOTIFICATIONS_SET_BROWSER_SUPPORT = 'NOTIFICATIONS_SET_BROWSER_SUPPORT'; export const NOTIFICATIONS_SET_BROWSER_PERMISSION = 'NOTIFICATIONS_SET_BROWSER_PERMISSION'; +export const NOTIFICATION_POLICY_FETCH_REQUEST = 'NOTIFICATION_POLICY_FETCH_REQUEST'; +export const NOTIFICATION_POLICY_FETCH_SUCCESS = 'NOTIFICATION_POLICY_FETCH_SUCCESS'; +export const NOTIFICATION_POLICY_FETCH_FAIL = 'NOTIFICATION_POLICY_FETCH_FAIL'; + +export const NOTIFICATION_REQUESTS_FETCH_REQUEST = 'NOTIFICATION_REQUESTS_FETCH_REQUEST'; +export const NOTIFICATION_REQUESTS_FETCH_SUCCESS = 'NOTIFICATION_REQUESTS_FETCH_SUCCESS'; +export const NOTIFICATION_REQUESTS_FETCH_FAIL = 'NOTIFICATION_REQUESTS_FETCH_FAIL'; + +export const NOTIFICATION_REQUESTS_EXPAND_REQUEST = 'NOTIFICATION_REQUESTS_EXPAND_REQUEST'; +export const NOTIFICATION_REQUESTS_EXPAND_SUCCESS = 'NOTIFICATION_REQUESTS_EXPAND_SUCCESS'; +export const NOTIFICATION_REQUESTS_EXPAND_FAIL = 'NOTIFICATION_REQUESTS_EXPAND_FAIL'; + +export const NOTIFICATION_REQUEST_FETCH_REQUEST = 'NOTIFICATION_REQUEST_FETCH_REQUEST'; +export const NOTIFICATION_REQUEST_FETCH_SUCCESS = 'NOTIFICATION_REQUEST_FETCH_SUCCESS'; +export const NOTIFICATION_REQUEST_FETCH_FAIL = 'NOTIFICATION_REQUEST_FETCH_FAIL'; + +export const NOTIFICATION_REQUEST_ACCEPT_REQUEST = 'NOTIFICATION_REQUEST_ACCEPT_REQUEST'; +export const NOTIFICATION_REQUEST_ACCEPT_SUCCESS = 'NOTIFICATION_REQUEST_ACCEPT_SUCCESS'; +export const NOTIFICATION_REQUEST_ACCEPT_FAIL = 'NOTIFICATION_REQUEST_ACCEPT_FAIL'; + +export const NOTIFICATION_REQUEST_DISMISS_REQUEST = 'NOTIFICATION_REQUEST_DISMISS_REQUEST'; +export const NOTIFICATION_REQUEST_DISMISS_SUCCESS = 'NOTIFICATION_REQUEST_DISMISS_SUCCESS'; +export const NOTIFICATION_REQUEST_DISMISS_FAIL = 'NOTIFICATION_REQUEST_DISMISS_FAIL'; + +export const NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST = 'NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST'; +export const NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS = 'NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS'; +export const NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL = 'NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL'; + +export const NOTIFICATIONS_FOR_REQUEST_EXPAND_REQUEST = 'NOTIFICATIONS_FOR_REQUEST_EXPAND_REQUEST'; +export const NOTIFICATIONS_FOR_REQUEST_EXPAND_SUCCESS = 'NOTIFICATIONS_FOR_REQUEST_EXPAND_SUCCESS'; +export const NOTIFICATIONS_FOR_REQUEST_EXPAND_FAIL = 'NOTIFICATIONS_FOR_REQUEST_EXPAND_FAIL'; + defineMessages({ mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, }); @@ -401,3 +433,270 @@ export function setBrowserPermission (value) { value, }; } + +export const fetchNotificationPolicy = () => (dispatch, getState) => { + dispatch(fetchNotificationPolicyRequest()); + + api(getState).get('/api/v1/notifications/policy').then(({ data }) => { + dispatch(fetchNotificationPolicySuccess(data)); + }).catch(err => { + dispatch(fetchNotificationPolicyFail(err)); + }); +}; + +export const fetchNotificationPolicyRequest = () => ({ + type: NOTIFICATION_POLICY_FETCH_REQUEST, +}); + +export const fetchNotificationPolicySuccess = policy => ({ + type: NOTIFICATION_POLICY_FETCH_SUCCESS, + policy, +}); + +export const fetchNotificationPolicyFail = error => ({ + type: NOTIFICATION_POLICY_FETCH_FAIL, + error, +}); + +export const updateNotificationsPolicy = params => (dispatch, getState) => { + dispatch(fetchNotificationPolicyRequest()); + + api(getState).put('/api/v1/notifications/policy', params).then(({ data }) => { + dispatch(fetchNotificationPolicySuccess(data)); + }).catch(err => { + dispatch(fetchNotificationPolicyFail(err)); + }); +}; + +export const fetchNotificationRequests = () => (dispatch, getState) => { + const params = {}; + + if (getState().getIn(['notificationRequests', 'isLoading'])) { + return; + } + + if (getState().getIn(['notificationRequests', 'items'])?.size > 0) { + params.since_id = getState().getIn(['notificationRequests', 'items', 0, 'id']); + } + + dispatch(fetchNotificationRequestsRequest()); + + api(getState).get('/api/v1/notifications/requests', { params }).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data.map(x => x.account))); + dispatch(fetchNotificationRequestsSuccess(response.data, next ? next.uri : null)); + }).catch(err => { + dispatch(fetchNotificationRequestsFail(err)); + }); +}; + +export const fetchNotificationRequestsRequest = () => ({ + type: NOTIFICATION_REQUESTS_FETCH_REQUEST, +}); + +export const fetchNotificationRequestsSuccess = (requests, next) => ({ + type: NOTIFICATION_REQUESTS_FETCH_SUCCESS, + requests, + next, +}); + +export const fetchNotificationRequestsFail = error => ({ + type: NOTIFICATION_REQUESTS_FETCH_FAIL, + error, +}); + +export const expandNotificationRequests = () => (dispatch, getState) => { + const url = getState().getIn(['notificationRequests', 'next']); + + if (!url || getState().getIn(['notificationRequests', 'isLoading'])) { + return; + } + + dispatch(expandNotificationRequestsRequest()); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data.map(x => x.account))); + dispatch(expandNotificationRequestsSuccess(response.data, next?.uri)); + }).catch(err => { + dispatch(expandNotificationRequestsFail(err)); + }); +}; + +export const expandNotificationRequestsRequest = () => ({ + type: NOTIFICATION_REQUESTS_EXPAND_REQUEST, +}); + +export const expandNotificationRequestsSuccess = (requests, next) => ({ + type: NOTIFICATION_REQUESTS_EXPAND_SUCCESS, + requests, + next, +}); + +export const expandNotificationRequestsFail = error => ({ + type: NOTIFICATION_REQUESTS_EXPAND_FAIL, + error, +}); + +export const fetchNotificationRequest = id => (dispatch, getState) => { + const current = getState().getIn(['notificationRequests', 'current']); + + if (current.getIn(['item', 'id']) === id || current.get('isLoading')) { + return; + } + + dispatch(fetchNotificationRequestRequest(id)); + + api(getState).get(`/api/v1/notifications/requests/${id}`).then(({ data }) => { + dispatch(fetchNotificationRequestSuccess(data)); + }).catch(err => { + dispatch(fetchNotificationRequestFail(id, err)); + }); +}; + +export const fetchNotificationRequestRequest = id => ({ + type: NOTIFICATION_REQUEST_FETCH_REQUEST, + id, +}); + +export const fetchNotificationRequestSuccess = request => ({ + type: NOTIFICATION_REQUEST_FETCH_SUCCESS, + request, +}); + +export const fetchNotificationRequestFail = (id, error) => ({ + type: NOTIFICATION_REQUEST_FETCH_FAIL, + id, + error, +}); + +export const acceptNotificationRequest = id => (dispatch, getState) => { + dispatch(acceptNotificationRequestRequest(id)); + + api(getState).post(`/api/v1/notifications/requests/${id}/accept`).then(() => { + dispatch(acceptNotificationRequestSuccess(id)); + }).catch(err => { + dispatch(acceptNotificationRequestFail(id, err)); + }); +}; + +export const acceptNotificationRequestRequest = id => ({ + type: NOTIFICATION_REQUEST_ACCEPT_REQUEST, + id, +}); + +export const acceptNotificationRequestSuccess = id => ({ + type: NOTIFICATION_REQUEST_ACCEPT_SUCCESS, + id, +}); + +export const acceptNotificationRequestFail = (id, error) => ({ + type: NOTIFICATION_REQUEST_ACCEPT_FAIL, + id, + error, +}); + +export const dismissNotificationRequest = id => (dispatch, getState) => { + dispatch(dismissNotificationRequestRequest(id)); + + api(getState).post(`/api/v1/notifications/requests/${id}/dismiss`).then(() =>{ + dispatch(dismissNotificationRequestSuccess(id)); + }).catch(err => { + dispatch(dismissNotificationRequestFail(id, err)); + }); +}; + +export const dismissNotificationRequestRequest = id => ({ + type: NOTIFICATION_REQUEST_DISMISS_REQUEST, + id, +}); + +export const dismissNotificationRequestSuccess = id => ({ + type: NOTIFICATION_REQUEST_DISMISS_SUCCESS, + id, +}); + +export const dismissNotificationRequestFail = (id, error) => ({ + type: NOTIFICATION_REQUEST_DISMISS_FAIL, + id, + error, +}); + +export const fetchNotificationsForRequest = accountId => (dispatch, getState) => { + const current = getState().getIn(['notificationRequests', 'current']); + const params = { account_id: accountId }; + + if (current.getIn(['item', 'account']) === accountId) { + if (current.getIn(['notifications', 'isLoading'])) { + return; + } + + if (current.getIn(['notifications', 'items'])?.size > 0) { + params.since_id = current.getIn(['notifications', 'items', 0, 'id']); + } + } + + dispatch(fetchNotificationsForRequestRequest()); + + api(getState).get('/api/v1/notifications', { params }).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data.map(item => item.account))); + dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status))); + dispatch(importFetchedAccounts(response.data.filter(item => item.report).map(item => item.report.target_account))); + + dispatch(fetchNotificationsForRequestSuccess(response.data, next?.uri)); + }).catch(err => { + dispatch(fetchNotificationsForRequestFail(err)); + }); +}; + +export const fetchNotificationsForRequestRequest = () => ({ + type: NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST, +}); + +export const fetchNotificationsForRequestSuccess = (notifications, next) => ({ + type: NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS, + notifications, + next, +}); + +export const fetchNotificationsForRequestFail = (error) => ({ + type: NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL, + error, +}); + +export const expandNotificationsForRequest = () => (dispatch, getState) => { + const url = getState().getIn(['notificationRequests', 'current', 'notifications', 'next']); + + if (!url || getState().getIn(['notificationRequests', 'current', 'notifications', 'isLoading'])) { + return; + } + + dispatch(expandNotificationsForRequestRequest()); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data.map(item => item.account))); + dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status))); + dispatch(importFetchedAccounts(response.data.filter(item => item.report).map(item => item.report.target_account))); + + dispatch(expandNotificationsForRequestSuccess(response.data, next?.uri)); + }).catch(err => { + dispatch(expandNotificationsForRequestFail(err)); + }); +}; + +export const expandNotificationsForRequestRequest = () => ({ + type: NOTIFICATIONS_FOR_REQUEST_EXPAND_REQUEST, +}); + +export const expandNotificationsForRequestSuccess = (notifications, next) => ({ + type: NOTIFICATIONS_FOR_REQUEST_EXPAND_SUCCESS, + notifications, + next, +}); + +export const expandNotificationsForRequestFail = (error) => ({ + type: NOTIFICATIONS_FOR_REQUEST_EXPAND_FAIL, + error, +}); diff --git a/app/javascript/flavours/glitch/actions/search.js b/app/javascript/flavours/glitch/actions/search.js index 7e54740d52..44344e2fb5 100644 --- a/app/javascript/flavours/glitch/actions/search.js +++ b/app/javascript/flavours/glitch/actions/search.js @@ -143,11 +143,14 @@ export const showSearch = () => ({ type: SEARCH_SHOW, }); -export const openURL = routerHistory => (dispatch, getState) => { - const value = getState().getIn(['search', 'value']); +export const openURL = (value, history, onFailure) => (dispatch, getState) => { const signedIn = !!getState().getIn(['meta', 'me']); if (!signedIn) { + if (onFailure) { + onFailure(); + } + return; } @@ -156,15 +159,21 @@ export const openURL = routerHistory => (dispatch, getState) => { api(getState).get('/api/v2/search', { params: { q: value, resolve: true } }).then(response => { if (response.data.accounts?.length > 0) { dispatch(importFetchedAccounts(response.data.accounts)); - routerHistory.push(`/@${response.data.accounts[0].acct}`); + history.push(`/@${response.data.accounts[0].acct}`); } else if (response.data.statuses?.length > 0) { dispatch(importFetchedStatuses(response.data.statuses)); - routerHistory.push(`/@${response.data.statuses[0].account.acct}/${response.data.statuses[0].id}`); + history.push(`/@${response.data.statuses[0].account.acct}/${response.data.statuses[0].id}`); + } else if (onFailure) { + onFailure(); } dispatch(fetchSearchSuccess(response.data, value)); }).catch(err => { dispatch(fetchSearchFail(err)); + + if (onFailure) { + onFailure(); + } }); }; diff --git a/app/javascript/flavours/glitch/actions/statuses.js b/app/javascript/flavours/glitch/actions/statuses.js index 5bdd31c343..332057ee67 100644 --- a/app/javascript/flavours/glitch/actions/statuses.js +++ b/app/javascript/flavours/glitch/actions/statuses.js @@ -1,7 +1,7 @@ import api from '../api'; import { ensureComposeIsVisible, setComposeToStatus } from './compose'; -import { importFetchedStatus, importFetchedStatuses } from './importer'; +import { importFetchedStatus, importFetchedStatuses, importFetchedAccount } from './importer'; import { deleteFromTimelines } from './timelines'; export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST'; @@ -138,10 +138,10 @@ export function deleteStatus(id, routerHistory, withRedraft = false) { api(getState).delete(`/api/v1/statuses/${id}`).then(response => { dispatch(deleteStatusSuccess(id)); dispatch(deleteFromTimelines(id)); + dispatch(importFetchedAccount(response.data.account)); if (withRedraft) { dispatch(redraft(status, response.data.text, response.data.content_type)); - ensureComposeIsVisible(getState, routerHistory); } }).catch(error => { diff --git a/app/javascript/flavours/glitch/components/attachment_list.jsx b/app/javascript/flavours/glitch/components/attachment_list.jsx index 44b8bf78ee..60800f41ec 100644 --- a/app/javascript/flavours/glitch/components/attachment_list.jsx +++ b/app/javascript/flavours/glitch/components/attachment_list.jsx @@ -10,7 +10,6 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import LinkIcon from '@/material-icons/400-24px/link.svg?react'; import { Icon } from 'flavours/glitch/components/icon'; - const filename = url => url.split('/').pop().split('#')[0].split('?')[0]; export default class AttachmentList extends ImmutablePureComponent { diff --git a/app/javascript/flavours/glitch/components/check_box.tsx b/app/javascript/flavours/glitch/components/check_box.tsx new file mode 100644 index 0000000000..7da8ef0ac5 --- /dev/null +++ b/app/javascript/flavours/glitch/components/check_box.tsx @@ -0,0 +1,39 @@ +import classNames from 'classnames'; + +import DoneIcon from '@/material-icons/400-24px/done.svg?react'; + +import { Icon } from './icon'; + +interface Props { + value: string; + checked: boolean; + name: string; + onChange: (event: React.ChangeEvent) => void; + label: React.ReactNode; +} + +export const CheckBox: React.FC = ({ + name, + value, + checked, + onChange, + label, +}) => { + return ( + + ); +}; diff --git a/app/javascript/flavours/glitch/components/column_header.jsx b/app/javascript/flavours/glitch/components/column_header.jsx index ec99698d0c..c2524b6dd9 100644 --- a/app/javascript/flavours/glitch/components/column_header.jsx +++ b/app/javascript/flavours/glitch/components/column_header.jsx @@ -1,7 +1,7 @@ import PropTypes from 'prop-types'; import { PureComponent, useCallback } from 'react'; -import { FormattedMessage, injectIntl, defineMessages } from 'react-intl'; +import { FormattedMessage, injectIntl, defineMessages, useIntl } from 'react-intl'; import classNames from 'classnames'; import { withRouter } from 'react-router-dom'; @@ -11,12 +11,11 @@ import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.svg?react'; import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react'; import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'; import CloseIcon from '@/material-icons/400-24px/close.svg?react'; -import TuneIcon from '@/material-icons/400-24px/tune.svg?react'; +import SettingsIcon from '@/material-icons/400-24px/settings.svg?react'; import { Icon } from 'flavours/glitch/components/icon'; import { ButtonInTabsBar, useColumnsContext } from 'flavours/glitch/features/ui/util/columns_context'; import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router'; - import { useAppHistory } from './router'; const messages = defineMessages({ @@ -24,10 +23,12 @@ const messages = defineMessages({ hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' }, moveLeft: { id: 'column_header.moveLeft_settings', defaultMessage: 'Move column to the left' }, moveRight: { id: 'column_header.moveRight_settings', defaultMessage: 'Move column to the right' }, + back: { id: 'column_back_button.label', defaultMessage: 'Back' }, }); -const BackButton = ({ pinned, show }) => { +const BackButton = ({ pinned, show, onlyIcon }) => { const history = useAppHistory(); + const intl = useIntl(); const { multiColumn } = useColumnsContext(); const handleBackClick = useCallback(() => { @@ -40,18 +41,20 @@ const BackButton = ({ pinned, show }) => { const showButton = history && !pinned && ((multiColumn && history.location?.state?.fromMastodon) || show); - if(!showButton) return null; - - return (); + if (!showButton) return null; + return ( + + ); }; BackButton.propTypes = { pinned: PropTypes.bool, show: PropTypes.bool, + onlyIcon: PropTypes.bool, }; class ColumnHeader extends PureComponent { @@ -146,27 +149,31 @@ class ColumnHeader extends PureComponent { } if (multiColumn && pinned) { - pinButton = ; + pinButton = ; moveButtons = ( -
+
); } else if (multiColumn && this.props.onPin) { - pinButton = ; + pinButton = ; } - backButton = ; + backButton = ; const collapsedContent = [ extraContent, ]; if (multiColumn) { - collapsedContent.push(pinButton); - collapsedContent.push(moveButtons); + collapsedContent.push( +
+ {pinButton} + {moveButtons} +
+ ); } if (this.context.identity.signedIn && (children || (multiColumn && this.props.onPin))) { @@ -178,7 +185,7 @@ class ColumnHeader extends PureComponent { onClick={this.handleToggleClick} > - + {collapseIssues && } @@ -191,16 +198,19 @@ class ColumnHeader extends PureComponent {

{hasTitle && ( - + <> + {showBackButton && backButton} + + + )} - {!hasTitle && backButton} + {!hasTitle && showBackButton && backButton}
- {hasTitle && backButton} {extraButton} {collapseButton}
diff --git a/app/javascript/flavours/glitch/components/dropdown_menu.jsx b/app/javascript/flavours/glitch/components/dropdown_menu.jsx index 4d9c34a762..26c828fd64 100644 --- a/app/javascript/flavours/glitch/components/dropdown_menu.jsx +++ b/app/javascript/flavours/glitch/components/dropdown_menu.jsx @@ -9,7 +9,6 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import { supportsPassiveEvents } from 'detect-passive-events'; import Overlay from 'react-overlays/Overlay'; -import CloseIcon from '@/material-icons/400-24px/close.svg?react'; import { CircularProgress } from 'flavours/glitch/components/circular_progress'; import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router'; @@ -298,7 +297,7 @@ class Dropdown extends PureComponent { }) : ( ); diff --git a/app/javascript/flavours/glitch/components/logo.jsx b/app/javascript/flavours/glitch/components/logo.tsx similarity index 72% rename from app/javascript/flavours/glitch/components/logo.jsx rename to app/javascript/flavours/glitch/components/logo.tsx index 73a94af9ed..b7f8bd6695 100644 --- a/app/javascript/flavours/glitch/components/logo.jsx +++ b/app/javascript/flavours/glitch/components/logo.tsx @@ -1,14 +1,12 @@ import logo from '@/images/logo.svg'; -export const WordmarkLogo = () => ( +export const WordmarkLogo: React.FC = () => ( Mastodon ); -export const SymbolLogo = () => ( +export const SymbolLogo: React.FC = () => ( Mastodon ); - -export default WordmarkLogo; diff --git a/app/javascript/flavours/glitch/components/regeneration_indicator.jsx b/app/javascript/flavours/glitch/components/regeneration_indicator.jsx index 78844f389d..d42a7d7c72 100644 --- a/app/javascript/flavours/glitch/components/regeneration_indicator.jsx +++ b/app/javascript/flavours/glitch/components/regeneration_indicator.jsx @@ -1,6 +1,6 @@ import { FormattedMessage } from 'react-intl'; -import illustration from 'flavours/glitch/images/elephant_ui_working.svg'; +import illustration from '@/images/elephant_ui_working.svg'; const RegenerationIndicator = () => (
diff --git a/app/javascript/flavours/glitch/components/relative_timestamp.tsx b/app/javascript/flavours/glitch/components/relative_timestamp.tsx index ac3ab0fb4d..b9e1e4f8fd 100644 --- a/app/javascript/flavours/glitch/components/relative_timestamp.tsx +++ b/app/javascript/flavours/glitch/components/relative_timestamp.tsx @@ -53,7 +53,6 @@ const messages = defineMessages({ }); const dateFormatOptions = { - hour12: false, year: 'numeric', month: 'short', day: '2-digit', @@ -103,7 +102,7 @@ const getUnitDelay = (units: string) => { }; export const timeAgoString = ( - intl: IntlShape, + intl: Pick, date: Date, now: number, year: number, diff --git a/app/javascript/flavours/glitch/components/status.jsx b/app/javascript/flavours/glitch/components/status.jsx index 42076f0890..32a34a086a 100644 --- a/app/javascript/flavours/glitch/components/status.jsx +++ b/app/javascript/flavours/glitch/components/status.jsx @@ -20,6 +20,7 @@ import Card from '../features/status/components/card'; // to use the progress bar to show download progress import Bundle from '../features/ui/components/bundle'; import { MediaGallery, Video, Audio } from '../features/ui/util/async-components'; +import { SensitiveMediaContext } from '../features/ui/util/sensitive_media_context'; import { displayMedia } from '../initial_state'; import AttachmentList from './attachment_list'; @@ -72,6 +73,8 @@ export const defaultMediaVisibility = (status, settings) => { class Status extends ImmutablePureComponent { + static contextType = SensitiveMediaContext; + static propTypes = { containerId: PropTypes.string, id: PropTypes.string, @@ -125,8 +128,7 @@ class Status extends ImmutablePureComponent { isCollapsed: false, autoCollapsed: false, isExpanded: undefined, - showMedia: undefined, - statusId: undefined, + showMedia: defaultMediaVisibility(this.props.status, this.props.settings) && !(this.context?.hideMediaByDefault), revealBehindCW: undefined, showCard: false, forceFilter: undefined, @@ -211,12 +213,6 @@ class Status extends ImmutablePureComponent { updated = true; } - if (nextProps.status && nextProps.status.get('id') !== prevState.statusId) { - update.showMedia = defaultMediaVisibility(nextProps.status, nextProps.settings); - update.statusId = nextProps.status.get('id'); - updated = true; - } - if (nextProps.settings.getIn(['media', 'reveal_behind_cw']) !== prevState.revealBehindCW) { update.revealBehindCW = nextProps.settings.getIn(['media', 'reveal_behind_cw']); if (update.revealBehindCW) { @@ -312,6 +308,18 @@ class Status extends ImmutablePureComponent { if (snapshot !== null && this.props.updateScrollBottom && this.node.offsetTop < snapshot.top) { this.props.updateScrollBottom(snapshot.height - snapshot.top); } + + // This will potentially cause a wasteful redraw, but in most cases `Status` components are used + // with a `key` directly depending on their `id`, preventing re-use of the component across + // different IDs. + // But just in case this does change, reset the state on status change. + + if (this.props.status?.get('id') !== prevProps.status?.get('id')) { + this.setState({ + showMedia: defaultMediaVisibility(this.props.status, this.props.settings) && !(this.context?.hideMediaByDefault), + forceFilter: undefined, + }); + } } componentWillUnmount() { diff --git a/app/javascript/flavours/glitch/components/status_action_bar.jsx b/app/javascript/flavours/glitch/components/status_action_bar.jsx index c10122d234..f27719a6b9 100644 --- a/app/javascript/flavours/glitch/components/status_action_bar.jsx +++ b/app/javascript/flavours/glitch/components/status_action_bar.jsx @@ -351,7 +351,7 @@ class StatusActionBar extends ImmutablePureComponent { ); diff --git a/app/javascript/flavours/glitch/containers/media_container.jsx b/app/javascript/flavours/glitch/containers/media_container.jsx index 52aac5ebe4..a7b20bc249 100644 --- a/app/javascript/flavours/glitch/containers/media_container.jsx +++ b/app/javascript/flavours/glitch/containers/media_container.jsx @@ -80,7 +80,7 @@ export default class MediaContainer extends PureComponent { return ( <> - {[].map.call(components, (component, i) => { + {Array.from(components).map((component, i) => { const componentName = component.getAttribute('data-component'); const Component = MEDIA_COMPONENTS[componentName]; const { media, card, poll, hashtag, ...props } = JSON.parse(component.getAttribute('data-props')); diff --git a/app/javascript/flavours/glitch/features/about/index.jsx b/app/javascript/flavours/glitch/features/about/index.jsx index e5544c8a74..4fde1f0473 100644 --- a/app/javascript/flavours/glitch/features/about/index.jsx +++ b/app/javascript/flavours/glitch/features/about/index.jsx @@ -10,7 +10,6 @@ import { List as ImmutableList } from 'immutable'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { connect } from 'react-redux'; - import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'; import ExpandMoreIcon from '@/material-icons/400-24px/expand_more.svg?react'; import { fetchServer, fetchExtendedDescription, fetchDomainBlocks } from 'flavours/glitch/actions/server'; @@ -171,7 +170,8 @@ class About extends PureComponent {
    {server.get('rules').map(rule => (
  1. - {rule.get('text')} +
    {rule.get('text')}
    + {rule.get('hint').length > 0 && (
    {rule.get('hint')}
    )}
  2. ))}
@@ -189,18 +189,20 @@ class About extends PureComponent { <>

-
- {domainBlocks.get('items').map(block => ( -
-
-
{block.get('domain')}
- {intl.formatMessage(severityMessages[block.get('severity')].title)} -
+ {domainBlocks.get('items').size > 0 && ( +
+ {domainBlocks.get('items').map(block => ( +
+
+
{block.get('domain')}
+ {intl.formatMessage(severityMessages[block.get('severity')].title)} +
-

{(block.get('comment') || '').length > 0 ? block.get('comment') : }

-
- ))} -
+

{(block.get('comment') || '').length > 0 ? block.get('comment') : }

+
+ ))} +
+ )} ) : (

diff --git a/app/javascript/flavours/glitch/features/account/components/domain_pill.jsx b/app/javascript/flavours/glitch/features/account/components/domain_pill.jsx new file mode 100644 index 0000000000..9cd028fa68 --- /dev/null +++ b/app/javascript/flavours/glitch/features/account/components/domain_pill.jsx @@ -0,0 +1,86 @@ +import PropTypes from 'prop-types'; +import { useState, useRef, useCallback } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; + +import Overlay from 'react-overlays/Overlay'; + + + +import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react'; +import BadgeIcon from '@/material-icons/400-24px/badge.svg?react'; +import GlobeIcon from '@/material-icons/400-24px/globe.svg?react'; +import { Icon } from 'flavours/glitch/components/icon'; + +export const DomainPill = ({ domain, username, isSelf }) => { + const [open, setOpen] = useState(false); + const [expanded, setExpanded] = useState(false); + const triggerRef = useRef(null); + + const handleClick = useCallback(() => { + setOpen(!open); + }, [open, setOpen]); + + const handleExpandClick = useCallback(() => { + setExpanded(!expanded); + }, [expanded, setExpanded]); + + return ( + <> + + + + {({ props }) => ( +
+
+
+

+
+ +
+
{isSelf ? : }
+
@{username}@{domain}
+
+ +
+
+
+ +
+
+

{isSelf ? : }

+
+
+ +
+
+ +
+
+

{isSelf ? : }

+
+
+
+ +

{isSelf ? }} /> : }} />}

+ + {expanded && ( + <> +

+

+ + )} +
+ )} +
+ + ); +}; + +DomainPill.propTypes = { + username: PropTypes.string.isRequired, + domain: PropTypes.string.isRequired, + isSelf: PropTypes.bool, +}; diff --git a/app/javascript/flavours/glitch/features/account/components/header.jsx b/app/javascript/flavours/glitch/features/account/components/header.jsx index 29e3aa32f7..b97de5aeab 100644 --- a/app/javascript/flavours/glitch/features/account/components/header.jsx +++ b/app/javascript/flavours/glitch/features/account/components/header.jsx @@ -21,8 +21,9 @@ import { Button } from 'flavours/glitch/components/button'; import { CopyIconButton } from 'flavours/glitch/components/copy_icon_button'; import { Icon } from 'flavours/glitch/components/icon'; import { IconButton } from 'flavours/glitch/components/icon_button'; +import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator'; import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container'; -import { autoPlayGif, me, domain } from 'flavours/glitch/initial_state'; +import { autoPlayGif, me, domain as localDomain } from 'flavours/glitch/initial_state'; import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'flavours/glitch/permissions'; import { preferencesLink, profileLink, accountAdminLink } from 'flavours/glitch/utils/backend_links'; import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router'; @@ -30,6 +31,8 @@ import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router'; import AccountNoteContainer from '../containers/account_note_container'; import FollowRequestNoteContainer from '../containers/follow_request_note_container'; +import { DomainPill } from './domain_pill'; + const messages = defineMessages({ unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, follow: { id: 'account.follow', defaultMessage: 'Follow' }, @@ -74,7 +77,7 @@ const messages = defineMessages({ const titleFromAccount = account => { const displayName = account.get('display_name'); - const acct = account.get('acct') === account.get('username') ? `${account.get('username')}@${domain}` : account.get('acct'); + const acct = account.get('acct') === account.get('username') ? `${account.get('username')}@${localDomain}` : account.get('acct'); const prefix = displayName.trim().length === 0 ? account.get('username') : displayName; return `${prefix} (@${acct})`; @@ -84,7 +87,6 @@ const dateFormatOptions = { month: 'short', day: 'numeric', year: 'numeric', - hour12: false, hour: '2-digit', minute: '2-digit', }; @@ -167,7 +169,7 @@ class Header extends ImmutablePureComponent { }; render () { - const { account, hidden, intl, domain } = this.props; + const { account, hidden, intl } = this.props; const { signedIn, permissions } = this.context.identity; if (!account) { @@ -207,7 +209,7 @@ class Header extends ImmutablePureComponent { if (me !== account.get('id')) { if (signedIn && !account.get('relationship')) { // Wait until the relationship is loaded - actionBtn = ''; + actionBtn = ; } else if (account.getIn(['relationship', 'requested'])) { actionBtn =
@@ -364,7 +362,9 @@ class Header extends ImmutablePureComponent {

- @{acct} {lockedIcon} + @{username}@{domain} + + {lockedIcon}

diff --git a/app/javascript/flavours/glitch/features/account_gallery/components/media_item.jsx b/app/javascript/flavours/glitch/features/account_gallery/components/media_item.jsx index ebd156a4f9..c709e58db1 100644 --- a/app/javascript/flavours/glitch/features/account_gallery/components/media_item.jsx +++ b/app/javascript/flavours/glitch/features/account_gallery/components/media_item.jsx @@ -12,7 +12,6 @@ import { Blurhash } from 'flavours/glitch/components/blurhash'; import { Icon } from 'flavours/glitch/components/icon'; import { autoPlayGif, displayMedia, useBlurhash } from 'flavours/glitch/initial_state'; - export default class MediaItem extends ImmutablePureComponent { static propTypes = { diff --git a/app/javascript/flavours/glitch/features/account_timeline/components/header.jsx b/app/javascript/flavours/glitch/features/account_timeline/components/header.jsx index eb2ddbdd80..37258d9d83 100644 --- a/app/javascript/flavours/glitch/features/account_timeline/components/header.jsx +++ b/app/javascript/flavours/glitch/features/account_timeline/components/header.jsx @@ -72,11 +72,7 @@ class Header extends ImmutablePureComponent { }; handleBlockDomain = () => { - const domain = this.props.account.get('acct').split('@')[1]; - - if (!domain) return; - - this.props.onBlockDomain(domain); + this.props.onBlockDomain(this.props.account); }; handleUnblockDomain = () => { diff --git a/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.jsx b/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.jsx index c3a3de71ed..d42bb2c251 100644 --- a/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.jsx +++ b/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.jsx @@ -15,7 +15,7 @@ import { mentionCompose, directCompose, } from '../../../actions/compose'; -import { blockDomain, unblockDomain } from '../../../actions/domain_blocks'; +import { initDomainBlockModal, unblockDomain } from '../../../actions/domain_blocks'; import { openModal } from '../../../actions/modal'; import { initMuteModal } from '../../../actions/mutes'; import { initReport } from '../../../actions/reports'; @@ -138,15 +138,8 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ } }, - onBlockDomain (domain) { - dispatch(openModal({ - modalType: 'CONFIRM', - modalProps: { - message: {domain} }} />, - confirm: intl.formatMessage(messages.blockDomainConfirm), - onConfirm: () => dispatch(blockDomain(domain)), - }, - })); + onBlockDomain (account) { + dispatch(initDomainBlockModal(account)); }, onUnblockDomain (domain) { diff --git a/app/javascript/flavours/glitch/features/audio/index.jsx b/app/javascript/flavours/glitch/features/audio/index.jsx index dc9d76222d..6b0c2f6fea 100644 --- a/app/javascript/flavours/glitch/features/audio/index.jsx +++ b/app/javascript/flavours/glitch/features/audio/index.jsx @@ -18,7 +18,6 @@ import VolumeUpIcon from '@/material-icons/400-24px/volume_up-fill.svg?react'; import { Icon } from 'flavours/glitch/components/icon'; import { formatTime, getPointerPosition, fileNameFromURL } from 'flavours/glitch/features/video'; - import { Blurhash } from '../../components/blurhash'; import { displayMedia, useBlurhash } from '../../initial_state'; diff --git a/app/javascript/flavours/glitch/features/community_timeline/components/column_settings.jsx b/app/javascript/flavours/glitch/features/community_timeline/components/column_settings.jsx index 1e93125d59..a13081e82b 100644 --- a/app/javascript/flavours/glitch/features/community_timeline/components/column_settings.jsx +++ b/app/javascript/flavours/glitch/features/community_timeline/components/column_settings.jsx @@ -26,7 +26,7 @@ class ColumnSettings extends PureComponent { const { settings, onChange, intl } = this.props; return ( -
+
} />
diff --git a/app/javascript/flavours/glitch/features/community_timeline/index.jsx b/app/javascript/flavours/glitch/features/community_timeline/index.jsx index e2b982aaf8..7be2196511 100644 --- a/app/javascript/flavours/glitch/features/community_timeline/index.jsx +++ b/app/javascript/flavours/glitch/features/community_timeline/index.jsx @@ -7,7 +7,6 @@ import { Helmet } from 'react-helmet'; import { connect } from 'react-redux'; - import PeopleIcon from '@/material-icons/400-24px/group.svg?react'; import { DismissableBanner } from 'flavours/glitch/components/dismissable_banner'; import { domain } from 'flavours/glitch/initial_state'; diff --git a/app/javascript/flavours/glitch/features/compose/components/dropdown_icon_button.jsx b/app/javascript/flavours/glitch/features/compose/components/dropdown_icon_button.jsx index 0ced5a04ad..9774d4260e 100644 --- a/app/javascript/flavours/glitch/features/compose/components/dropdown_icon_button.jsx +++ b/app/javascript/flavours/glitch/features/compose/components/dropdown_icon_button.jsx @@ -5,7 +5,7 @@ import Overlay from 'react-overlays/Overlay'; import { IconButton } from 'flavours/glitch/components/icon_button'; -import DropdownMenu from './dropdown_menu'; +import { PrivacyDropdownMenu } from './privacy_dropdown_menu'; export const DropdownIconButton = ({ value, disabled, icon, onChange, iconComponent, title, options }) => { const containerRef = useRef(null); @@ -53,7 +53,7 @@ export const DropdownIconButton = ({ value, disabled, icon, onChange, iconCompon {({ props, placement }) => (
- { - if (this.node && !this.node.contains(e.target)) { - this.props.onClose(); - e.stopPropagation(); - } - }; - - handleKeyDown = e => { - const { items } = this.props; - const value = e.currentTarget.getAttribute('data-index'); - const index = items.findIndex(item => { - return (item.value === value); - }); - let element = null; - - switch(e.key) { - case 'Escape': - this.props.onClose(); - break; - case 'Enter': - this.handleClick(e); - break; - case 'ArrowDown': - element = this.node.childNodes[index + 1] || this.node.firstChild; - break; - case 'ArrowUp': - element = this.node.childNodes[index - 1] || this.node.lastChild; - break; - case 'Tab': - if (e.shiftKey) { - element = this.node.childNodes[index - 1] || this.node.lastChild; - } else { - element = this.node.childNodes[index + 1] || this.node.firstChild; - } - break; - case 'Home': - element = this.node.firstChild; - break; - case 'End': - element = this.node.lastChild; - break; - } - - if (element) { - element.focus(); - this.props.onChange(element.getAttribute('data-index')); - e.preventDefault(); - e.stopPropagation(); - } - }; - - handleClick = e => { - const value = e.currentTarget.getAttribute('data-index'); - - e.preventDefault(); - - this.props.onClose(); - this.props.onChange(value); - }; - - componentDidMount () { - document.addEventListener('click', this.handleDocumentClick, { capture: true }); - document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); - if (this.focusedItem) this.focusedItem.focus({ preventScroll: true }); - } - - componentWillUnmount () { - document.removeEventListener('click', this.handleDocumentClick, { capture: true }); - document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions); - } - - setRef = c => { - this.node = c; - }; - - setFocusRef = c => { - this.focusedItem = c; - }; - - render () { - const { style, items, value } = this.props; - - return ( -
- {items.map(item => ( -
-
- -
- -
- {item.text} - {item.meta} -
-
- ))} -
- ); - } - -} - -export default DropdownMenu; diff --git a/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.jsx b/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.jsx index afe2981d05..c556f15366 100644 --- a/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.jsx +++ b/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.jsx @@ -332,6 +332,7 @@ class EmojiPickerDropdown extends PureComponent { state = { active: false, loading: false, + placement: 'bottom', }; setRef = (c) => { @@ -383,10 +384,14 @@ class EmojiPickerDropdown extends PureComponent { return this.target; }; + handleOverlayEnter = (state) => { + this.setState({ placement: state.placement }); + }; + render () { const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props; const title = intl.formatMessage(messages.emoji); - const { active, loading } = this.state; + const { active, loading, placement } = this.state; return (
@@ -399,7 +404,7 @@ class EmojiPickerDropdown extends PureComponent { inverted /> - + {({ props, placement })=> (
diff --git a/app/javascript/flavours/glitch/features/compose/components/language_dropdown.jsx b/app/javascript/flavours/glitch/features/compose/components/language_dropdown.jsx index db1ce9cece..8edf75203f 100644 --- a/app/javascript/flavours/glitch/features/compose/components/language_dropdown.jsx +++ b/app/javascript/flavours/glitch/features/compose/components/language_dropdown.jsx @@ -141,6 +141,7 @@ class LanguageDropdownMenu extends PureComponent { case 'Escape': onClose(); break; + case ' ': case 'Enter': this.handleClick(e); break; diff --git a/app/javascript/flavours/glitch/features/compose/components/poll_form.jsx b/app/javascript/flavours/glitch/features/compose/components/poll_form.jsx index e757b9162a..361522d7b4 100644 --- a/app/javascript/flavours/glitch/features/compose/components/poll_form.jsx +++ b/app/javascript/flavours/glitch/features/compose/components/poll_form.jsx @@ -59,10 +59,11 @@ const Option = ({ multipleChoice, index, title, autoFocus }) => { const dispatch = useDispatch(); const suggestions = useSelector(state => state.getIn(['compose', 'suggestions'])); const lang = useSelector(state => state.getIn(['compose', 'language'])); + const maxOptions = useSelector(state => state.getIn(['server', 'server', 'configuration', 'polls', 'max_options'])); const handleChange = useCallback(({ target: { value } }) => { - dispatch(changePollOption(index, value)); - }, [dispatch, index]); + dispatch(changePollOption(index, value, maxOptions)); + }, [dispatch, index, maxOptions]); const handleSuggestionsFetchRequested = useCallback(token => { dispatch(fetchComposeSuggestions(token)); diff --git a/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.jsx b/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.jsx index 8a49f71511..c99f18545b 100644 --- a/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.jsx +++ b/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.jsx @@ -5,16 +5,16 @@ import { injectIntl, defineMessages } from 'react-intl'; import classNames from 'classnames'; -import { supportsPassiveEvents } from 'detect-passive-events'; import Overlay from 'react-overlays/Overlay'; import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react'; -import InfoIcon from '@/material-icons/400-24px/info.svg?react'; import LockIcon from '@/material-icons/400-24px/lock.svg?react'; import PublicIcon from '@/material-icons/400-24px/public.svg?react'; import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react'; import { Icon } from 'flavours/glitch/components/icon'; +import { PrivacyDropdownMenu } from './privacy_dropdown_menu'; + const messages = defineMessages({ public_short: { id: 'privacy.public.short', defaultMessage: 'Public' }, public_long: { id: 'privacy.public.long', defaultMessage: 'Anyone on and off Mastodon' }, @@ -28,126 +28,6 @@ const messages = defineMessages({ unlisted_extra: { id: 'privacy.unlisted.additional', defaultMessage: 'This behaves exactly like public, except the post will not appear in live feeds or hashtags, explore, or Mastodon search, even if you are opted-in account-wide.' }, }); -const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true; - -class PrivacyDropdownMenu extends PureComponent { - - static propTypes = { - style: PropTypes.object, - items: PropTypes.array.isRequired, - value: PropTypes.string.isRequired, - onClose: PropTypes.func.isRequired, - onChange: PropTypes.func.isRequired, - }; - - handleDocumentClick = e => { - if (this.node && !this.node.contains(e.target)) { - this.props.onClose(); - e.stopPropagation(); - } - }; - - handleKeyDown = e => { - const { items } = this.props; - const value = e.currentTarget.getAttribute('data-index'); - const index = items.findIndex(item => { - return (item.value === value); - }); - let element = null; - - switch(e.key) { - case 'Escape': - this.props.onClose(); - break; - case 'Enter': - this.handleClick(e); - break; - case 'ArrowDown': - element = this.node.childNodes[index + 1] || this.node.firstChild; - break; - case 'ArrowUp': - element = this.node.childNodes[index - 1] || this.node.lastChild; - break; - case 'Tab': - if (e.shiftKey) { - element = this.node.childNodes[index - 1] || this.node.lastChild; - } else { - element = this.node.childNodes[index + 1] || this.node.firstChild; - } - break; - case 'Home': - element = this.node.firstChild; - break; - case 'End': - element = this.node.lastChild; - break; - } - - if (element) { - element.focus(); - this.props.onChange(element.getAttribute('data-index')); - e.preventDefault(); - e.stopPropagation(); - } - }; - - handleClick = e => { - const value = e.currentTarget.getAttribute('data-index'); - - e.preventDefault(); - - this.props.onClose(); - this.props.onChange(value); - }; - - componentDidMount () { - document.addEventListener('click', this.handleDocumentClick, { capture: true }); - document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); - if (this.focusedItem) this.focusedItem.focus({ preventScroll: true }); - } - - componentWillUnmount () { - document.removeEventListener('click', this.handleDocumentClick, { capture: true }); - document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions); - } - - setRef = c => { - this.node = c; - }; - - setFocusRef = c => { - this.focusedItem = c; - }; - - render () { - const { style, items, value } = this.props; - - return ( -
- {items.map(item => ( -
-
- -
- -
- {item.text} - {item.meta} -
- - {item.extra && ( -
- -
- )} -
- ))} -
- ); - } - -} - class PrivacyDropdown extends PureComponent { static propTypes = { diff --git a/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown_menu.jsx b/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown_menu.jsx new file mode 100644 index 0000000000..03a0b76d23 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown_menu.jsx @@ -0,0 +1,128 @@ +import PropTypes from 'prop-types'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import classNames from 'classnames'; + +import { supportsPassiveEvents } from 'detect-passive-events'; + +import InfoIcon from '@/material-icons/400-24px/info.svg?react'; +import { Icon } from 'flavours/glitch/components/icon'; + +const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true; + +export const PrivacyDropdownMenu = ({ style, items, value, onClose, onChange }) => { + const nodeRef = useRef(null); + const focusedItemRef = useRef(null); + const [currentValue, setCurrentValue] = useState(value); + + const handleDocumentClick = useCallback((e) => { + if (nodeRef.current && !nodeRef.current.contains(e.target)) { + onClose(); + e.stopPropagation(); + } + }, [nodeRef, onClose]); + + const handleClick = useCallback((e) => { + const value = e.currentTarget.getAttribute('data-index'); + + e.preventDefault(); + + onClose(); + onChange(value); + }, [onClose, onChange]); + + const handleKeyDown = useCallback((e) => { + const value = e.currentTarget.getAttribute('data-index'); + const index = items.findIndex(item => (item.value === value)); + + let element = null; + + switch (e.key) { + case 'Escape': + onClose(); + break; + case ' ': + case 'Enter': + handleClick(e); + break; + case 'ArrowDown': + element = nodeRef.current.childNodes[index + 1] || nodeRef.current.firstChild; + break; + case 'ArrowUp': + element = nodeRef.current.childNodes[index - 1] || nodeRef.current.lastChild; + break; + case 'Tab': + if (e.shiftKey) { + element = nodeRef.current.childNodes[index + 1] || nodeRef.current.firstChild; + } else { + element = nodeRef.current.childNodes[index - 1] || nodeRef.current.lastChild; + } + break; + case 'Home': + element = nodeRef.current.firstChild; + break; + case 'End': + element = nodeRef.current.lastChild; + break; + } + + if (element) { + element.focus(); + setCurrentValue(element.getAttribute('data-index')); + e.preventDefault(); + e.stopPropagation(); + } + }, [nodeRef, items, onClose, handleClick, setCurrentValue]); + + useEffect(() => { + document.addEventListener('click', handleDocumentClick, { capture: true }); + document.addEventListener('touchend', handleDocumentClick, listenerOptions); + focusedItemRef.current?.focus({ preventScroll: true }); + + return () => { + document.removeEventListener('click', handleDocumentClick, { capture: true }); + document.removeEventListener('touchend', handleDocumentClick, listenerOptions); + }; + }, [handleDocumentClick]); + + return ( +
    + {items.map(item => ( +
  • +
    + +
    + +
    + {item.text} + {item.meta} +
    + + {item.extra && ( +
    + +
    + )} +
  • + ))} +
+ ); +}; + +PrivacyDropdownMenu.propTypes = { + style: PropTypes.object, + items: PropTypes.array.isRequired, + value: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, +}; diff --git a/app/javascript/flavours/glitch/features/compose/components/search_results.jsx b/app/javascript/flavours/glitch/features/compose/components/search_results.jsx index b759eac90b..3df025db55 100644 --- a/app/javascript/flavours/glitch/features/compose/components/search_results.jsx +++ b/app/javascript/flavours/glitch/features/compose/components/search_results.jsx @@ -7,7 +7,6 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import FindInPageIcon from '@/material-icons/400-24px/find_in_page.svg?react'; import PeopleIcon from '@/material-icons/400-24px/group.svg?react'; -import SearchIcon from '@/material-icons/400-24px/search.svg?react'; import TagIcon from '@/material-icons/400-24px/tag.svg?react'; import { Icon } from 'flavours/glitch/components/icon'; import { LoadMore } from 'flavours/glitch/components/load_more'; @@ -76,11 +75,6 @@ class SearchResults extends ImmutablePureComponent { return (
-
- - -
- {accounts} {hashtags} {statuses} diff --git a/app/javascript/flavours/glitch/features/compose/components/upload_button.jsx b/app/javascript/flavours/glitch/features/compose/components/upload_button.jsx index caa6784cb9..ed2cbb04f2 100644 --- a/app/javascript/flavours/glitch/features/compose/components/upload_button.jsx +++ b/app/javascript/flavours/glitch/features/compose/components/upload_button.jsx @@ -7,10 +7,14 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import { connect } from 'react-redux'; import PhotoLibraryIcon from '@/material-icons/400-20px/photo_library.svg?react'; -import { IconButton } from 'flavours/glitch/components/icon_button'; +import BrushIcon from '@/material-icons/400-24px/brush.svg?react'; +import UploadFileIcon from '@/material-icons/400-24px/upload_file.svg?react'; + +import { DropdownIconButton } from './dropdown_icon_button'; const messages = defineMessages({ upload: { id: 'upload_button.label', defaultMessage: 'Add images, a video or an audio file' }, + doodle: { id: 'compose.attach.doodle', defaultMessage: 'Draw something' }, }); const makeMapStateToProps = () => { @@ -21,16 +25,12 @@ const makeMapStateToProps = () => { return mapStateToProps; }; -const iconStyle = { - height: null, - lineHeight: '27px', -}; - class UploadButton extends ImmutablePureComponent { static propTypes = { disabled: PropTypes.bool, onSelectFile: PropTypes.func.isRequired, + onDoodleOpen: PropTypes.func.isRequired, style: PropTypes.object, resetFileKey: PropTypes.number, acceptContentTypes: ImmutablePropTypes.listOf(PropTypes.string).isRequired, @@ -43,8 +43,12 @@ class UploadButton extends ImmutablePureComponent { } }; - handleClick = () => { - this.fileElement.click(); + handleSelect = (value) => { + if (value === 'upload') { + this.fileElement.click(); + } else { + this.props.onDoodleOpen(); + } }; setRef = (c) => { @@ -56,9 +60,32 @@ class UploadButton extends ImmutablePureComponent { const message = intl.formatMessage(messages.upload); + const options = [ + { + icon: 'cloud-upload', + iconComponent: UploadFileIcon, + value: 'upload', + text: intl.formatMessage(messages.upload), + }, + { + icon: 'paint-brush', + iconComponent: BrushIcon, + value: 'doodle', + text: intl.formatMessage(messages.doodle), + }, + ]; + return (
- +