diff --git a/.bundler-audit.yml b/.bundler-audit.yml new file mode 100644 index 00000000000000..f84ec80872696e --- /dev/null +++ b/.bundler-audit.yml @@ -0,0 +1,3 @@ +--- +ignore: + - CVE-2015-9284 # Mitigation following https://github.com/omniauth/omniauth/wiki/Resolving-CVE-2015-9284#mitigating-in-rails-applications diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml new file mode 100644 index 00000000000000..73fe22f3aabd3c --- /dev/null +++ b/.devcontainer/docker-compose.yml @@ -0,0 +1,90 @@ +version: '3' + +services: + app: + build: + context: . + dockerfile: Dockerfile + volumes: + - ../..:/workspaces:cached + environment: + RAILS_ENV: development + NODE_ENV: development + BIND: 0.0.0.0 + REDIS_HOST: redis + REDIS_PORT: '6379' + DB_HOST: db + DB_USER: postgres + DB_PASS: postgres + DB_PORT: '5432' + ES_ENABLED: 'true' + ES_HOST: es + ES_PORT: '9200' + LIBRE_TRANSLATE_ENDPOINT: http://libretranslate:5000 + # Overrides default command so things don't shut down after the process ends. + command: sleep infinity + ports: + - '127.0.0.1:3000:3000' + - '127.0.0.1:4000:4000' + - '127.0.0.1:80:3000' + networks: + - external_network + - internal_network + + db: + image: postgres:14-alpine + restart: unless-stopped + volumes: + - postgres-data:/var/lib/postgresql/data + environment: + POSTGRES_USER: postgres + POSTGRES_DB: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_HOST_AUTH_METHOD: trust + networks: + - internal_network + + redis: + image: redis:7-alpine + restart: unless-stopped + volumes: + - redis-data:/data + networks: + - internal_network + + es: + image: docker.elastic.co/elasticsearch/elasticsearch-oss:7.10.2 + restart: unless-stopped + environment: + ES_JAVA_OPTS: -Xms512m -Xmx512m + cluster.name: es-mastodon + discovery.type: single-node + bootstrap.memory_lock: 'true' + volumes: + - es-data:/usr/share/elasticsearch/data + networks: + - internal_network + ulimits: + memlock: + soft: -1 + hard: -1 + + libretranslate: + image: libretranslate/libretranslate:v1.3.10 + restart: unless-stopped + volumes: + - lt-data:/home/libretranslate/.local + networks: + - external_network + - internal_network + +volumes: + postgres-data: + redis-data: + es-data: + lt-data: + +networks: + external_network: + internal_network: + internal: true diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh new file mode 100755 index 00000000000000..a075cc7b3bc298 --- /dev/null +++ b/.devcontainer/post-create.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +set -e # Fail the whole script on first error + +# Fetch Ruby gem dependencies +bundle config path 'vendor/bundle' +bundle config with 'development test' +bundle install + +# Make Gemfile.lock pristine again +git checkout -- Gemfile.lock + +# Fetch Javascript dependencies +yarn --frozen-lockfile + +# [re]create, migrate, and seed the test database +RAILS_ENV=test ./bin/rails db:setup + +# [re]create, migrate, and seed the development database +RAILS_ENV=development ./bin/rails db:setup + +# Precompile assets for development +RAILS_ENV=development ./bin/rails assets:precompile + +# Precompile assets for test +RAILS_ENV=test NODE_ENV=tests ./bin/rails assets:precompile diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml new file mode 100644 index 00000000000000..ff8f0404e49b1c --- /dev/null +++ b/.github/workflows/build-image.yml @@ -0,0 +1,50 @@ +name: Build container image +on: + workflow_dispatch: + push: + branches: + - 'main' + tags: + - '*' +jobs: + build-image: + runs-on: ubuntu-latest + + concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + + steps: + - uses: actions/checkout@v3 + - uses: hadolint/hadolint-action@v3.1.0 + - uses: docker/setup-qemu-action@v2 + - uses: docker/setup-buildx-action@v2 + + - name: Log in to the Github Container registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + if: github.event_name != 'pull_request' + + - uses: docker/metadata-action@v4 + id: meta + with: + images: ghcr.io/${{ github.repository_owner }}/mastodon + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=edge,branch=main + type=sha,prefix=,format=long + + - uses: docker/build-push-action@v4 + with: + context: . + platforms: linux/amd64,linux/arm64 + provenance: false + builder: ${{ steps.buildx.outputs.name }} + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/lint-json.yml b/.github/workflows/lint-json.yml new file mode 100644 index 00000000000000..98f101ad95aa69 --- /dev/null +++ b/.github/workflows/lint-json.yml @@ -0,0 +1,43 @@ +name: JSON Linting +on: + push: + branches-ignore: + - 'dependabot/**' + 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@v3 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + cache: yarn + node-version-file: '.nvmrc' + + - name: Install all yarn packages + run: yarn --frozen-lockfile + + - name: Prettier + run: yarn prettier --check "**/*.json" diff --git a/.github/workflows/lint-md.yml b/.github/workflows/lint-md.yml new file mode 100644 index 00000000000000..6f76dd60c2fca6 --- /dev/null +++ b/.github/workflows/lint-md.yml @@ -0,0 +1,40 @@ +name: Markdown Linting +on: + push: + branches-ignore: + - 'dependabot/**' + paths: + - '.github/workflows/lint-md.yml' + - '.prettier*' + - '**/*.md' + - '!AUTHORS.md' + - 'package.json' + - 'yarn.lock' + + pull_request: + paths: + - '.github/workflows/lint-md.yml' + - '.prettier*' + - '**/*.md' + - '!AUTHORS.md' + - 'package.json' + - 'yarn.lock' + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - name: Clone repository + uses: actions/checkout@v3 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + cache: yarn + + - name: Install all yarn packages + run: yarn --frozen-lockfile + + - name: Prettier + run: yarn prettier --check "**/*.md" diff --git a/.github/workflows/lint-yml.yml b/.github/workflows/lint-yml.yml new file mode 100644 index 00000000000000..6f79babcfd45d2 --- /dev/null +++ b/.github/workflows/lint-yml.yml @@ -0,0 +1,45 @@ +name: YML Linting +on: + push: + branches-ignore: + - 'dependabot/**' + 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@v3 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + cache: yarn + node-version-file: '.nvmrc' + + - name: Install all yarn packages + run: yarn --frozen-lockfile + + - name: Prettier + run: yarn prettier --check "**/*.{yml,yaml}" diff --git a/.github/workflows/test-migrations-one-step.yml b/.github/workflows/test-migrations-one-step.yml new file mode 100644 index 00000000000000..d7e424a8c46782 --- /dev/null +++ b/.github/workflows/test-migrations-one-step.yml @@ -0,0 +1,102 @@ +name: Test one step migrations +on: + push: + branches-ignore: + - 'dependabot/**' + pull_request: + +jobs: + pre_job: + runs-on: ubuntu-latest + + outputs: + should_skip: ${{ steps.skip_check.outputs.should_skip }} + + steps: + - id: skip_check + uses: fkirc/skip-duplicate-actions@v5 + with: + paths: '["Gemfile*", ".ruby-version", "**/*.rb", ".github/workflows/test-migrations-one-step.yml", "lib/tasks/tests.rake"]' + + test: + runs-on: ubuntu-latest + needs: pre_job + if: needs.pre_job.outputs.should_skip != 'true' + + services: + postgres: + image: postgres:14-alpine + env: + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + redis: + image: redis:7-alpine + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 6379:6379 + + env: + CONTINUOUS_INTEGRATION: true + DB_HOST: localhost + DB_USER: postgres + DB_PASS: postgres + DISABLE_SIMPLECOV: true + RAILS_ENV: test + BUNDLE_CLEAN: true + BUNDLE_FROZEN: true + BUNDLE_WITHOUT: 'development production' + BUNDLE_JOBS: 3 + BUNDLE_RETRY: 3 + + steps: + - uses: actions/checkout@v3 + + - name: Install native Ruby dependencies + run: | + sudo apt-get update + sudo apt-get install -y libicu-dev libidn11-dev + + - name: Set up bundler cache + uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + + - name: Create database + run: './bin/rails db:create' + + - name: Run migrations up to v2.0.0 + run: './bin/rails db:migrate VERSION=20171010025614' + + - name: Populate database with test data + run: './bin/rails tests:migrations:populate_v2' + + - name: Run migrations up to v2.4.0 + run: './bin/rails db:migrate VERSION=20180514140000' + + - name: Populate database with test data + run: './bin/rails tests:migrations:populate_v2_4' + + - name: Run migrations up to v2.4.3 + run: './bin/rails db:migrate VERSION=20180707154237' + + - name: Populate database with test data + run: './bin/rails tests:migrations:populate_v2_4_3' + + - name: Run all remaining migrations + run: './bin/rails db:migrate' + + - name: Check migration result + run: './bin/rails tests:migrations:check_database' diff --git a/.github/workflows/test-migrations-two-step.yml b/.github/workflows/test-migrations-two-step.yml new file mode 100644 index 00000000000000..25bf5ba87f164d --- /dev/null +++ b/.github/workflows/test-migrations-two-step.yml @@ -0,0 +1,110 @@ +name: Test two step migrations +on: + push: + branches-ignore: + - 'dependabot/**' + pull_request: + +jobs: + pre_job: + runs-on: ubuntu-latest + + outputs: + should_skip: ${{ steps.skip_check.outputs.should_skip }} + + steps: + - id: skip_check + uses: fkirc/skip-duplicate-actions@v5 + with: + paths: '["Gemfile*", ".ruby-version", "**/*.rb", ".github/workflows/test-migrations-two-step.yml", "lib/tasks/tests.rake"]' + + test: + runs-on: ubuntu-latest + needs: pre_job + if: needs.pre_job.outputs.should_skip != 'true' + + services: + postgres: + image: postgres:14-alpine + env: + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + redis: + image: redis:7-alpine + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 6379:6379 + + env: + CONTINUOUS_INTEGRATION: true + DB_HOST: localhost + DB_USER: postgres + DB_PASS: postgres + DISABLE_SIMPLECOV: true + RAILS_ENV: test + BUNDLE_CLEAN: true + BUNDLE_FROZEN: true + BUNDLE_WITHOUT: 'development production' + BUNDLE_JOBS: 3 + BUNDLE_RETRY: 3 + + steps: + - uses: actions/checkout@v3 + + - name: Install native Ruby dependencies + run: | + sudo apt-get update + sudo apt-get install -y libicu-dev libidn11-dev + + - name: Set up bundler cache + uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + + - name: Create database + run: './bin/rails db:create' + + - name: Run migrations up to v2.0.0 + run: './bin/rails db:migrate VERSION=20171010025614' + + - name: Populate database with test data + run: './bin/rails tests:migrations:populate_v2' + + - name: Run pre-deployment migrations up to v2.4.0 + run: './bin/rails db:migrate VERSION=20180514140000' + env: + SKIP_POST_DEPLOYMENT_MIGRATIONS: true + + - name: Populate database with test data + run: './bin/rails tests:migrations:populate_v2_4' + + - name: Run migrations up to v2.4.3 + run: './bin/rails db:migrate VERSION=20180707154237' + env: + SKIP_POST_DEPLOYMENT_MIGRATIONS: true + + - name: Populate database with test data + run: './bin/rails tests:migrations:populate_v2_4_3' + + - name: Run all remaining pre-deployment migrations + run: './bin/rails db:migrate' + env: + SKIP_POST_DEPLOYMENT_MIGRATIONS: true + + - name: Run all post-deployment migrations + run: './bin/rails db:migrate' + + - name: Check migration result + run: './bin/rails tests:migrations:check_database' diff --git a/.haml-lint_todo.yml b/.haml-lint_todo.yml new file mode 100644 index 00000000000000..c6016839078150 --- /dev/null +++ b/.haml-lint_todo.yml @@ -0,0 +1,106 @@ +# This configuration was generated by +# `haml-lint --auto-gen-config` +# on 2023-03-15 00:55:01 -0400 using Haml-Lint version 0.45.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: 63 + RuboCop: + exclude: + - 'app/views/accounts/_og.html.haml' + - 'app/views/admin/account_warnings/_account_warning.html.haml' + - 'app/views/admin/accounts/index.html.haml' + - 'app/views/admin/accounts/show.html.haml' + - 'app/views/admin/announcements/edit.html.haml' + - 'app/views/admin/announcements/new.html.haml' + - 'app/views/admin/disputes/appeals/_appeal.html.haml' + - 'app/views/admin/domain_blocks/edit.html.haml' + - 'app/views/admin/domain_blocks/new.html.haml' + - 'app/views/admin/ip_blocks/new.html.haml' + - 'app/views/admin/reports/actions/preview.html.haml' + - 'app/views/admin/reports/index.html.haml' + - 'app/views/admin/reports/show.html.haml' + - 'app/views/admin/roles/_form.html.haml' + - 'app/views/admin/settings/about/show.html.haml' + - 'app/views/admin/settings/appearance/show.html.haml' + - 'app/views/admin/settings/registrations/show.html.haml' + - 'app/views/admin/statuses/show.html.haml' + - 'app/views/auth/registrations/new.html.haml' + - 'app/views/disputes/strikes/show.html.haml' + - 'app/views/filters/_filter_fields.html.haml' + - 'app/views/invites/_form.html.haml' + - 'app/views/layouts/application.html.haml' + - 'app/views/layouts/error.html.haml' + - 'app/views/notification_mailer/_status.html.haml' + - 'app/views/settings/applications/_fields.html.haml' + - 'app/views/settings/imports/show.html.haml' + - 'app/views/settings/preferences/appearance/show.html.haml' + - 'app/views/settings/preferences/other/show.html.haml' + - 'app/views/statuses/_detailed_status.html.haml' + - 'app/views/statuses/_poll.html.haml' + - 'app/views/statuses/show.html.haml' + - 'app/views/statuses_cleanup/show.html.haml' + - 'app/views/user_mailer/warning.html.haml' + + # Offense count: 913 + LineLength: + enabled: false + + # Offense count: 22 + UnnecessaryStringOutput: + exclude: + - 'app/views/accounts/show.html.haml' + - 'app/views/admin/custom_emojis/_custom_emoji.html.haml' + - 'app/views/admin/relays/_relay.html.haml' + - 'app/views/admin/rules/_rule.html.haml' + - 'app/views/admin/statuses/index.html.haml' + - 'app/views/auth/registrations/_sessions.html.haml' + - 'app/views/disputes/strikes/show.html.haml' + - 'app/views/notification_mailer/_status.html.haml' + - 'app/views/settings/two_factor_authentication_methods/index.html.haml' + - 'app/views/statuses/_detailed_status.html.haml' + - 'app/views/statuses/_poll.html.haml' + - 'app/views/statuses/_simple_status.html.haml' + - 'app/views/user_mailer/suspicious_sign_in.html.haml' + - 'app/views/user_mailer/webauthn_credential_added.html.haml' + - 'app/views/user_mailer/webauthn_credential_deleted.html.haml' + - 'app/views/user_mailer/welcome.html.haml' + + # Offense count: 3 + ViewLength: + exclude: + - 'app/views/admin/accounts/show.html.haml' + - 'app/views/admin/reports/show.html.haml' + - 'app/views/disputes/strikes/show.html.haml' + + # Offense count: 41 + InstanceVariables: + exclude: + - 'app/views/admin/reports/_actions.html.haml' + - 'app/views/admin/roles/_form.html.haml' + - 'app/views/admin/webhooks/_form.html.haml' + - 'app/views/auth/registrations/_sessions.html.haml' + - 'app/views/auth/registrations/_status.html.haml' + - 'app/views/auth/sessions/two_factor/_otp_authentication_form.html.haml' + - 'app/views/authorize_interactions/_post_follow_actions.html.haml' + - 'app/views/invites/_form.html.haml' + - 'app/views/relationships/_account.html.haml' + - 'app/views/shared/_og.html.haml' + - 'app/views/statuses/_status.html.haml' + + # Offense count: 6 + ConsecutiveSilentScripts: + exclude: + - 'app/views/admin/settings/shared/_links.html.haml' + - 'app/views/settings/login_activities/_login_activity.html.haml' + - 'app/views/statuses/_poll.html.haml' + + # Offense count: 3 + IdNames: + exclude: + - 'app/views/authorize_interactions/error.html.haml' + - 'app/views/oauth/authorizations/error.html.haml' + - 'app/views/shared/_error_messages.html.haml' diff --git a/.nanoignore b/.nanoignore new file mode 100644 index 00000000000000..80e93970355ce3 --- /dev/null +++ b/.nanoignore @@ -0,0 +1,19 @@ +.DS_Store +.git/ +.gitignore + +.bundle/ +.cache/ +config/deploy/* +coverage +docs/ +.env +log/*.log +neo4j/ +node_modules/ +public/assets/ +public/system/ +spec/ +tmp/ +.vagrant/ +vendor/bundle/ diff --git a/.profile b/.profile new file mode 100644 index 00000000000000..c6d57b609d3b87 --- /dev/null +++ b/.profile @@ -0,0 +1 @@ +LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/app/.apt/lib/x86_64-linux-gnu:/app/.apt/usr/lib/x86_64-linux-gnu/mesa:/app/.apt/usr/lib/x86_64-linux-gnu/pulseaudio diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz new file mode 100644 index 00000000000000..d76bc439d6afed Binary files /dev/null and b/.yarn/install-state.gz differ diff --git a/.yarnclean b/.yarnclean new file mode 100644 index 00000000000000..21eb734a6c6bb5 --- /dev/null +++ b/.yarnclean @@ -0,0 +1,49 @@ +# test directories +__tests__ +test +tests +powered-test + +# asset directories +docs +doc +website +images +# assets + +# examples +example +examples + +# code coverage directories +coverage +.nyc_output + +# build scripts +Makefile +Gulpfile.js +Gruntfile.js + +# configs +.tern-project +.gitattributes +.editorconfig +.*ignore +.eslintrc +.jshintrc +.flowconfig +.documentup.json +.yarn-metadata.json +.*.yml +*.yml + +# misc +*.gz +*.md + +# for specific ignore +!.svgo.yml +!sass-lint/**/*.yml + +# breaks lint-staged or generally anything using https://github.com/eemeli/yaml/issues/384 +!**/yaml/dist/**/doc diff --git a/Capfile b/Capfile new file mode 100644 index 00000000000000..86efa5bacf8d6b --- /dev/null +++ b/Capfile @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'capistrano/setup' +require 'capistrano/deploy' +require 'capistrano/scm/git' + +install_plugin Capistrano::SCM::Git + +require 'capistrano/rbenv' +require 'capistrano/bundler' +require 'capistrano/yarn' +require 'capistrano/rails/assets' +require 'capistrano/rails/migrations' + +Dir.glob('lib/capistrano/tasks/*.rake').each { |r| import r } diff --git a/app/controllers/activitypub/claims_controller.rb b/app/controllers/activitypub/claims_controller.rb new file mode 100644 index 00000000000000..339333e462ccc3 --- /dev/null +++ b/app/controllers/activitypub/claims_controller.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class ActivityPub::ClaimsController < ActivityPub::BaseController + include SignatureVerification + include AccountOwnedConcern + + skip_before_action :authenticate_user! + + before_action :require_account_signature! + before_action :set_claim_result + + def create + render json: @claim_result, serializer: ActivityPub::OneTimeKeySerializer + end + + private + + def set_claim_result + @claim_result = ::Keys::ClaimService.new.call(@account.id, params[:id]) + end +end diff --git a/app/controllers/api/v1/crypto/deliveries_controller.rb b/app/controllers/api/v1/crypto/deliveries_controller.rb new file mode 100644 index 00000000000000..aa9df6e03b20f2 --- /dev/null +++ b/app/controllers/api/v1/crypto/deliveries_controller.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class Api::V1::Crypto::DeliveriesController < Api::BaseController + before_action -> { doorkeeper_authorize! :crypto } + before_action :require_user! + before_action :set_current_device + + def create + devices.each do |device_params| + DeliverToDeviceService.new.call(current_account, @current_device, device_params) + end + + render_empty + end + + private + + def set_current_device + @current_device = Device.find_by!(access_token: doorkeeper_token) + end + + def resource_params + params.require(:device) + params.permit(device: [:account_id, :device_id, :type, :body, :hmac]) + end + + def devices + Array(resource_params[:device]) + end +end diff --git a/app/controllers/api/v1/crypto/encrypted_messages_controller.rb b/app/controllers/api/v1/crypto/encrypted_messages_controller.rb new file mode 100644 index 00000000000000..68cf4384f79bd0 --- /dev/null +++ b/app/controllers/api/v1/crypto/encrypted_messages_controller.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +class Api::V1::Crypto::EncryptedMessagesController < Api::BaseController + LIMIT = 80 + + before_action -> { doorkeeper_authorize! :crypto } + before_action :require_user! + before_action :set_current_device + + before_action :set_encrypted_messages, only: :index + after_action :insert_pagination_headers, only: :index + + def index + render json: @encrypted_messages, each_serializer: REST::EncryptedMessageSerializer + end + + def clear + @current_device.encrypted_messages.up_to(params[:up_to_id]).delete_all + render_empty + end + + private + + def set_current_device + @current_device = Device.find_by!(access_token: doorkeeper_token) + end + + def set_encrypted_messages + @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 + + def prev_path + 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 + end + + def records_continue? + @encrypted_messages.size == limit_param(LIMIT) + end + + def pagination_params(core_params) + params.slice(:limit).permit(:limit).merge(core_params) + end +end diff --git a/app/controllers/api/v1/crypto/keys/claims_controller.rb b/app/controllers/api/v1/crypto/keys/claims_controller.rb new file mode 100644 index 00000000000000..f9d202d67b8ed8 --- /dev/null +++ b/app/controllers/api/v1/crypto/keys/claims_controller.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class Api::V1::Crypto::Keys::ClaimsController < Api::BaseController + before_action -> { doorkeeper_authorize! :crypto } + before_action :require_user! + before_action :set_claim_results + + def create + render json: @claim_results, each_serializer: REST::Keys::ClaimResultSerializer + end + + private + + def set_claim_results + @claim_results = devices.filter_map { |device_params| ::Keys::ClaimService.new.call(current_account, device_params[:account_id], device_params[:device_id]) } + end + + def resource_params + params.permit(device: [:account_id, :device_id]) + end + + def devices + Array(resource_params[:device]) + end +end diff --git a/app/controllers/api/v1/crypto/keys/counts_controller.rb b/app/controllers/api/v1/crypto/keys/counts_controller.rb new file mode 100644 index 00000000000000..ffd7151b78291e --- /dev/null +++ b/app/controllers/api/v1/crypto/keys/counts_controller.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class Api::V1::Crypto::Keys::CountsController < Api::BaseController + before_action -> { doorkeeper_authorize! :crypto } + before_action :require_user! + before_action :set_current_device + + def show + render json: { one_time_keys: @current_device.one_time_keys.count } + end + + private + + def set_current_device + @current_device = Device.find_by!(access_token: doorkeeper_token) + end +end diff --git a/app/controllers/api/v1/crypto/keys/queries_controller.rb b/app/controllers/api/v1/crypto/keys/queries_controller.rb new file mode 100644 index 00000000000000..e6ce9f9192ac86 --- /dev/null +++ b/app/controllers/api/v1/crypto/keys/queries_controller.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class Api::V1::Crypto::Keys::QueriesController < Api::BaseController + before_action -> { doorkeeper_authorize! :crypto } + before_action :require_user! + before_action :set_accounts + before_action :set_query_results + + def create + render json: @query_results, each_serializer: REST::Keys::QueryResultSerializer + end + + private + + def set_accounts + @accounts = Account.where(id: account_ids).includes(:devices) + end + + def set_query_results + @query_results = @accounts.filter_map { |account| ::Keys::QueryService.new.call(account) } + end + + def account_ids + Array(params[:id]).map(&:to_i) + end +end diff --git a/app/controllers/api/v1/crypto/keys/uploads_controller.rb b/app/controllers/api/v1/crypto/keys/uploads_controller.rb new file mode 100644 index 00000000000000..fc4abf63b3a421 --- /dev/null +++ b/app/controllers/api/v1/crypto/keys/uploads_controller.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class Api::V1::Crypto::Keys::UploadsController < Api::BaseController + before_action -> { doorkeeper_authorize! :crypto } + before_action :require_user! + + def create + device = Device.find_or_initialize_by(access_token: doorkeeper_token) + + device.transaction do + device.account = current_account + device.update!(resource_params[:device]) + + if resource_params[:one_time_keys].present? && resource_params[:one_time_keys].is_a?(Enumerable) + resource_params[:one_time_keys].each do |one_time_key_params| + device.one_time_keys.create!(one_time_key_params) + end + end + end + + render json: device, serializer: REST::Keys::DeviceSerializer + end + + private + + def resource_params + params.permit(device: [:device_id, :name, :fingerprint_key, :identity_key], one_time_keys: [:key_id, :key, :signature]) + end +end diff --git a/app/controllers/concerns/access_token_tracking_concern.rb b/app/controllers/concerns/access_token_tracking_concern.rb new file mode 100644 index 00000000000000..cf60cfb995ba49 --- /dev/null +++ b/app/controllers/concerns/access_token_tracking_concern.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module AccessTokenTrackingConcern + extend ActiveSupport::Concern + + ACCESS_TOKEN_UPDATE_FREQUENCY = 24.hours.freeze + + included do + before_action :update_access_token_last_used + end + + private + + def update_access_token_last_used + doorkeeper_token.update_last_used(request) if access_token_needs_update? + end + + def access_token_needs_update? + doorkeeper_token.present? && (doorkeeper_token.last_used_at.nil? || doorkeeper_token.last_used_at < ACCESS_TOKEN_UPDATE_FREQUENCY.ago) + end +end diff --git a/app/controllers/concerns/admin_export_controller_concern.rb b/app/controllers/concerns/admin_export_controller_concern.rb new file mode 100644 index 00000000000000..4ac48a04b7ced9 --- /dev/null +++ b/app/controllers/concerns/admin_export_controller_concern.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module AdminExportControllerConcern + extend ActiveSupport::Concern + + private + + def send_export_file + respond_to do |format| + format.csv { send_data export_data, filename: export_filename } + end + end + + def export_data + raise 'Override in controller' + end + + def export_filename + raise 'Override in controller' + end + + def set_dummy_import! + @import = Admin::Import.new + end + + def import_params + params.require(:admin_import).permit(:data) + end +end diff --git a/app/controllers/concerns/captcha_concern.rb b/app/controllers/concerns/captcha_concern.rb new file mode 100644 index 00000000000000..538c1ffb147658 --- /dev/null +++ b/app/controllers/concerns/captcha_concern.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module CaptchaConcern + extend ActiveSupport::Concern + include Hcaptcha::Adapters::ViewMethods + + included do + helper_method :render_captcha + end + + def captcha_available? + ENV['HCAPTCHA_SECRET_KEY'].present? && ENV['HCAPTCHA_SITE_KEY'].present? + end + + def captcha_enabled? + captcha_available? && Setting.captcha_enabled + end + + def captcha_user_bypass? + false + end + + def captcha_required? + captcha_enabled? && !captcha_user_bypass? + end + + def check_captcha! + return true unless captcha_required? + + if verify_hcaptcha + true + else + if block_given? + message = flash[:hcaptcha_error] + flash.delete(:hcaptcha_error) + yield message + end + false + end + end + + def extend_csp_for_captcha! + policy = request.content_security_policy + return unless captcha_required? && policy.present? + + %w(script_src frame_src style_src connect_src).each do |directive| + values = policy.send(directive) + values << 'https://hcaptcha.com' unless values.include?('https://hcaptcha.com') || values.include?('https:') + values << 'https://*.hcaptcha.com' unless values.include?('https://*.hcaptcha.com') || values.include?('https:') + policy.send(directive, *values) + end + end + + def render_captcha + return unless captcha_required? + + hcaptcha_tags + end +end diff --git a/app/controllers/concerns/export_controller_concern.rb b/app/controllers/concerns/export_controller_concern.rb new file mode 100644 index 00000000000000..24cfc7a0124832 --- /dev/null +++ b/app/controllers/concerns/export_controller_concern.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module ExportControllerConcern + extend ActiveSupport::Concern + + included do + before_action :authenticate_user! + before_action :load_export + + skip_before_action :require_functional! + end + + private + + def load_export + @export = Export.new(current_account) + end + + def send_export_file + respond_to do |format| + format.csv { send_data export_data, filename: export_filename } + end + end + + def export_data + raise 'Override in controller' + end + + def export_filename + "#{controller_name}.csv" + end +end diff --git a/app/controllers/concerns/rate_limit_headers.rb b/app/controllers/concerns/rate_limit_headers.rb new file mode 100644 index 00000000000000..30702f00e73114 --- /dev/null +++ b/app/controllers/concerns/rate_limit_headers.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module RateLimitHeaders + extend ActiveSupport::Concern + + class_methods do + def override_rate_limit_headers(method_name, options = {}) + around_action(only: method_name, if: :current_account) do |_controller, block| + block.call + ensure + rate_limiter = RateLimiter.new(current_account, options) + rate_limit_headers = rate_limiter.to_headers + response.headers.merge!(rate_limit_headers) unless response.headers['X-RateLimit-Remaining'].present? && rate_limit_headers['X-RateLimit-Remaining'].to_i > response.headers['X-RateLimit-Remaining'].to_i + end + end + end + + included do + before_action :set_rate_limit_headers, if: :rate_limited_request? + end + + private + + def set_rate_limit_headers + apply_header_limit + apply_header_remaining + apply_header_reset + end + + def rate_limited_request? + !request.env['rack.attack.throttle_data'].nil? + end + + def apply_header_limit + response.headers['X-RateLimit-Limit'] = rate_limit_limit + end + + def rate_limit_limit + api_throttle_data[:limit].to_s + end + + def apply_header_remaining + response.headers['X-RateLimit-Remaining'] = rate_limit_remaining + end + + def rate_limit_remaining + (api_throttle_data[:limit] - api_throttle_data[:count]).to_s + end + + def apply_header_reset + response.headers['X-RateLimit-Reset'] = rate_limit_reset + end + + def rate_limit_reset + (request_time + reset_period_offset).iso8601(6) + end + + def api_throttle_data + most_limited_type, = request.env['rack.attack.throttle_data'].min_by { |_key, value| value[:limit] - value[:count] } + request.env['rack.attack.throttle_data'][most_limited_type] + end + + def request_time + @_request_time ||= Time.now.utc + end + + def reset_period_offset + api_throttle_data[:period] - (request_time.to_i % api_throttle_data[:period]) + end +end diff --git a/app/controllers/concerns/registration_spam_concern.rb b/app/controllers/concerns/registration_spam_concern.rb new file mode 100644 index 00000000000000..af434c985a0c96 --- /dev/null +++ b/app/controllers/concerns/registration_spam_concern.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module RegistrationSpamConcern + extend ActiveSupport::Concern + + def set_registration_form_time + session[:registration_form_time] = Time.now.utc + end +end diff --git a/app/controllers/concerns/two_factor_authentication_concern.rb b/app/controllers/concerns/two_factor_authentication_concern.rb new file mode 100644 index 00000000000000..b30cd354d27e3c --- /dev/null +++ b/app/controllers/concerns/two_factor_authentication_concern.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +module TwoFactorAuthenticationConcern + extend ActiveSupport::Concern + + included do + prepend_before_action :authenticate_with_two_factor, if: :two_factor_enabled?, only: [:create] + end + + def two_factor_enabled? + find_user&.two_factor_enabled? + end + + def valid_webauthn_credential?(user, webauthn_credential) + user_credential = user.webauthn_credentials.find_by!(external_id: webauthn_credential.id) + + begin + webauthn_credential.verify( + session[:webauthn_challenge], + public_key: user_credential.public_key, + sign_count: user_credential.sign_count + ) + + user_credential.update!(sign_count: webauthn_credential.sign_count) + rescue WebAuthn::Error + false + end + end + + def valid_otp_attempt?(user) + user.validate_and_consume_otp!(user_params[:otp_attempt]) || + user.invalidate_otp_backup_code!(user_params[:otp_attempt]) + rescue OpenSSL::Cipher::CipherError + false + end + + def authenticate_with_two_factor + if user_params[:email].present? + user = self.resource = find_user_from_params + prompt_for_two_factor(user) if user&.external_or_valid_password?(user_params[:password]) + elsif session[:attempt_user_id] + user = self.resource = User.find_by(id: session[:attempt_user_id]) + return if user.nil? + + if session[:attempt_user_updated_at] != user.updated_at.to_s + restart_session + elsif user.webauthn_enabled? && user_params.key?(:credential) + authenticate_with_two_factor_via_webauthn(user) + elsif user_params.key?(:otp_attempt) + authenticate_with_two_factor_via_otp(user) + end + end + end + + def authenticate_with_two_factor_via_webauthn(user) + webauthn_credential = WebAuthn::Credential.from_get(user_params[:credential]) + + if valid_webauthn_credential?(user, webauthn_credential) + on_authentication_success(user, :webauthn) + render json: { redirect_path: after_sign_in_path_for(user) }, status: 200 + else + on_authentication_failure(user, :webauthn, :invalid_credential) + render json: { error: t('webauthn_credentials.invalid_credential') }, status: 422 + end + end + + def authenticate_with_two_factor_via_otp(user) + if valid_otp_attempt?(user) + on_authentication_success(user, :otp) + else + on_authentication_failure(user, :otp, :invalid_otp_token) + flash.now[:alert] = I18n.t('users.invalid_otp_token') + prompt_for_two_factor(user) + end + end + + def prompt_for_two_factor(user) + set_attempt_session(user) + + use_pack 'auth' + + @body_classes = 'lighter' + @webauthn_enabled = user.webauthn_enabled? + @scheme_type = if user.webauthn_enabled? && user_params[:otp_attempt].blank? + 'webauthn' + else + 'totp' + end + + set_locale { render :two_factor } + end +end diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb new file mode 100644 index 00000000000000..281deb64d175a1 --- /dev/null +++ b/app/controllers/settings/preferences_controller.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class Settings::PreferencesController < Settings::BaseController + def show; end + + def update + if current_user.update(user_params) + I18n.locale = current_user.locale + redirect_to after_update_redirect_path, notice: I18n.t('generic.changes_saved_msg') + else + render :show + end + end + + private + + def after_update_redirect_path + settings_preferences_path + end + + def user_params + params.require(:user).permit(:locale, chosen_languages: [], settings_attributes: UserSettings.keys) + end +end diff --git a/app/controllers/well_known/nodeinfo_controller.rb b/app/controllers/well_known/nodeinfo_controller.rb new file mode 100644 index 00000000000000..11a699ebc82715 --- /dev/null +++ b/app/controllers/well_known/nodeinfo_controller.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module WellKnown + class NodeInfoController < ActionController::Base + include CacheConcern + + before_action { response.headers['Vary'] = 'Accept' } + + def index + expires_in 3.days, public: true + render_with_cache json: {}, serializer: NodeInfo::DiscoverySerializer, adapter: NodeInfo::Adapter, expires_in: 3.days, root: 'nodeinfo' + end + + def show + expires_in 30.minutes, public: true + render_with_cache json: {}, serializer: NodeInfo::Serializer, adapter: NodeInfo::Adapter, expires_in: 30.minutes, root: 'nodeinfo' + end + end +end diff --git a/app/helpers/mascot_helper.rb b/app/helpers/mascot_helper.rb new file mode 100644 index 00000000000000..0124c74f19b9a5 --- /dev/null +++ b/app/helpers/mascot_helper.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module MascotHelper + def mascot_url + full_asset_url(instance_presenter.mascot&.file&.url || asset_pack_path('media/images/elephant_ui_plane.svg')) + end + + private + + def instance_presenter + @instance_presenter ||= InstancePresenter.new + end +end diff --git a/app/helpers/webfinger_helper.rb b/app/helpers/webfinger_helper.rb new file mode 100644 index 00000000000000..482f4e19eabef0 --- /dev/null +++ b/app/helpers/webfinger_helper.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module WebfingerHelper + def webfinger!(uri) + Webfinger.new(uri).perform + end +end diff --git a/app/javascript/core/admin.js b/app/javascript/core/admin.js new file mode 100644 index 00000000000000..ac1b2f95fbf605 --- /dev/null +++ b/app/javascript/core/admin.js @@ -0,0 +1,227 @@ +// This file will be loaded on admin pages, regardless of theme. + +import 'packs/public-path'; +import { delegate } 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'); + } +}; + +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'; +}; + +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(); + } + } +}); + +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'); + } +}); + +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(); + } + } + } +}); + +delegate(document, '.media-spoiler-show-button', 'click', () => { + [].forEach.call(document.querySelectorAll('button.media-spoiler'), (element) => { + element.click(); + }); +}); + +delegate(document, '.media-spoiler-hide-button', 'click', () => { + [].forEach.call(document.querySelectorAll('.spoiler-button.spoiler-button--visible button'), (element) => { + element.click(); + }); +}); + +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'; + } +}; + +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'); + } + } +}; + +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_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); +}; + +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); + } + }); + + 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/auth.js b/app/javascript/core/auth.js new file mode 100644 index 00000000000000..d1d14d99e8df93 --- /dev/null +++ b/app/javascript/core/auth.js @@ -0,0 +1,3 @@ +import 'packs/public-path'; +import './settings'; +import './two_factor_authentication'; diff --git a/app/javascript/core/common.js b/app/javascript/core/common.js new file mode 100644 index 00000000000000..1cee2f6036f08c --- /dev/null +++ b/app/javascript/core/common.js @@ -0,0 +1,6 @@ +// This file will be loaded on all pages, regardless of theme. + +import 'packs/public-path'; +import 'font-awesome/css/font-awesome.css'; + +require.context('../images/', true); diff --git a/app/javascript/core/embed.js b/app/javascript/core/embed.js new file mode 100644 index 00000000000000..d1e8f6b10896c4 --- /dev/null +++ b/app/javascript/core/embed.js @@ -0,0 +1,25 @@ +// 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/mailer.js b/app/javascript/core/mailer.js new file mode 100644 index 00000000000000..a4b6d54464584a --- /dev/null +++ b/app/javascript/core/mailer.js @@ -0,0 +1,3 @@ +require('../styles/mailer.scss'); + +require.context('../icons'); diff --git a/app/javascript/core/public.js b/app/javascript/core/public.js new file mode 100644 index 00000000000000..5c7a51f447171b --- /dev/null +++ b/app/javascript/core/public.js @@ -0,0 +1,30 @@ +// This file will be loaded on public pages, regardless of theme. + +import 'packs/public-path'; +import ready from '../mastodon/ready'; + +const { delegate } = require('@rails/ujs'); +const { length } = require('stringz'); + +const getProfileAvatarAnimationHandler = (swapTo) => { + //animate avatar gifs on the profile page when moused over + return ({ target }) => { + const swapSrc = target.getAttribute(swapTo); + //only change the img source if autoplay is off and the image src is actually different + if(target.getAttribute('data-autoplay') !== 'true' && target.src !== swapSrc) { + target.src = swapSrc; + } + }; +}; + +delegate(document, 'img#profile_page_avatar', 'mouseover', getProfileAvatarAnimationHandler('data-original')); + +delegate(document, 'img#profile_page_avatar', 'mouseout', getProfileAvatarAnimationHandler('data-static')); + +delegate(document, '#account_header', 'change', ({ target }) => { + const header = document.querySelector('.card .card__img img'); + const [file] = target.files || []; + const url = file ? URL.createObjectURL(file) : header.dataset.originalSrc; + + header.src = url; +}); diff --git a/app/javascript/core/settings.js b/app/javascript/core/settings.js new file mode 100644 index 00000000000000..d578463a33a874 --- /dev/null +++ b/app/javascript/core/settings.js @@ -0,0 +1,79 @@ +// This file will be loaded on settings pages, regardless of theme. + +import 'packs/public-path'; +import escapeTextContentForBrowser from 'escape-html'; + +const { delegate } = require('@rails/ujs'); + +import emojify from '../mastodon/features/emoji/emoji'; + +delegate(document, '#account_display_name', 'input', ({ target }) => { + const name = document.querySelector('.card .display-name strong'); + if (name) { + if (target.value) { + name.innerHTML = emojify(escapeTextContentForBrowser(target.value)); + } else { + name.textContent = name.textContent = target.dataset.default; + } + } +}); + +delegate(document, '#account_avatar', 'change', ({ target }) => { + const avatar = document.querySelector('.card .avatar img'); + const [file] = target.files || []; + const url = file ? URL.createObjectURL(file) : avatar.dataset.originalSrc; + + avatar.src = url; +}); + +delegate(document, '#account_header', 'change', ({ target }) => { + const header = document.querySelector('.card .card__img img'); + const [file] = target.files || []; + const url = file ? URL.createObjectURL(file) : header.dataset.originalSrc; + + header.src = url; +}); + +delegate(document, '#account_locked', 'change', ({ target }) => { + const lock = document.querySelector('.card .display-name i'); + + if (lock) { + if (target.checked) { + delete lock.dataset.hidden; + } else { + lock.dataset.hidden = 'true'; + } + } +}); + +delegate(document, '.input-copy input', 'click', ({ target }) => { + target.focus(); + target.select(); + target.setSelectionRange(0, target.value.length); +}); + +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/theme.yml b/app/javascript/core/theme.yml new file mode 100644 index 00000000000000..b9144e43aa525f --- /dev/null +++ b/app/javascript/core/theme.yml @@ -0,0 +1,19 @@ +# These packs will be loaded on every appropriate page, regardless of +# theme. +pack: + about: + admin: admin.js + auth: auth.js + common: + filename: common.js + stylesheet: true + embed: embed.js + error: + home: + mailer: + filename: mailer.js + stylesheet: true + modal: public.js + public: public.js + settings: settings.js + share: diff --git a/app/javascript/core/two_factor_authentication.js b/app/javascript/core/two_factor_authentication.js new file mode 100644 index 00000000000000..f076cdf30a989e --- /dev/null +++ b/app/javascript/core/two_factor_authentication.js @@ -0,0 +1,119 @@ +import 'packs/public-path'; +import axios from 'axios'; +import * as WebAuthnJSON from '@github/webauthn-json'; +import ready from '../mastodon/ready'; +import 'regenerator-runtime/runtime'; + +function getCSRFToken() { + var CSRFSelector = document.querySelector('meta[name="csrf-token"]'); + if (CSRFSelector) { + return CSRFSelector.getAttribute('content'); + } else { + return null; + } +} + +function hideFlashMessages() { + Array.from(document.getElementsByClassName('flash-message')).forEach(function(flashMessage) { + flashMessage.classList.add('hidden'); + }); +} + +function callback(url, body) { + axios.post(url, JSON.stringify(body), { + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-CSRF-Token': getCSRFToken(), + }, + credentials: 'same-origin', + }).then(function(response) { + window.location.replace(response.data.redirect_path); + }).catch(function(error) { + if (error.response.status === 422) { + const errorMessage = document.getElementById('security-key-error-message'); + errorMessage.classList.remove('hidden'); + console.error(error.response.data.error); + } else { + console.error(error); + } + }); +} + +ready(() => { + if (!WebAuthnJSON.supported()) { + const unsupported_browser_message = document.getElementById('unsupported-browser-message'); + if (unsupported_browser_message) { + unsupported_browser_message.classList.remove('hidden'); + document.querySelector('.btn.js-webauthn').disabled = true; + } + } + + + const webAuthnCredentialRegistrationForm = document.getElementById('new_webauthn_credential'); + if (webAuthnCredentialRegistrationForm) { + webAuthnCredentialRegistrationForm.addEventListener('submit', (event) => { + event.preventDefault(); + + var nickname = event.target.querySelector('input[name="new_webauthn_credential[nickname]"]'); + if (nickname.value) { + axios.get('/settings/security_keys/options') + .then((response) => { + const credentialOptions = response.data; + + WebAuthnJSON.create({ 'publicKey': credentialOptions }).then((credential) => { + var params = { 'credential': credential, 'nickname': nickname.value }; + callback('/settings/security_keys', params); + }).catch((error) => { + const errorMessage = document.getElementById('security-key-error-message'); + errorMessage.classList.remove('hidden'); + console.error(error); + }); + }).catch((error) => { + console.error(error.response.data.error); + }); + } else { + nickname.focus(); + } + }); + } + + const webAuthnCredentialAuthenticationForm = document.getElementById('webauthn-form'); + if (webAuthnCredentialAuthenticationForm) { + webAuthnCredentialAuthenticationForm.addEventListener('submit', (event) => { + event.preventDefault(); + + axios.get('sessions/security_key_options') + .then((response) => { + const credentialOptions = response.data; + + WebAuthnJSON.get({ 'publicKey': credentialOptions }).then((credential) => { + var params = { 'user': { 'credential': credential } }; + callback('sign_in', params); + }).catch((error) => { + const errorMessage = document.getElementById('security-key-error-message'); + errorMessage.classList.remove('hidden'); + console.error(error); + }); + }).catch((error) => { + console.error(error.response.data.error); + }); + }); + + const otpAuthenticationForm = document.getElementById('otp-authentication-form'); + + const linkToOtp = document.getElementById('link-to-otp'); + linkToOtp.addEventListener('click', () => { + webAuthnCredentialAuthenticationForm.classList.add('hidden'); + otpAuthenticationForm.classList.remove('hidden'); + hideFlashMessages(); + }); + + const linkToWebAuthn = document.getElementById('link-to-webauthn'); + linkToWebAuthn.addEventListener('click', () => { + otpAuthenticationForm.classList.add('hidden'); + webAuthnCredentialAuthenticationForm.classList.remove('hidden'); + hideFlashMessages(); + }); + } +}); diff --git a/app/javascript/flavours/glitch/actions/account_notes.js b/app/javascript/flavours/glitch/actions/account_notes.js new file mode 100644 index 00000000000000..62a6b4cbb85cba --- /dev/null +++ b/app/javascript/flavours/glitch/actions/account_notes.js @@ -0,0 +1,69 @@ +import api from '../api'; + +export const ACCOUNT_NOTE_SUBMIT_REQUEST = 'ACCOUNT_NOTE_SUBMIT_REQUEST'; +export const ACCOUNT_NOTE_SUBMIT_SUCCESS = 'ACCOUNT_NOTE_SUBMIT_SUCCESS'; +export const ACCOUNT_NOTE_SUBMIT_FAIL = 'ACCOUNT_NOTE_SUBMIT_FAIL'; + +export const ACCOUNT_NOTE_INIT_EDIT = 'ACCOUNT_NOTE_INIT_EDIT'; +export const ACCOUNT_NOTE_CANCEL = 'ACCOUNT_NOTE_CANCEL'; + +export const ACCOUNT_NOTE_CHANGE_COMMENT = 'ACCOUNT_NOTE_CHANGE_COMMENT'; + +export function submitAccountNote() { + return (dispatch, getState) => { + dispatch(submitAccountNoteRequest()); + + const id = getState().getIn(['account_notes', 'edit', 'account_id']); + + api(getState).post(`/api/v1/accounts/${id}/note`, { + comment: getState().getIn(['account_notes', 'edit', 'comment']), + }).then(response => { + dispatch(submitAccountNoteSuccess(response.data)); + }).catch(error => dispatch(submitAccountNoteFail(error))); + }; +} + +export function submitAccountNoteRequest() { + return { + type: ACCOUNT_NOTE_SUBMIT_REQUEST, + }; +} + +export function submitAccountNoteSuccess(relationship) { + return { + type: ACCOUNT_NOTE_SUBMIT_SUCCESS, + relationship, + }; +} + +export function submitAccountNoteFail(error) { + return { + type: ACCOUNT_NOTE_SUBMIT_FAIL, + error, + }; +} + +export function initEditAccountNote(account) { + return (dispatch, getState) => { + const comment = getState().getIn(['relationships', account.get('id'), 'note']); + + dispatch({ + type: ACCOUNT_NOTE_INIT_EDIT, + account, + comment, + }); + }; +} + +export function cancelAccountNote() { + return { + type: ACCOUNT_NOTE_CANCEL, + }; +} + +export function changeAccountNoteComment(comment) { + return { + type: ACCOUNT_NOTE_CHANGE_COMMENT, + comment, + }; +} diff --git a/app/javascript/flavours/glitch/actions/app.js b/app/javascript/flavours/glitch/actions/app.js new file mode 100644 index 00000000000000..de2d93e2927a0f --- /dev/null +++ b/app/javascript/flavours/glitch/actions/app.js @@ -0,0 +1,6 @@ +export const APP_LAYOUT_CHANGE = 'APP_LAYOUT_CHANGE'; + +export const changeLayout = layout => ({ + type: APP_LAYOUT_CHANGE, + layout, +}); diff --git a/app/javascript/flavours/glitch/actions/boosts.js b/app/javascript/flavours/glitch/actions/boosts.js new file mode 100644 index 00000000000000..c0f0f3acc512f8 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/boosts.js @@ -0,0 +1,29 @@ +import { openModal } from './modal'; + +export const BOOSTS_INIT_MODAL = 'BOOSTS_INIT_MODAL'; +export const BOOSTS_CHANGE_PRIVACY = 'BOOSTS_CHANGE_PRIVACY'; + +export function initBoostModal(props) { + return (dispatch, getState) => { + const default_privacy = getState().getIn(['compose', 'default_privacy']); + + const privacy = props.status.get('visibility') === 'private' ? 'private' : default_privacy; + + dispatch({ + type: BOOSTS_INIT_MODAL, + privacy, + }); + + dispatch(openModal('BOOST', props)); + }; +} + + +export function changeBoostPrivacy(privacy) { + return dispatch => { + dispatch({ + type: BOOSTS_CHANGE_PRIVACY, + privacy, + }); + }; +} diff --git a/app/javascript/flavours/glitch/actions/directory.js b/app/javascript/flavours/glitch/actions/directory.js new file mode 100644 index 00000000000000..4b2b6dd56dce58 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/directory.js @@ -0,0 +1,61 @@ +import api from '../api'; +import { importFetchedAccounts } from './importer'; +import { fetchRelationships } from './accounts'; + +export const DIRECTORY_FETCH_REQUEST = 'DIRECTORY_FETCH_REQUEST'; +export const DIRECTORY_FETCH_SUCCESS = 'DIRECTORY_FETCH_SUCCESS'; +export const DIRECTORY_FETCH_FAIL = 'DIRECTORY_FETCH_FAIL'; + +export const DIRECTORY_EXPAND_REQUEST = 'DIRECTORY_EXPAND_REQUEST'; +export const DIRECTORY_EXPAND_SUCCESS = 'DIRECTORY_EXPAND_SUCCESS'; +export const DIRECTORY_EXPAND_FAIL = 'DIRECTORY_EXPAND_FAIL'; + +export const fetchDirectory = params => (dispatch, getState) => { + dispatch(fetchDirectoryRequest()); + + api(getState).get('/api/v1/directory', { params: { ...params, limit: 20 } }).then(({ data }) => { + dispatch(importFetchedAccounts(data)); + dispatch(fetchDirectorySuccess(data)); + dispatch(fetchRelationships(data.map(x => x.id))); + }).catch(error => dispatch(fetchDirectoryFail(error))); +}; + +export const fetchDirectoryRequest = () => ({ + type: DIRECTORY_FETCH_REQUEST, +}); + +export const fetchDirectorySuccess = accounts => ({ + type: DIRECTORY_FETCH_SUCCESS, + accounts, +}); + +export const fetchDirectoryFail = error => ({ + type: DIRECTORY_FETCH_FAIL, + error, +}); + +export const expandDirectory = params => (dispatch, getState) => { + dispatch(expandDirectoryRequest()); + + const loadedItems = getState().getIn(['user_lists', 'directory', 'items']).size; + + api(getState).get('/api/v1/directory', { params: { ...params, offset: loadedItems, limit: 20 } }).then(({ data }) => { + dispatch(importFetchedAccounts(data)); + dispatch(expandDirectorySuccess(data)); + dispatch(fetchRelationships(data.map(x => x.id))); + }).catch(error => dispatch(expandDirectoryFail(error))); +}; + +export const expandDirectoryRequest = () => ({ + type: DIRECTORY_EXPAND_REQUEST, +}); + +export const expandDirectorySuccess = accounts => ({ + type: DIRECTORY_EXPAND_SUCCESS, + accounts, +}); + +export const expandDirectoryFail = error => ({ + type: DIRECTORY_EXPAND_FAIL, + error, +}); diff --git a/app/javascript/flavours/glitch/actions/dropdown_menu.js b/app/javascript/flavours/glitch/actions/dropdown_menu.js new file mode 100644 index 00000000000000..023151d4bfc55a --- /dev/null +++ b/app/javascript/flavours/glitch/actions/dropdown_menu.js @@ -0,0 +1,10 @@ +export const DROPDOWN_MENU_OPEN = 'DROPDOWN_MENU_OPEN'; +export const DROPDOWN_MENU_CLOSE = 'DROPDOWN_MENU_CLOSE'; + +export function openDropdownMenu(id, keyboard, scroll_key) { + return { type: DROPDOWN_MENU_OPEN, id, keyboard, scroll_key }; +} + +export function closeDropdownMenu(id) { + return { type: DROPDOWN_MENU_CLOSE, id }; +} diff --git a/app/javascript/flavours/glitch/actions/identity_proofs.js b/app/javascript/flavours/glitch/actions/identity_proofs.js new file mode 100644 index 00000000000000..10398395660345 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/identity_proofs.js @@ -0,0 +1,31 @@ +import api from '../api'; + +export const IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST = 'IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST'; +export const IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS = 'IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS'; +export const IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL = 'IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL'; + +export const fetchAccountIdentityProofs = accountId => (dispatch, getState) => { + dispatch(fetchAccountIdentityProofsRequest(accountId)); + + api(getState).get(`/api/v1/accounts/${accountId}/identity_proofs`) + .then(({ data }) => dispatch(fetchAccountIdentityProofsSuccess(accountId, data))) + .catch(err => dispatch(fetchAccountIdentityProofsFail(accountId, err))); +}; + +export const fetchAccountIdentityProofsRequest = id => ({ + type: IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST, + id, +}); + +export const fetchAccountIdentityProofsSuccess = (accountId, identity_proofs) => ({ + type: IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS, + accountId, + identity_proofs, +}); + +export const fetchAccountIdentityProofsFail = (accountId, err) => ({ + type: IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL, + accountId, + err, + skipNotFound: true, +}); diff --git a/app/javascript/flavours/glitch/actions/languages.js b/app/javascript/flavours/glitch/actions/languages.js new file mode 100644 index 00000000000000..ad186ba0ccd3e5 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/languages.js @@ -0,0 +1,12 @@ +import { saveSettings } from './settings'; + +export const LANGUAGE_USE = 'LANGUAGE_USE'; + +export const useLanguage = language => dispatch => { + dispatch({ + type: LANGUAGE_USE, + language, + }); + + dispatch(saveSettings()); +}; diff --git a/app/javascript/flavours/glitch/actions/markers.js b/app/javascript/flavours/glitch/actions/markers.js new file mode 100644 index 00000000000000..f82675342648b1 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/markers.js @@ -0,0 +1,150 @@ +import api from '../api'; +import { debounce } from 'lodash'; +import compareId from '../compare_id'; +import { List as ImmutableList } from 'immutable'; + +export const MARKERS_FETCH_REQUEST = 'MARKERS_FETCH_REQUEST'; +export const MARKERS_FETCH_SUCCESS = 'MARKERS_FETCH_SUCCESS'; +export const MARKERS_FETCH_FAIL = 'MARKERS_FETCH_FAIL'; +export const MARKERS_SUBMIT_SUCCESS = 'MARKERS_SUBMIT_SUCCESS'; + +export const synchronouslySubmitMarkers = () => (dispatch, getState) => { + const accessToken = getState().getIn(['meta', 'access_token'], ''); + const params = _buildParams(getState()); + + if (Object.keys(params).length === 0 || accessToken === '') { + return; + } + + // The Fetch API allows us to perform requests that will be carried out + // after the page closes. But that only works if the `keepalive` attribute + // is supported. + if (window.fetch && 'keepalive' in new Request('')) { + fetch('/api/v1/markers', { + keepalive: true, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${accessToken}`, + }, + body: JSON.stringify(params), + }); + + return; + } else if (navigator && navigator.sendBeacon) { + // Failing that, we can use sendBeacon, but we have to encode the data as + // FormData for DoorKeeper to recognize the token. + const formData = new FormData(); + + formData.append('bearer_token', accessToken); + + for (const [id, value] of Object.entries(params)) { + formData.append(`${id}[last_read_id]`, value.last_read_id); + } + + if (navigator.sendBeacon('/api/v1/markers', formData)) { + return; + } + } + + // If neither Fetch nor sendBeacon worked, try to perform a synchronous + // request. + try { + const client = new XMLHttpRequest(); + + client.open('POST', '/api/v1/markers', false); + client.setRequestHeader('Content-Type', 'application/json'); + client.setRequestHeader('Authorization', `Bearer ${accessToken}`); + client.send(JSON.stringify(params)); + } catch (e) { + // Do not make the BeforeUnload handler error out + } +}; + +const _buildParams = (state) => { + const params = {}; + + const lastHomeId = state.getIn(['timelines', 'home', 'items'], ImmutableList()).find(item => item !== null); + const lastNotificationId = state.getIn(['notifications', 'lastReadId']); + + if (lastHomeId && compareId(lastHomeId, state.getIn(['markers', 'home'])) > 0) { + params.home = { + last_read_id: lastHomeId, + }; + } + + if (lastNotificationId && lastNotificationId !== '0' && compareId(lastNotificationId, state.getIn(['markers', 'notifications'])) > 0) { + params.notifications = { + last_read_id: lastNotificationId, + }; + } + + return params; +}; + +const debouncedSubmitMarkers = debounce((dispatch, getState) => { + const accessToken = getState().getIn(['meta', 'access_token'], ''); + const params = _buildParams(getState()); + + if (Object.keys(params).length === 0 || accessToken === '') { + return; + } + + api(getState).post('/api/v1/markers', params).then(() => { + dispatch(submitMarkersSuccess(params)); + }).catch(() => {}); +}, 300000, { leading: true, trailing: true }); + +export function submitMarkersSuccess({ home, notifications }) { + return { + type: MARKERS_SUBMIT_SUCCESS, + home: (home || {}).last_read_id, + notifications: (notifications || {}).last_read_id, + }; +} + +export function submitMarkers(params = {}) { + const result = (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState); + + if (params.immediate === true) { + debouncedSubmitMarkers.flush(); + } + + return result; +} + +export const fetchMarkers = () => (dispatch, getState) => { + const params = { timeline: ['notifications'] }; + + dispatch(fetchMarkersRequest()); + + api(getState).get('/api/v1/markers', { params }).then(response => { + dispatch(fetchMarkersSuccess(response.data)); + }).catch(error => { + dispatch(fetchMarkersFail(error)); + }); +}; + +export function fetchMarkersRequest() { + return { + type: MARKERS_FETCH_REQUEST, + skipLoading: true, + }; +} + +export function fetchMarkersSuccess(markers) { + return { + type: MARKERS_FETCH_SUCCESS, + markers, + skipLoading: true, + }; +} + +export function fetchMarkersFail(error) { + return { + type: MARKERS_FETCH_FAIL, + error, + skipLoading: true, + skipAlert: true, + }; +} diff --git a/app/javascript/flavours/glitch/actions/modal.js b/app/javascript/flavours/glitch/actions/modal.js new file mode 100644 index 00000000000000..ef2ae0e4c7fe6c --- /dev/null +++ b/app/javascript/flavours/glitch/actions/modal.js @@ -0,0 +1,18 @@ +export const MODAL_OPEN = 'MODAL_OPEN'; +export const MODAL_CLOSE = 'MODAL_CLOSE'; + +export function openModal(type, props) { + return { + type: MODAL_OPEN, + modalType: type, + modalProps: props, + }; +} + +export function closeModal(type, options = { ignoreFocus: false }) { + return { + type: MODAL_CLOSE, + modalType: type, + ignoreFocus: options.ignoreFocus, + }; +} diff --git a/app/javascript/flavours/glitch/actions/picture_in_picture.js b/app/javascript/flavours/glitch/actions/picture_in_picture.js new file mode 100644 index 00000000000000..33d8d57d475d05 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/picture_in_picture.js @@ -0,0 +1,45 @@ +// @ts-check + +export const PICTURE_IN_PICTURE_DEPLOY = 'PICTURE_IN_PICTURE_DEPLOY'; +export const PICTURE_IN_PICTURE_REMOVE = 'PICTURE_IN_PICTURE_REMOVE'; + +/** + * @typedef MediaProps + * @property {string} src + * @property {boolean} muted + * @property {number} volume + * @property {number} currentTime + * @property {string} poster + * @property {string} backgroundColor + * @property {string} foregroundColor + * @property {string} accentColor + */ + +/** + * @param {string} statusId + * @param {string} accountId + * @param {string} playerType + * @param {MediaProps} props + * @return {object} + */ +export const deployPictureInPicture = (statusId, accountId, playerType, props) => { + return (dispatch, getState) => { + // Do not open a player for a toot that does not exist + if (getState().hasIn(['statuses', statusId])) { + dispatch({ + type: PICTURE_IN_PICTURE_DEPLOY, + statusId, + accountId, + playerType, + props, + }); + } + }; +}; + +/* + * @return {object} + */ +export const removePictureInPicture = () => ({ + type: PICTURE_IN_PICTURE_REMOVE, +}); diff --git a/app/javascript/flavours/glitch/api.js b/app/javascript/flavours/glitch/api.js new file mode 100644 index 00000000000000..6bbddbef66257a --- /dev/null +++ b/app/javascript/flavours/glitch/api.js @@ -0,0 +1,75 @@ +// @ts-check + +import axios from 'axios'; +import LinkHeader from 'http-link-header'; +import ready from './ready'; + +/** + * @param {import('axios').AxiosResponse} response + * @returns {LinkHeader} + */ +export const getLinks = response => { + const value = response.headers.link; + + if (!value) { + return new LinkHeader(); + } + + return LinkHeader.parse(value); +}; + +/** @type {import('axios').RawAxiosRequestHeaders} */ +const csrfHeader = {}; + +/** + * @returns {void} + */ +const setCSRFHeader = () => { + /** @type {HTMLMetaElement | null} */ + const csrfToken = document.querySelector('meta[name=csrf-token]'); + + if (csrfToken) { + csrfHeader['X-CSRF-Token'] = csrfToken.content; + } +}; + +ready(setCSRFHeader); + +/** + * @param {() => import('immutable').Map} getState + * @returns {import('axios').RawAxiosRequestHeaders} + */ +const authorizationHeaderFromState = getState => { + const accessToken = getState && getState().getIn(['meta', 'access_token'], ''); + + if (!accessToken) { + return {}; + } + + return { + 'Authorization': `Bearer ${accessToken}`, + }; +}; + +/** + * @param {() => import('immutable').Map} getState + * @returns {import('axios').AxiosInstance} + */ +export default function api(getState) { + return axios.create({ + headers: { + ...csrfHeader, + ...authorizationHeaderFromState(getState), + }, + + transformResponse: [ + function (data) { + try { + return JSON.parse(data); + } catch { + return data; + } + }, + ], + }); +} diff --git a/app/javascript/flavours/glitch/base_polyfills.js b/app/javascript/flavours/glitch/base_polyfills.js new file mode 100644 index 00000000000000..91bc5d6dc8bf4d --- /dev/null +++ b/app/javascript/flavours/glitch/base_polyfills.js @@ -0,0 +1,37 @@ +import 'intl'; +import 'intl/locale-data/jsonp/en'; +import 'es6-symbol/implement'; +import assign from 'object-assign'; +import values from 'object.values'; +import { decode as decodeBase64 } from './utils/base64'; +import promiseFinally from 'promise.prototype.finally'; + +if (!Object.assign) { + Object.assign = assign; +} + +if (!Object.values) { + values.shim(); +} + +promiseFinally.shim(); + +if (!HTMLCanvasElement.prototype.toBlob) { + const BASE64_MARKER = ';base64,'; + + Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', { + value(callback, type = 'image/png', quality) { + const dataURL = this.toDataURL(type, quality); + let data; + + if (dataURL.indexOf(BASE64_MARKER) >= 0) { + const [, base64] = dataURL.split(BASE64_MARKER); + data = decodeBase64(base64); + } else { + [, data] = dataURL.split(','); + } + + callback(new Blob([data], { type })); + }, + }); +} diff --git a/app/javascript/flavours/glitch/blurhash.js b/app/javascript/flavours/glitch/blurhash.js new file mode 100644 index 00000000000000..5adcc3e77021e1 --- /dev/null +++ b/app/javascript/flavours/glitch/blurhash.js @@ -0,0 +1,112 @@ +const DIGIT_CHARACTERS = [ + '0', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + 'A', + 'B', + 'C', + 'D', + 'E', + 'F', + 'G', + 'H', + 'I', + 'J', + 'K', + 'L', + 'M', + 'N', + 'O', + 'P', + 'Q', + 'R', + 'S', + 'T', + 'U', + 'V', + 'W', + 'X', + 'Y', + 'Z', + 'a', + 'b', + 'c', + 'd', + 'e', + 'f', + 'g', + 'h', + 'i', + 'j', + 'k', + 'l', + 'm', + 'n', + 'o', + 'p', + 'q', + 'r', + 's', + 't', + 'u', + 'v', + 'w', + 'x', + 'y', + 'z', + '#', + '$', + '%', + '*', + '+', + ',', + '-', + '.', + ':', + ';', + '=', + '?', + '@', + '[', + ']', + '^', + '_', + '{', + '|', + '}', + '~', +]; + +export const decode83 = (str) => { + let value = 0; + let c, digit; + + for (let i = 0; i < str.length; i++) { + c = str[i]; + digit = DIGIT_CHARACTERS.indexOf(c); + value = value * 83 + digit; + } + + return value; +}; + +export const intToRGB = int => ({ + r: Math.max(0, (int >> 16)), + g: Math.max(0, (int >> 8) & 255), + b: Math.max(0, (int & 255)), +}); + +export const getAverageFromBlurhash = blurhash => { + if (!blurhash) { + return null; + } + + return intToRGB(decode83(blurhash.slice(2, 6))); +}; diff --git a/app/javascript/flavours/glitch/compare_id.js b/app/javascript/flavours/glitch/compare_id.js new file mode 100644 index 00000000000000..d2bd74f447ab3c --- /dev/null +++ b/app/javascript/flavours/glitch/compare_id.js @@ -0,0 +1,11 @@ +export default function compareId (id1, id2) { + if (id1 === id2) { + return 0; + } + + if (id1.length === id2.length) { + return id1 > id2 ? 1 : -1; + } else { + return id1.length > id2.length ? 1 : -1; + } +} diff --git a/app/javascript/flavours/glitch/components/autosuggest_hashtag.jsx b/app/javascript/flavours/glitch/components/autosuggest_hashtag.jsx new file mode 100644 index 00000000000000..d787ed07ad3403 --- /dev/null +++ b/app/javascript/flavours/glitch/components/autosuggest_hashtag.jsx @@ -0,0 +1,42 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ShortNumber from 'flavours/glitch/components/short_number'; +import { FormattedMessage } from 'react-intl'; + +export default class AutosuggestHashtag extends React.PureComponent { + + static propTypes = { + tag: PropTypes.shape({ + name: PropTypes.string.isRequired, + url: PropTypes.string, + history: PropTypes.array, + }).isRequired, + }; + + render() { + const { tag } = this.props; + const weeklyUses = tag.history && ( + total + day.uses * 1, 0)} + /> + ); + + return ( +
+
+ #{tag.name} +
+ {tag.history !== undefined && ( +
+ +
+ )} +
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/components/avatar.jsx b/app/javascript/flavours/glitch/components/avatar.jsx new file mode 100644 index 00000000000000..f30b33e700ec48 --- /dev/null +++ b/app/javascript/flavours/glitch/components/avatar.jsx @@ -0,0 +1,79 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { autoPlayGif } from 'flavours/glitch/initial_state'; +import classNames from 'classnames'; + +export default class Avatar extends React.PureComponent { + + static propTypes = { + account: ImmutablePropTypes.map, + className: PropTypes.string, + size: PropTypes.number.isRequired, + style: PropTypes.object, + inline: PropTypes.bool, + animate: PropTypes.bool, + }; + + static defaultProps = { + animate: autoPlayGif, + size: 20, + inline: false, + }; + + state = { + hovering: false, + }; + + handleMouseEnter = () => { + if (this.props.animate) return; + this.setState({ hovering: true }); + }; + + handleMouseLeave = () => { + if (this.props.animate) return; + this.setState({ hovering: false }); + }; + + render () { + const { + account, + animate, + className, + inline, + size, + } = this.props; + const { hovering } = this.state; + + const style = { + ...this.props.style, + width: `${size}px`, + height: `${size}px`, + backgroundSize: `${size}px ${size}px`, + }; + + if (account) { + const src = account.get('avatar'); + const staticSrc = account.get('avatar_static'); + + if (hovering || animate) { + style.backgroundImage = `url(${src})`; + } else { + style.backgroundImage = `url(${staticSrc})`; + } + } + + return ( +
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/components/avatar_overlay.jsx b/app/javascript/flavours/glitch/components/avatar_overlay.jsx new file mode 100644 index 00000000000000..01dec587a5db5a --- /dev/null +++ b/app/javascript/flavours/glitch/components/avatar_overlay.jsx @@ -0,0 +1,37 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { autoPlayGif } from 'flavours/glitch/initial_state'; + +export default class AvatarOverlay extends React.PureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + friend: ImmutablePropTypes.map.isRequired, + animate: PropTypes.bool, + }; + + static defaultProps = { + animate: autoPlayGif, + }; + + render() { + const { account, friend, animate } = this.props; + + const baseStyle = { + backgroundImage: `url(${account.get(animate ? 'avatar' : 'avatar_static')})`, + }; + + const overlayStyle = { + backgroundImage: `url(${friend.get(animate ? 'avatar' : 'avatar_static')})`, + }; + + return ( +
+
+
+
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/components/blurhash.jsx b/app/javascript/flavours/glitch/components/blurhash.jsx new file mode 100644 index 00000000000000..2af5cfc5685f81 --- /dev/null +++ b/app/javascript/flavours/glitch/components/blurhash.jsx @@ -0,0 +1,65 @@ +// @ts-check + +import { decode } from 'blurhash'; +import React, { useRef, useEffect } from 'react'; +import PropTypes from 'prop-types'; + +/** + * @typedef BlurhashPropsBase + * @property {string?} hash Hash to render + * @property {number} width + * Width of the blurred region in pixels. Defaults to 32 + * @property {number} [height] + * Height of the blurred region in pixels. Defaults to width + * @property {boolean} [dummy] + * Whether dummy mode is enabled. If enabled, nothing is rendered + * and canvas left untouched + */ + +/** @typedef {JSX.IntrinsicElements['canvas'] & BlurhashPropsBase} BlurhashProps */ + +/** + * Component that is used to render blurred of blurhash string + * + * @param {BlurhashProps} param1 Props of the component + * @returns Canvas which will render blurred region element to embed + */ +function Blurhash({ + hash, + width = 32, + height = width, + dummy = false, + ...canvasProps +}) { + const canvasRef = /** @type {import('react').MutableRefObject} */ (useRef()); + + useEffect(() => { + const { current: canvas } = canvasRef; + canvas.width = canvas.width; // resets canvas + + if (dummy || !hash) return; + + try { + const pixels = decode(hash, width, height); + const ctx = canvas.getContext('2d'); + const imageData = new ImageData(pixels, width, height); + + ctx.putImageData(imageData, 0, 0); + } catch (err) { + console.error('Blurhash decoding failure', { err, hash }); + } + }, [dummy, hash, width, height]); + + return ( + + ); +} + +Blurhash.propTypes = { + hash: PropTypes.string.isRequired, + width: PropTypes.number, + height: PropTypes.number, + dummy: PropTypes.bool, +}; + +export default React.memo(Blurhash); diff --git a/app/javascript/flavours/glitch/components/button.jsx b/app/javascript/flavours/glitch/components/button.jsx new file mode 100644 index 00000000000000..40b8f5a1597b5f --- /dev/null +++ b/app/javascript/flavours/glitch/components/button.jsx @@ -0,0 +1,52 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +export default class Button extends React.PureComponent { + + static propTypes = { + text: PropTypes.node, + onClick: PropTypes.func, + disabled: PropTypes.bool, + block: PropTypes.bool, + secondary: PropTypes.bool, + className: PropTypes.string, + title: PropTypes.string, + children: PropTypes.node, + }; + + handleClick = (e) => { + if (!this.props.disabled) { + this.props.onClick(e); + } + }; + + setRef = (c) => { + this.node = c; + }; + + focus() { + this.node.focus(); + } + + render () { + let attrs = { + className: classNames('button', this.props.className, { + 'button-secondary': this.props.secondary, + 'button--block': this.props.block, + }), + disabled: this.props.disabled, + onClick: this.handleClick, + ref: this.setRef, + }; + + if (this.props.title) attrs.title = this.props.title; + + return ( + + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/check.jsx b/app/javascript/flavours/glitch/components/check.jsx new file mode 100644 index 00000000000000..ee2ef1595a2d03 --- /dev/null +++ b/app/javascript/flavours/glitch/components/check.jsx @@ -0,0 +1,9 @@ +import React from 'react'; + +const Check = () => ( + + + +); + +export default Check; diff --git a/app/javascript/flavours/glitch/components/column_back_button.jsx b/app/javascript/flavours/glitch/components/column_back_button.jsx new file mode 100644 index 00000000000000..e9e2615cbb1c02 --- /dev/null +++ b/app/javascript/flavours/glitch/components/column_back_button.jsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import PropTypes from 'prop-types'; +import Icon from 'flavours/glitch/components/icon'; +import { createPortal } from 'react-dom'; + +export default class ColumnBackButton extends React.PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + multiColumn: PropTypes.bool, + }; + + handleClick = (event) => { + // if history is exhausted, or we would leave mastodon, just go to root. + if (window.history.state) { + const state = this.context.router.history.location.state; + if (event.shiftKey && state && state.mastodonBackSteps) { + this.context.router.history.go(-state.mastodonBackSteps); + } else { + this.context.router.history.goBack(); + } + } else { + this.context.router.history.push('/'); + } + }; + + render () { + const { multiColumn } = this.props; + + const component = ( + + ); + + if (multiColumn) { + return component; + } else { + // The portal container and the component may be rendered to the DOM in + // the same React render pass, so the container might not be available at + // the time `render()` is called. + const container = document.getElementById('tabs-bar__portal'); + if (container === null) { + // The container wasn't available, force a re-render so that the + // component can eventually be inserted in the container and not scroll + // with the rest of the area. + this.forceUpdate(); + return component; + } else { + return createPortal(component, container); + } + } + } + +} diff --git a/app/javascript/flavours/glitch/components/column_back_button_slim.jsx b/app/javascript/flavours/glitch/components/column_back_button_slim.jsx new file mode 100644 index 00000000000000..4df045b5fb6b0d --- /dev/null +++ b/app/javascript/flavours/glitch/components/column_back_button_slim.jsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import PropTypes from 'prop-types'; +import Icon from 'flavours/glitch/components/icon'; + +export default class ColumnBackButtonSlim extends React.PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + handleClick = (event) => { + // if history is exhausted, or we would leave mastodon, just go to root. + if (window.history.state) { + const state = this.context.router.history.location.state; + if (event.shiftKey && state && state.mastodonBackSteps) { + this.context.router.history.go(-state.mastodonBackSteps); + } else { + this.context.router.history.goBack(); + } + } else { + this.context.router.history.push('/'); + } + }; + + render () { + return ( +
+
+ + +
+
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/components/column_header.jsx b/app/javascript/flavours/glitch/components/column_header.jsx new file mode 100644 index 00000000000000..6fbe2955d74134 --- /dev/null +++ b/app/javascript/flavours/glitch/components/column_header.jsx @@ -0,0 +1,221 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { createPortal } from 'react-dom'; +import classNames from 'classnames'; +import { FormattedMessage, injectIntl, defineMessages } from 'react-intl'; +import Icon from 'flavours/glitch/components/icon'; + +const messages = defineMessages({ + show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' }, + hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' }, + moveLeft: { id: 'column_header.moveLeft_settings', defaultMessage: 'Move column to the left' }, + moveRight: { id: 'column_header.moveRight_settings', defaultMessage: 'Move column to the right' }, +}); + +class ColumnHeader extends React.PureComponent { + + static contextTypes = { + router: PropTypes.object, + identity: PropTypes.object, + }; + + static propTypes = { + intl: PropTypes.object.isRequired, + title: PropTypes.node, + icon: PropTypes.string, + active: PropTypes.bool, + multiColumn: PropTypes.bool, + extraButton: PropTypes.node, + showBackButton: PropTypes.bool, + children: PropTypes.node, + pinned: PropTypes.bool, + placeholder: PropTypes.bool, + onPin: PropTypes.func, + onMove: PropTypes.func, + onClick: PropTypes.func, + appendContent: PropTypes.node, + collapseIssues: PropTypes.bool, + }; + + state = { + collapsed: true, + animating: false, + }; + + historyBack = (skip) => { + // if history is exhausted, or we would leave mastodon, just go to root. + if (window.history.state) { + const state = this.context.router.history.location.state; + if (skip && state && state.mastodonBackSteps) { + this.context.router.history.go(-state.mastodonBackSteps); + } else { + this.context.router.history.goBack(); + } + } else { + this.context.router.history.push('/'); + } + }; + + handleToggleClick = (e) => { + e.stopPropagation(); + this.setState({ collapsed: !this.state.collapsed, animating: true }); + }; + + handleTitleClick = () => { + this.props.onClick?.(); + }; + + handleMoveLeft = () => { + this.props.onMove(-1); + }; + + handleMoveRight = () => { + this.props.onMove(1); + }; + + handleBackClick = (event) => { + this.historyBack(event.shiftKey); + }; + + handleTransitionEnd = () => { + this.setState({ animating: false }); + }; + + handlePin = () => { + if (!this.props.pinned) { + this.historyBack(); + } + this.props.onPin(); + }; + + render () { + const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent, collapseIssues } = this.props; + const { collapsed, animating } = this.state; + + const wrapperClassName = classNames('column-header__wrapper', { + 'active': active, + }); + + const buttonClassName = classNames('column-header', { + 'active': active, + }); + + const collapsibleClassName = classNames('column-header__collapsible', { + 'collapsed': collapsed, + 'animating': animating, + }); + + const collapsibleButtonClassName = classNames('column-header__button', { + 'active': !collapsed, + }); + + let extraContent, pinButton, moveButtons, backButton, collapseButton; + + if (children) { + extraContent = ( +
+ {children} +
+ ); + } + + if (multiColumn && pinned) { + pinButton = ; + + moveButtons = ( +
+ + +
+ ); + } else if (multiColumn && this.props.onPin) { + pinButton = ; + } + + if (!pinned && (multiColumn || showBackButton)) { + backButton = ( + + ); + } + + const collapsedContent = [ + extraContent, + ]; + + if (multiColumn) { + collapsedContent.push(pinButton); + collapsedContent.push(moveButtons); + } + + if (this.context.identity.signedIn && (children || (multiColumn && this.props.onPin))) { + collapseButton = ( + + ); + } + + const hasTitle = icon && title; + + const component = ( +
+

+ {hasTitle && ( + + )} + + {!hasTitle && backButton} + +
+ {hasTitle && backButton} + {extraButton} + {collapseButton} +
+

+ +
+
+ {(!collapsed || animating) && collapsedContent} +
+
+ + {appendContent} +
+ ); + + if (multiColumn || placeholder) { + return component; + } else { + // The portal container and the component may be rendered to the DOM in + // the same React render pass, so the container might not be available at + // the time `render()` is called. + const container = document.getElementById('tabs-bar__portal'); + if (container === null) { + // The container wasn't available, force a re-render so that the + // component can eventually be inserted in the container and not scroll + // with the rest of the area. + this.forceUpdate(); + return component; + } else { + return createPortal(component, container); + } + } + } + +} + +export default injectIntl(ColumnHeader); diff --git a/app/javascript/flavours/glitch/components/common_counter.jsx b/app/javascript/flavours/glitch/components/common_counter.jsx new file mode 100644 index 00000000000000..dd9b62de9b1b3d --- /dev/null +++ b/app/javascript/flavours/glitch/components/common_counter.jsx @@ -0,0 +1,62 @@ +// @ts-check +import React from 'react'; +import { FormattedMessage } from 'react-intl'; + +/** + * Returns custom renderer for one of the common counter types + * + * @param {"statuses" | "following" | "followers"} counterType + * Type of the counter + * @param {boolean} isBold Whether display number must be displayed in bold + * @returns {(displayNumber: JSX.Element, pluralReady: number) => JSX.Element} + * Renderer function + * @throws If counterType is not covered by this function + */ +export function counterRenderer(counterType, isBold = true) { + /** + * @type {(displayNumber: JSX.Element) => JSX.Element} + */ + const renderCounter = isBold + ? (displayNumber) => {displayNumber} + : (displayNumber) => displayNumber; + + switch (counterType) { + case 'statuses': { + return (displayNumber, pluralReady) => ( + + ); + } + case 'following': { + return (displayNumber, pluralReady) => ( + + ); + } + case 'followers': { + return (displayNumber, pluralReady) => ( + + ); + } + default: throw Error(`Incorrect counter name: ${counterType}. Ensure it accepted by commonCounter function`); + } +} diff --git a/app/javascript/flavours/glitch/components/dismissable_banner.jsx b/app/javascript/flavours/glitch/components/dismissable_banner.jsx new file mode 100644 index 00000000000000..9b3faf6f272990 --- /dev/null +++ b/app/javascript/flavours/glitch/components/dismissable_banner.jsx @@ -0,0 +1,52 @@ +import React from 'react'; +import IconButton from './icon_button'; +import PropTypes from 'prop-types'; +import { injectIntl, defineMessages } from 'react-intl'; +import { bannerSettings } from 'flavours/glitch/settings'; + +const messages = defineMessages({ + dismiss: { id: 'dismissable_banner.dismiss', defaultMessage: 'Dismiss' }, +}); + +class DismissableBanner extends React.PureComponent { + + static propTypes = { + id: PropTypes.string.isRequired, + children: PropTypes.node, + intl: PropTypes.object.isRequired, + }; + + state = { + visible: !bannerSettings.get(this.props.id), + }; + + handleDismiss = () => { + const { id } = this.props; + this.setState({ visible: false }, () => bannerSettings.set(id, true)); + }; + + render () { + const { visible } = this.state; + + if (!visible) { + return null; + } + + const { children, intl } = this.props; + + return ( +
+
+ {children} +
+ +
+ +
+
+ ); + } + +} + +export default injectIntl(DismissableBanner); diff --git a/app/javascript/flavours/glitch/components/display_name.jsx b/app/javascript/flavours/glitch/components/display_name.jsx new file mode 100644 index 00000000000000..19f63ec60cd34c --- /dev/null +++ b/app/javascript/flavours/glitch/components/display_name.jsx @@ -0,0 +1,102 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { autoPlayGif } from 'flavours/glitch/initial_state'; +import Skeleton from 'flavours/glitch/components/skeleton'; + +export default class DisplayName extends React.PureComponent { + + static propTypes = { + account: ImmutablePropTypes.map, + className: PropTypes.string, + inline: PropTypes.bool, + localDomain: PropTypes.string, + others: ImmutablePropTypes.list, + handleClick: PropTypes.func, + }; + + handleMouseEnter = ({ currentTarget }) => { + if (autoPlayGif) { + return; + } + + const emojis = currentTarget.querySelectorAll('.custom-emoji'); + + for (var i = 0; i < emojis.length; i++) { + let emoji = emojis[i]; + emoji.src = emoji.getAttribute('data-original'); + } + }; + + handleMouseLeave = ({ currentTarget }) => { + if (autoPlayGif) { + return; + } + + const emojis = currentTarget.querySelectorAll('.custom-emoji'); + + for (var i = 0; i < emojis.length; i++) { + let emoji = emojis[i]; + emoji.src = emoji.getAttribute('data-static'); + } + }; + + render() { + const { account, className, inline, localDomain, others, onAccountClick } = this.props; + + const computedClass = classNames('display-name', { inline }, className); + + let displayName, suffix; + let acct; + + if (account) { + acct = account.get('acct'); + + if (acct.indexOf('@') === -1 && localDomain) { + acct = `${acct}@${localDomain}`; + } + } + + if (others && others.size > 0) { + displayName = others.take(2).map(a => ( + onAccountClick(a.get('acct'), e)} + title={`@${a.get('acct')}`} + rel='noopener noreferrer' + > + + + + + )).reduce((prev, cur) => [prev, ', ', cur]); + + if (others.size - 2 > 0) { + displayName.push(` +${others.size - 2}`); + } + + suffix = ( + onAccountClick(account.get('acct'), e)} rel='noopener noreferrer'> + @{acct} + + ); + } else if (account) { + displayName = ; + suffix = @{acct}; + } else { + displayName = ; + suffix = ; + } + + return ( + + {displayName} + {inline ? ' ' : null} + {suffix} + + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/domain.jsx b/app/javascript/flavours/glitch/components/domain.jsx new file mode 100644 index 00000000000000..85ebdbde9359b7 --- /dev/null +++ b/app/javascript/flavours/glitch/components/domain.jsx @@ -0,0 +1,43 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import IconButton from './icon_button'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' }, +}); + +class Account extends ImmutablePureComponent { + + static propTypes = { + domain: PropTypes.string, + onUnblockDomain: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + handleDomainUnblock = () => { + this.props.onUnblockDomain(this.props.domain); + }; + + render () { + const { domain, intl } = this.props; + + return ( +
+
+ + {domain} + + +
+ +
+
+
+ ); + } + +} + +export default injectIntl(Account); diff --git a/app/javascript/flavours/glitch/components/hashtag.jsx b/app/javascript/flavours/glitch/components/hashtag.jsx new file mode 100644 index 00000000000000..422b9a8fa15c3c --- /dev/null +++ b/app/javascript/flavours/glitch/components/hashtag.jsx @@ -0,0 +1,115 @@ +// @ts-check +import React from 'react'; +import { Sparklines, SparklinesCurve } from 'react-sparklines'; +import { FormattedMessage } from 'react-intl'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Permalink from './permalink'; +import ShortNumber from 'flavours/glitch/components/short_number'; +import Skeleton from 'flavours/glitch/components/skeleton'; +import classNames from 'classnames'; + +class SilentErrorBoundary extends React.Component { + + static propTypes = { + children: PropTypes.node, + }; + + state = { + error: false, + }; + + componentDidCatch () { + this.setState({ error: true }); + } + + render () { + if (this.state.error) { + return null; + } + + return this.props.children; + } + +} + +/** + * Used to render counter of how much people are talking about hashtag + * + * @type {(displayNumber: JSX.Element, pluralReady: number) => JSX.Element} + */ +export const accountsCountRenderer = (displayNumber, pluralReady) => ( + {displayNumber}
, + days: 2, + }} + /> +); + +export const ImmutableHashtag = ({ hashtag }) => ( + day.get('uses')).toArray()} + /> +); + +ImmutableHashtag.propTypes = { + hashtag: ImmutablePropTypes.map.isRequired, +}; + +const Hashtag = ({ name, href, to, people, uses, history, className, description, withGraph }) => ( +
+
+ + {name ? #{name} : } + + + {description ? ( + {description} + ) : ( + typeof people !== 'undefined' ? : + )} +
+ + {typeof uses !== 'undefined' && ( +
+ +
+ )} + + {withGraph && ( +
+ + 0)}> + + + +
+ )} +
+); + +Hashtag.propTypes = { + name: PropTypes.string, + href: PropTypes.string, + to: PropTypes.string, + people: PropTypes.number, + description: PropTypes.node, + uses: PropTypes.number, + history: PropTypes.arrayOf(PropTypes.number), + className: PropTypes.string, + withGraph: PropTypes.bool, +}; + +Hashtag.defaultProps = { + withGraph: true, +}; + +export default Hashtag; diff --git a/app/javascript/flavours/glitch/components/icon.jsx b/app/javascript/flavours/glitch/components/icon.jsx new file mode 100644 index 00000000000000..d8a17722fed2a7 --- /dev/null +++ b/app/javascript/flavours/glitch/components/icon.jsx @@ -0,0 +1,21 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +export default class Icon extends React.PureComponent { + + static propTypes = { + id: PropTypes.string.isRequired, + className: PropTypes.string, + fixedWidth: PropTypes.bool, + }; + + render () { + const { id, className, fixedWidth, ...other } = this.props; + + return ( + + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/icon_button.jsx b/app/javascript/flavours/glitch/components/icon_button.jsx new file mode 100644 index 00000000000000..10d7926be5e33d --- /dev/null +++ b/app/javascript/flavours/glitch/components/icon_button.jsx @@ -0,0 +1,177 @@ +import React from 'react'; +import Motion from '../features/ui/util/optional_motion'; +import spring from 'react-motion/lib/spring'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import Icon from 'flavours/glitch/components/icon'; +import AnimatedNumber from 'flavours/glitch/components/animated_number'; + +export default class IconButton extends React.PureComponent { + + static propTypes = { + className: PropTypes.string, + title: PropTypes.string.isRequired, + icon: PropTypes.string.isRequired, + onClick: PropTypes.func, + onMouseDown: PropTypes.func, + onKeyDown: PropTypes.func, + onKeyPress: PropTypes.func, + size: PropTypes.number, + active: PropTypes.bool, + expanded: PropTypes.bool, + style: PropTypes.object, + activeStyle: PropTypes.object, + disabled: PropTypes.bool, + inverted: PropTypes.bool, + animate: PropTypes.bool, + overlay: PropTypes.bool, + tabIndex: PropTypes.string, + label: PropTypes.string, + counter: PropTypes.number, + obfuscateCount: PropTypes.bool, + href: PropTypes.string, + ariaHidden: PropTypes.bool, + }; + + static defaultProps = { + size: 18, + active: false, + disabled: false, + animate: false, + overlay: false, + tabIndex: '0', + ariaHidden: false, + }; + + state = { + activate: false, + deactivate: false, + }; + + componentWillReceiveProps (nextProps) { + if (!nextProps.animate) return; + + if (this.props.active && !nextProps.active) { + this.setState({ activate: false, deactivate: true }); + } else if (!this.props.active && nextProps.active) { + this.setState({ activate: true, deactivate: false }); + } + } + + handleClick = (e) => { + e.preventDefault(); + + if (!this.props.disabled) { + this.props.onClick(e); + } + }; + + handleKeyPress = (e) => { + if (this.props.onKeyPress && !this.props.disabled) { + this.props.onKeyPress(e); + } + }; + + handleMouseDown = (e) => { + if (!this.props.disabled && this.props.onMouseDown) { + this.props.onMouseDown(e); + } + }; + + handleKeyDown = (e) => { + if (!this.props.disabled && this.props.onKeyDown) { + this.props.onKeyDown(e); + } + }; + + render () { + // Hack required for some icons which have an overriden size + let containerSize = '1.28571429em'; + if (this.props.style?.fontSize) { + containerSize = `${this.props.size * 1.28571429}px`; + } + + let style = { + fontSize: `${this.props.size}px`, + height: containerSize, + lineHeight: `${this.props.size}px`, + ...this.props.style, + ...(this.props.active ? this.props.activeStyle : {}), + }; + if (!this.props.label) { + style.width = containerSize; + } else { + style.textAlign = 'left'; + } + + const { + active, + className, + disabled, + expanded, + icon, + inverted, + overlay, + tabIndex, + title, + counter, + obfuscateCount, + href, + ariaHidden, + } = this.props; + + const { + activate, + deactivate, + } = this.state; + + const classes = classNames(className, 'icon-button', { + active, + disabled, + inverted, + activate, + deactivate, + overlayed: overlay, + 'icon-button--with-counter': typeof counter !== 'undefined', + }); + + if (typeof counter !== 'undefined') { + style.width = 'auto'; + } + + let contents = ( + + + ); + + if (href && !this.prop) { + contents = ( + + {contents} + + ); + } + + return ( + + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/icon_with_badge.jsx b/app/javascript/flavours/glitch/components/icon_with_badge.jsx new file mode 100644 index 00000000000000..a42ba45892f09a --- /dev/null +++ b/app/javascript/flavours/glitch/components/icon_with_badge.jsx @@ -0,0 +1,22 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Icon from 'flavours/glitch/components/icon'; + +const formatNumber = num => num > 40 ? '40+' : num; + +const IconWithBadge = ({ id, count, issueBadge, className }) => ( + + + {count > 0 && {formatNumber(count)}} + {issueBadge && } + +); + +IconWithBadge.propTypes = { + id: PropTypes.string.isRequired, + count: PropTypes.number.isRequired, + issueBadge: PropTypes.bool, + className: PropTypes.string, +}; + +export default IconWithBadge; diff --git a/app/javascript/flavours/glitch/components/image.jsx b/app/javascript/flavours/glitch/components/image.jsx new file mode 100644 index 00000000000000..6e81ddf082cb33 --- /dev/null +++ b/app/javascript/flavours/glitch/components/image.jsx @@ -0,0 +1,33 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Blurhash from './blurhash'; +import classNames from 'classnames'; + +export default class Image extends React.PureComponent { + + static propTypes = { + src: PropTypes.string, + srcSet: PropTypes.string, + blurhash: PropTypes.string, + className: PropTypes.string, + }; + + state = { + loaded: false, + }; + + handleLoad = () => this.setState({ loaded: true }); + + render () { + const { src, srcSet, blurhash, className } = this.props; + const { loaded } = this.state; + + return ( +
+ {blurhash && } + +
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/components/load_gap.jsx b/app/javascript/flavours/glitch/components/load_gap.jsx new file mode 100644 index 00000000000000..e70365d9ef10f3 --- /dev/null +++ b/app/javascript/flavours/glitch/components/load_gap.jsx @@ -0,0 +1,35 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, defineMessages } from 'react-intl'; +import Icon from 'flavours/glitch/components/icon'; + +const messages = defineMessages({ + load_more: { id: 'status.load_more', defaultMessage: 'Load more' }, +}); + +class LoadGap extends React.PureComponent { + + static propTypes = { + disabled: PropTypes.bool, + maxId: PropTypes.string, + onClick: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + handleClick = () => { + this.props.onClick(this.props.maxId); + }; + + render () { + const { disabled, intl } = this.props; + + return ( + + ); + } + +} + +export default injectIntl(LoadGap); diff --git a/app/javascript/flavours/glitch/components/load_more.jsx b/app/javascript/flavours/glitch/components/load_more.jsx new file mode 100644 index 00000000000000..ab9428e35a86fa --- /dev/null +++ b/app/javascript/flavours/glitch/components/load_more.jsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import PropTypes from 'prop-types'; + +export default class LoadMore extends React.PureComponent { + + static propTypes = { + onClick: PropTypes.func, + disabled: PropTypes.bool, + visible: PropTypes.bool, + }; + + static defaultProps = { + visible: true, + }; + + render() { + const { disabled, visible } = this.props; + + return ( + + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/load_pending.jsx b/app/javascript/flavours/glitch/components/load_pending.jsx new file mode 100644 index 00000000000000..a75259146ae804 --- /dev/null +++ b/app/javascript/flavours/glitch/components/load_pending.jsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import PropTypes from 'prop-types'; + +export default class LoadPending extends React.PureComponent { + + static propTypes = { + onClick: PropTypes.func, + count: PropTypes.number, + }; + + render() { + const { count } = this.props; + + return ( + + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/loading_indicator.jsx b/app/javascript/flavours/glitch/components/loading_indicator.jsx new file mode 100644 index 00000000000000..59f721c50f9c93 --- /dev/null +++ b/app/javascript/flavours/glitch/components/loading_indicator.jsx @@ -0,0 +1,32 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +export const CircularProgress = ({ size, strokeWidth }) => { + const viewBox = `0 0 ${size} ${size}`; + const radius = (size - strokeWidth) / 2; + + return ( + + + + ); +}; + +CircularProgress.propTypes = { + size: PropTypes.number.isRequired, + strokeWidth: PropTypes.number.isRequired, +}; + +const LoadingIndicator = () => ( +
+ +
+); + +export default LoadingIndicator; diff --git a/app/javascript/flavours/glitch/components/logo.jsx b/app/javascript/flavours/glitch/components/logo.jsx new file mode 100644 index 00000000000000..ff562983252f4b --- /dev/null +++ b/app/javascript/flavours/glitch/components/logo.jsx @@ -0,0 +1,7 @@ +import React from 'react'; + +const Logo = () => ( + Logo +); + +export default Logo; diff --git a/app/javascript/flavours/glitch/components/navigation_portal.jsx b/app/javascript/flavours/glitch/components/navigation_portal.jsx new file mode 100644 index 00000000000000..9e849417936df9 --- /dev/null +++ b/app/javascript/flavours/glitch/components/navigation_portal.jsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { Switch, Route, withRouter } from 'react-router-dom'; +import { showTrends } from 'flavours/glitch/initial_state'; +import Trends from 'flavours/glitch/features/getting_started/containers/trends_container'; +import AccountNavigation from 'flavours/glitch/features/account/navigation'; + +const DefaultNavigation = () => ( + <> + {showTrends && ( + <> +
+ + + )} + +); + +class NavigationPortal extends React.PureComponent { + + render () { + return ( + + + + + + + + + + ); + } + +} + +export default withRouter(NavigationPortal); diff --git a/app/javascript/flavours/glitch/components/not_signed_in_indicator.jsx b/app/javascript/flavours/glitch/components/not_signed_in_indicator.jsx new file mode 100644 index 00000000000000..b440c6be2f7045 --- /dev/null +++ b/app/javascript/flavours/glitch/components/not_signed_in_indicator.jsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; + +const NotSignedInIndicator = () => ( +
+
+ +
+
+); + +export default NotSignedInIndicator; diff --git a/app/javascript/flavours/glitch/components/permalink.jsx b/app/javascript/flavours/glitch/components/permalink.jsx new file mode 100644 index 00000000000000..b09b17eeb4ef01 --- /dev/null +++ b/app/javascript/flavours/glitch/components/permalink.jsx @@ -0,0 +1,51 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +export default class Permalink extends React.PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + className: PropTypes.string, + href: PropTypes.string.isRequired, + to: PropTypes.string.isRequired, + children: PropTypes.node, + onInterceptClick: PropTypes.func, + }; + + handleClick = (e) => { + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { + if (this.props.onInterceptClick && this.props.onInterceptClick()) { + e.preventDefault(); + return; + } + + if (this.context.router) { + e.preventDefault(); + let state = { ...this.context.router.history.location.state }; + state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1; + this.context.router.history.push(this.props.to, state); + } + } + }; + + render () { + const { + children, + className, + href, + to, + onInterceptClick, + ...other + } = this.props; + + return ( + + {children} + + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/radio_button.jsx b/app/javascript/flavours/glitch/components/radio_button.jsx new file mode 100644 index 00000000000000..0496fa2868ac7b --- /dev/null +++ b/app/javascript/flavours/glitch/components/radio_button.jsx @@ -0,0 +1,35 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +export default class RadioButton extends React.PureComponent { + + static propTypes = { + value: PropTypes.string.isRequired, + checked: PropTypes.bool, + name: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + label: PropTypes.node.isRequired, + }; + + render () { + const { name, value, checked, onChange, label } = this.props; + + return ( + + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/relative_timestamp.jsx b/app/javascript/flavours/glitch/components/relative_timestamp.jsx new file mode 100644 index 00000000000000..e6c3e088058daf --- /dev/null +++ b/app/javascript/flavours/glitch/components/relative_timestamp.jsx @@ -0,0 +1,200 @@ +import React from 'react'; +import { injectIntl, defineMessages } from 'react-intl'; +import PropTypes from 'prop-types'; + +const messages = defineMessages({ + today: { id: 'relative_time.today', defaultMessage: 'today' }, + just_now: { id: 'relative_time.just_now', defaultMessage: 'now' }, + just_now_full: { id: 'relative_time.full.just_now', defaultMessage: 'just now' }, + seconds: { id: 'relative_time.seconds', defaultMessage: '{number}s' }, + seconds_full: { id: 'relative_time.full.seconds', defaultMessage: '{number, plural, one {# second} other {# seconds}} ago' }, + minutes: { id: 'relative_time.minutes', defaultMessage: '{number}m' }, + minutes_full: { id: 'relative_time.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}} ago' }, + hours: { id: 'relative_time.hours', defaultMessage: '{number}h' }, + hours_full: { id: 'relative_time.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}} ago' }, + days: { id: 'relative_time.days', defaultMessage: '{number}d' }, + days_full: { id: 'relative_time.full.days', defaultMessage: '{number, plural, one {# day} other {# days}} ago' }, + moments_remaining: { id: 'time_remaining.moments', defaultMessage: 'Moments remaining' }, + seconds_remaining: { id: 'time_remaining.seconds', defaultMessage: '{number, plural, one {# second} other {# seconds}} left' }, + minutes_remaining: { id: 'time_remaining.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}} left' }, + hours_remaining: { id: 'time_remaining.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}} left' }, + days_remaining: { id: 'time_remaining.days', defaultMessage: '{number, plural, one {# day} other {# days}} left' }, +}); + +const dateFormatOptions = { + hour12: false, + year: 'numeric', + month: 'short', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', +}; + +const shortDateFormatOptions = { + month: 'short', + day: 'numeric', +}; + +const SECOND = 1000; +const MINUTE = 1000 * 60; +const HOUR = 1000 * 60 * 60; +const DAY = 1000 * 60 * 60 * 24; + +const MAX_DELAY = 2147483647; + +const selectUnits = delta => { + const absDelta = Math.abs(delta); + + if (absDelta < MINUTE) { + return 'second'; + } else if (absDelta < HOUR) { + return 'minute'; + } else if (absDelta < DAY) { + return 'hour'; + } + + return 'day'; +}; + +const getUnitDelay = units => { + switch (units) { + case 'second': + return SECOND; + case 'minute': + return MINUTE; + case 'hour': + return HOUR; + case 'day': + return DAY; + default: + return MAX_DELAY; + } +}; + +export const timeAgoString = (intl, date, now, year, timeGiven, short) => { + const delta = now - date.getTime(); + + let relativeTime; + + if (delta < DAY && !timeGiven) { + relativeTime = intl.formatMessage(messages.today); + } else if (delta < 10 * SECOND) { + relativeTime = intl.formatMessage(short ? messages.just_now : messages.just_now_full); + } else if (delta < 7 * DAY) { + if (delta < MINUTE) { + relativeTime = intl.formatMessage(short ? messages.seconds : messages.seconds_full, { number: Math.floor(delta / SECOND) }); + } else if (delta < HOUR) { + relativeTime = intl.formatMessage(short ? messages.minutes : messages.minutes_full, { number: Math.floor(delta / MINUTE) }); + } else if (delta < DAY) { + relativeTime = intl.formatMessage(short ? messages.hours : messages.hours_full, { number: Math.floor(delta / HOUR) }); + } else { + relativeTime = intl.formatMessage(short ? messages.days : messages.days_full, { number: Math.floor(delta / DAY) }); + } + } else if (date.getFullYear() === year) { + relativeTime = intl.formatDate(date, shortDateFormatOptions); + } else { + relativeTime = intl.formatDate(date, { ...shortDateFormatOptions, year: 'numeric' }); + } + + return relativeTime; +}; + +const timeRemainingString = (intl, date, now, timeGiven = true) => { + const delta = date.getTime() - now; + + let relativeTime; + + if (delta < DAY && !timeGiven) { + relativeTime = intl.formatMessage(messages.today); + } else if (delta < 10 * SECOND) { + relativeTime = intl.formatMessage(messages.moments_remaining); + } else if (delta < MINUTE) { + relativeTime = intl.formatMessage(messages.seconds_remaining, { number: Math.floor(delta / SECOND) }); + } else if (delta < HOUR) { + relativeTime = intl.formatMessage(messages.minutes_remaining, { number: Math.floor(delta / MINUTE) }); + } else if (delta < DAY) { + relativeTime = intl.formatMessage(messages.hours_remaining, { number: Math.floor(delta / HOUR) }); + } else { + relativeTime = intl.formatMessage(messages.days_remaining, { number: Math.floor(delta / DAY) }); + } + + return relativeTime; +}; + +class RelativeTimestamp extends React.Component { + + static propTypes = { + intl: PropTypes.object.isRequired, + timestamp: PropTypes.string.isRequired, + year: PropTypes.number.isRequired, + futureDate: PropTypes.bool, + short: PropTypes.bool, + }; + + state = { + now: this.props.intl.now(), + }; + + static defaultProps = { + year: (new Date()).getFullYear(), + short: true, + }; + + shouldComponentUpdate (nextProps, nextState) { + // As of right now the locale doesn't change without a new page load, + // but we might as well check in case that ever changes. + return this.props.timestamp !== nextProps.timestamp || + this.props.intl.locale !== nextProps.intl.locale || + this.state.now !== nextState.now; + } + + componentWillReceiveProps (nextProps) { + if (this.props.timestamp !== nextProps.timestamp) { + this.setState({ now: this.props.intl.now() }); + } + } + + componentDidMount () { + this._scheduleNextUpdate(this.props, this.state); + } + + componentWillUpdate (nextProps, nextState) { + this._scheduleNextUpdate(nextProps, nextState); + } + + componentWillUnmount () { + clearTimeout(this._timer); + } + + _scheduleNextUpdate (props, state) { + clearTimeout(this._timer); + + const { timestamp } = props; + const delta = (new Date(timestamp)).getTime() - state.now; + const unitDelay = getUnitDelay(selectUnits(delta)); + const unitRemainder = Math.abs(delta % unitDelay); + const updateInterval = 1000 * 10; + const delay = delta < 0 ? Math.max(updateInterval, unitDelay - unitRemainder) : Math.max(updateInterval, unitRemainder); + + this._timer = setTimeout(() => { + this.setState({ now: this.props.intl.now() }); + }, delay); + } + + render () { + const { timestamp, intl, year, futureDate, short } = this.props; + + const timeGiven = timestamp.includes('T'); + const date = new Date(timestamp); + const relativeTime = futureDate ? timeRemainingString(intl, date, this.state.now, timeGiven) : timeAgoString(intl, date, this.state.now, year, timeGiven, short); + + return ( + + ); + } + +} + +export default injectIntl(RelativeTimestamp); diff --git a/app/javascript/flavours/glitch/components/short_number.jsx b/app/javascript/flavours/glitch/components/short_number.jsx new file mode 100644 index 00000000000000..535c17727d5337 --- /dev/null +++ b/app/javascript/flavours/glitch/components/short_number.jsx @@ -0,0 +1,117 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { toShortNumber, pluralReady, DECIMAL_UNITS } from '../utils/numbers'; +import { FormattedMessage, FormattedNumber } from 'react-intl'; +// @ts-check + +/** + * @callback ShortNumberRenderer + * @param {JSX.Element} displayNumber Number to display + * @param {number} pluralReady Number used for pluralization + * @returns {JSX.Element} Final render of number + */ + +/** + * @typedef {object} ShortNumberProps + * @property {number} value Number to display in short variant + * @property {ShortNumberRenderer} [renderer] + * Custom renderer for numbers, provided as a prop. If another renderer + * passed as a child of this component, this prop won't be used. + * @property {ShortNumberRenderer} [children] + * Custom renderer for numbers, provided as a child. If another renderer + * passed as a prop of this component, this one will be used instead. + */ + +/** + * Component that renders short big number to a shorter version + * + * @param {ShortNumberProps} param0 Props for the component + * @returns {JSX.Element} Rendered number + */ +function ShortNumber({ value, renderer, children }) { + const shortNumber = toShortNumber(value); + const [, division] = shortNumber; + + // eslint-disable-next-line eqeqeq + if (children != null && renderer != null) { + console.warn('Both renderer prop and renderer as a child provided. This is a mistake and you really should fix that. Only renderer passed as a child will be used.'); + } + + // eslint-disable-next-line eqeqeq + const customRenderer = children != null ? children : renderer; + + const displayNumber = ; + + // eslint-disable-next-line eqeqeq + return customRenderer != null + ? customRenderer(displayNumber, pluralReady(value, division)) + : displayNumber; +} + +ShortNumber.propTypes = { + value: PropTypes.number.isRequired, + renderer: PropTypes.func, + children: PropTypes.func, +}; + +/** + * @typedef {object} ShortNumberCounterProps + * @property {import('../utils/number').ShortNumber} value Short number + */ + +/** + * Renders short number into corresponding localizable react fragment + * + * @param {ShortNumberCounterProps} param0 Props for the component + * @returns {JSX.Element} FormattedMessage ready to be embedded in code + */ +function ShortNumberCounter({ value }) { + const [rawNumber, unit, maxFractionDigits = 0] = value; + + const count = ( + + ); + + let values = { count, rawNumber }; + + switch (unit) { + case DECIMAL_UNITS.THOUSAND: { + return ( + + ); + } + case DECIMAL_UNITS.MILLION: { + return ( + + ); + } + case DECIMAL_UNITS.BILLION: { + return ( + + ); + } + // Not sure if we should go farther - @Sasha-Sorokin + default: return count; + } +} + +ShortNumberCounter.propTypes = { + value: PropTypes.arrayOf(PropTypes.number), +}; + +export default React.memo(ShortNumber); diff --git a/app/javascript/flavours/glitch/components/skeleton.jsx b/app/javascript/flavours/glitch/components/skeleton.jsx new file mode 100644 index 00000000000000..6a17ffb261ef16 --- /dev/null +++ b/app/javascript/flavours/glitch/components/skeleton.jsx @@ -0,0 +1,11 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const Skeleton = ({ width, height }) => ; + +Skeleton.propTypes = { + width: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + height: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), +}; + +export default Skeleton; diff --git a/app/javascript/flavours/glitch/components/spoilers.jsx b/app/javascript/flavours/glitch/components/spoilers.jsx new file mode 100644 index 00000000000000..d1d875807219fc --- /dev/null +++ b/app/javascript/flavours/glitch/components/spoilers.jsx @@ -0,0 +1,52 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; + +export default +class Spoilers extends React.PureComponent { + + static propTypes = { + spoilerText: PropTypes.string, + children: PropTypes.node, + }; + + state = { + hidden: true, + }; + + handleSpoilerClick = () => { + this.setState({ hidden: !this.state.hidden }); + }; + + render () { + const { spoilerText, children } = this.props; + const { hidden } = this.state; + + const toggleText = hidden ? + () : + (); + + return ([ +

+ {spoilerText} + {' '} + +

, +
+ {children} +
, + ]); + } + +} + diff --git a/app/javascript/flavours/glitch/components/status_visibility_icon.jsx b/app/javascript/flavours/glitch/components/status_visibility_icon.jsx new file mode 100644 index 00000000000000..fcedfbfd6bd1b2 --- /dev/null +++ b/app/javascript/flavours/glitch/components/status_visibility_icon.jsx @@ -0,0 +1,52 @@ +// Package imports // +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import Icon from 'flavours/glitch/components/icon'; + +const messages = defineMessages({ + public: { id: 'privacy.public.short', defaultMessage: 'Public' }, + unlisted: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' }, + private: { id: 'privacy.private.short', defaultMessage: 'Followers-only' }, + direct: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' }, +}); + +class VisibilityIcon extends ImmutablePureComponent { + + static propTypes = { + visibility: PropTypes.string, + intl: PropTypes.object.isRequired, + withLabel: PropTypes.bool, + }; + + render() { + const { withLabel, visibility, intl } = this.props; + + const visibilityIcon = { + public: 'globe', + unlisted: 'unlock', + private: 'lock', + direct: 'envelope', + }[visibility]; + + const label = intl.formatMessage(messages[visibility]); + + const icon = (
}} />, + confirm: intl.formatMessage(messages.blockDomainConfirm), + onConfirm: () => dispatch(blockDomain(domain)), + })); + }, + + onUnblockDomain (domain) { + dispatch(unblockDomain(domain)); + }, +}); + +export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Domain)); diff --git a/app/javascript/flavours/glitch/extra_polyfills.js b/app/javascript/flavours/glitch/extra_polyfills.js new file mode 100644 index 00000000000000..e6c69de8b557b7 --- /dev/null +++ b/app/javascript/flavours/glitch/extra_polyfills.js @@ -0,0 +1,2 @@ +import 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only'; +import 'requestidlecallback'; 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 new file mode 100644 index 00000000000000..5fd84996b2bf79 --- /dev/null +++ b/app/javascript/flavours/glitch/features/account_gallery/components/media_item.jsx @@ -0,0 +1,149 @@ +import Blurhash from 'flavours/glitch/components/blurhash'; +import classNames from 'classnames'; +import Icon from 'flavours/glitch/components/icon'; +import { autoPlayGif, displayMedia, useBlurhash } from 'flavours/glitch/initial_state'; +import PropTypes from 'prop-types'; +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +export default class MediaItem extends ImmutablePureComponent { + + static propTypes = { + attachment: ImmutablePropTypes.map.isRequired, + displayWidth: PropTypes.number.isRequired, + onOpenMedia: PropTypes.func.isRequired, + }; + + state = { + visible: displayMedia !== 'hide_all' && !this.props.attachment.getIn(['status', 'sensitive']) || displayMedia === 'show_all', + loaded: false, + }; + + handleImageLoad = () => { + this.setState({ loaded: true }); + }; + + handleMouseEnter = e => { + if (this.hoverToPlay()) { + e.target.play(); + } + }; + + handleMouseLeave = e => { + if (this.hoverToPlay()) { + e.target.pause(); + e.target.currentTime = 0; + } + }; + + hoverToPlay () { + return !autoPlayGif && ['gifv', 'video'].indexOf(this.props.attachment.get('type')) !== -1; + } + + handleClick = e => { + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + + if (this.state.visible) { + this.props.onOpenMedia(this.props.attachment); + } else { + this.setState({ visible: true }); + } + } + }; + + render () { + const { attachment, displayWidth } = this.props; + const { visible, loaded } = this.state; + + const width = `${Math.floor((displayWidth - 4) / 3) - 4}px`; + const height = width; + const status = attachment.get('status'); + const title = status.get('spoiler_text') || attachment.get('description'); + + let thumbnail, label, icon, content; + + if (!visible) { + icon = ( + + + + ); + } else { + if (['audio', 'video'].includes(attachment.get('type'))) { + content = ( + {attachment.get('description')} + ); + + if (attachment.get('type') === 'audio') { + label = ; + } else { + label = ; + } + } else if (attachment.get('type') === 'image') { + const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0; + const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0; + const x = ((focusX / 2) + .5) * 100; + const y = ((focusY / -2) + .5) * 100; + + content = ( + {attachment.get('description')} + ); + } else if (attachment.get('type') === 'gifv') { + content = ( +
+ + )} +
+ + + + + + ); + } + +} + +export default connect(mapStateToProps)(FollowRecommendations); diff --git a/app/javascript/flavours/glitch/features/home_timeline/components/column_settings.jsx b/app/javascript/flavours/glitch/features/home_timeline/components/column_settings.jsx new file mode 100644 index 00000000000000..1eeeaa378ac38e --- /dev/null +++ b/app/javascript/flavours/glitch/features/home_timeline/components/column_settings.jsx @@ -0,0 +1,51 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import SettingToggle from 'flavours/glitch/features/notifications/components/setting_toggle'; +import SettingText from 'flavours/glitch/components/setting_text'; + +const messages = defineMessages({ + filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' }, + settings: { id: 'home.settings', defaultMessage: 'Column settings' }, +}); + +class ColumnSettings extends React.PureComponent { + + static propTypes = { + settings: ImmutablePropTypes.map.isRequired, + onChange: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + render () { + const { settings, onChange, intl } = this.props; + + return ( +
+ + +
+ } /> +
+ +
+ } /> +
+ +
+ } /> +
+ + + +
+ +
+
+ ); + } + +} + +export default injectIntl(ColumnSettings); diff --git a/app/javascript/flavours/glitch/features/home_timeline/containers/column_settings_container.js b/app/javascript/flavours/glitch/features/home_timeline/containers/column_settings_container.js new file mode 100644 index 00000000000000..16747151b0624d --- /dev/null +++ b/app/javascript/flavours/glitch/features/home_timeline/containers/column_settings_container.js @@ -0,0 +1,21 @@ +import { connect } from 'react-redux'; +import ColumnSettings from '../components/column_settings'; +import { changeSetting, saveSettings } from 'flavours/glitch/actions/settings'; + +const mapStateToProps = state => ({ + settings: state.getIn(['settings', 'home']), +}); + +const mapDispatchToProps = dispatch => ({ + + onChange (path, checked) { + dispatch(changeSetting(['home', ...path], checked)); + }, + + onSave () { + dispatch(saveSettings()); + }, + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings); diff --git a/app/javascript/flavours/glitch/features/list_editor/containers/account_container.js b/app/javascript/flavours/glitch/features/list_editor/containers/account_container.js new file mode 100644 index 00000000000000..782eb42f3e2907 --- /dev/null +++ b/app/javascript/flavours/glitch/features/list_editor/containers/account_container.js @@ -0,0 +1,24 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { makeGetAccount } from 'flavours/glitch/selectors'; +import { injectIntl } from 'react-intl'; +import { removeFromListEditor, addToListEditor } from 'flavours/glitch/actions/lists'; +import Account from '../components/account'; + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = (state, { accountId, added }) => ({ + account: getAccount(state, accountId), + added: typeof added === 'undefined' ? state.getIn(['listEditor', 'accounts', 'items']).includes(accountId) : added, + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch, { accountId }) => ({ + onRemove: () => dispatch(removeFromListEditor(accountId)), + onAdd: () => dispatch(addToListEditor(accountId)), +}); + +export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Account)); diff --git a/app/javascript/flavours/glitch/features/list_editor/containers/search_container.js b/app/javascript/flavours/glitch/features/list_editor/containers/search_container.js new file mode 100644 index 00000000000000..5af20efbd6c453 --- /dev/null +++ b/app/javascript/flavours/glitch/features/list_editor/containers/search_container.js @@ -0,0 +1,17 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { injectIntl } from 'react-intl'; +import { fetchListSuggestions, clearListSuggestions, changeListSuggestions } from '../../../actions/lists'; +import Search from '../components/search'; + +const mapStateToProps = state => ({ + value: state.getIn(['listEditor', 'suggestions', 'value']), +}); + +const mapDispatchToProps = dispatch => ({ + onSubmit: value => dispatch(fetchListSuggestions(value)), + onClear: () => dispatch(clearListSuggestions()), + onChange: value => dispatch(changeListSuggestions(value)), +}); + +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Search)); diff --git a/app/javascript/flavours/glitch/features/notifications/components/admin_report.jsx b/app/javascript/flavours/glitch/features/notifications/components/admin_report.jsx new file mode 100644 index 00000000000000..9b55fe4e981d6e --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/components/admin_report.jsx @@ -0,0 +1,112 @@ +// Package imports. +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { HotKeys } from 'react-hotkeys'; +import classNames from 'classnames'; + +// Our imports. +import Permalink from 'flavours/glitch/components/permalink'; +import AccountContainer from 'flavours/glitch/containers/account_container'; +import NotificationOverlayContainer from '../containers/overlay_container'; +import Icon from 'flavours/glitch/components/icon'; +import Report from './report'; + +const messages = defineMessages({ + adminReport: { id: 'notification.admin.report', defaultMessage: '{name} reported {target}' }, +}); + +export default class AdminReport extends ImmutablePureComponent { + + static propTypes = { + hidden: PropTypes.bool, + id: PropTypes.string.isRequired, + account: ImmutablePropTypes.map.isRequired, + notification: ImmutablePropTypes.map.isRequired, + unread: PropTypes.bool, + report: ImmutablePropTypes.map.isRequired, + }; + + handleMoveUp = () => { + const { notification, onMoveUp } = this.props; + onMoveUp(notification.get('id')); + }; + + handleMoveDown = () => { + const { notification, onMoveDown } = this.props; + onMoveDown(notification.get('id')); + }; + + handleOpen = () => { + this.handleOpenProfile(); + }; + + handleOpenProfile = () => { + const { notification } = this.props; + this.context.router.history.push(`/@${notification.getIn(['account', 'acct'])}`); + }; + + handleMention = e => { + e.preventDefault(); + + const { notification, onMention } = this.props; + onMention(notification.get('account'), this.context.router.history); + }; + + getHandlers () { + return { + moveUp: this.handleMoveUp, + moveDown: this.handleMoveDown, + open: this.handleOpen, + openProfile: this.handleOpenProfile, + mention: this.handleMention, + reply: this.handleMention, + }; + } + + render () { + const { intl, account, notification, unread, report } = this.props; + + if (!report) { + return null; + } + + // Links to the display name. + const displayName = account.get('display_name_html') || account.get('username'); + const link = ( + + ); + + const targetAccount = report.get('target_account'); + const targetDisplayNameHtml = { __html: targetAccount.get('display_name_html') }; + const targetLink = ; + + return ( + +
+
+
+ +
+ + + + +
+ +
+
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/notifications/components/admin_signup.jsx b/app/javascript/flavours/glitch/features/notifications/components/admin_signup.jsx new file mode 100644 index 00000000000000..d982108e92dea2 --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/components/admin_signup.jsx @@ -0,0 +1,101 @@ +// Package imports. +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { HotKeys } from 'react-hotkeys'; +import classNames from 'classnames'; + +// Our imports. +import Permalink from 'flavours/glitch/components/permalink'; +import AccountContainer from 'flavours/glitch/containers/account_container'; +import NotificationOverlayContainer from '../containers/overlay_container'; +import Icon from 'flavours/glitch/components/icon'; + +export default class NotificationFollow extends ImmutablePureComponent { + + static propTypes = { + hidden: PropTypes.bool, + id: PropTypes.string.isRequired, + account: ImmutablePropTypes.map.isRequired, + notification: ImmutablePropTypes.map.isRequired, + unread: PropTypes.bool, + }; + + handleMoveUp = () => { + const { notification, onMoveUp } = this.props; + onMoveUp(notification.get('id')); + }; + + handleMoveDown = () => { + const { notification, onMoveDown } = this.props; + onMoveDown(notification.get('id')); + }; + + handleOpen = () => { + this.handleOpenProfile(); + }; + + handleOpenProfile = () => { + const { notification } = this.props; + this.context.router.history.push(`/@${notification.getIn(['account', 'acct'])}`); + }; + + handleMention = e => { + e.preventDefault(); + + const { notification, onMention } = this.props; + onMention(notification.get('account'), this.context.router.history); + }; + + getHandlers () { + return { + moveUp: this.handleMoveUp, + moveDown: this.handleMoveDown, + open: this.handleOpen, + openProfile: this.handleOpenProfile, + mention: this.handleMention, + reply: this.handleMention, + }; + } + + render () { + const { account, notification, hidden, unread } = this.props; + + // Links to the display name. + const displayName = account.get('display_name_html') || account.get('username'); + const link = ( + + ); + + // Renders. + return ( + +
+
+
+ +
+ + +
+ +
+
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/notifications/components/follow.jsx b/app/javascript/flavours/glitch/features/notifications/components/follow.jsx new file mode 100644 index 00000000000000..e9ef70911ef0af --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/components/follow.jsx @@ -0,0 +1,101 @@ +// Package imports. +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { HotKeys } from 'react-hotkeys'; +import classNames from 'classnames'; + +// Our imports. +import Permalink from 'flavours/glitch/components/permalink'; +import AccountContainer from 'flavours/glitch/containers/account_container'; +import NotificationOverlayContainer from '../containers/overlay_container'; +import Icon from 'flavours/glitch/components/icon'; + +export default class NotificationFollow extends ImmutablePureComponent { + + static propTypes = { + hidden: PropTypes.bool, + id: PropTypes.string.isRequired, + account: ImmutablePropTypes.map.isRequired, + notification: ImmutablePropTypes.map.isRequired, + unread: PropTypes.bool, + }; + + handleMoveUp = () => { + const { notification, onMoveUp } = this.props; + onMoveUp(notification.get('id')); + }; + + handleMoveDown = () => { + const { notification, onMoveDown } = this.props; + onMoveDown(notification.get('id')); + }; + + handleOpen = () => { + this.handleOpenProfile(); + }; + + handleOpenProfile = () => { + const { notification } = this.props; + this.context.router.history.push(`/@${notification.getIn(['account', 'acct'])}`); + }; + + handleMention = e => { + e.preventDefault(); + + const { notification, onMention } = this.props; + onMention(notification.get('account'), this.context.router.history); + }; + + getHandlers () { + return { + moveUp: this.handleMoveUp, + moveDown: this.handleMoveDown, + open: this.handleOpen, + openProfile: this.handleOpenProfile, + mention: this.handleMention, + reply: this.handleMention, + }; + } + + render () { + const { account, notification, hidden, unread } = this.props; + + // Links to the display name. + const displayName = account.get('display_name_html') || account.get('username'); + const link = ( + + ); + + // Renders. + return ( + +
+
+
+ +
+ + +
+ +
+
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/notifications/containers/admin_report_container.js b/app/javascript/flavours/glitch/features/notifications/containers/admin_report_container.js new file mode 100644 index 00000000000000..4198afce8b8b88 --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/containers/admin_report_container.js @@ -0,0 +1,13 @@ +import { connect } from 'react-redux'; +import { makeGetReport } from 'flavours/glitch/selectors'; +import AdminReport from '../components/admin_report'; + +const mapStateToProps = (state, { notification }) => { + const getReport = makeGetReport(); + + return { + report: notification.get('report') ? getReport(state, notification.get('report'), notification.getIn(['report', 'target_account', 'id'])) : null, + }; +}; + +export default connect(mapStateToProps)(AdminReport); diff --git a/app/javascript/flavours/glitch/features/picture_in_picture/components/header.jsx b/app/javascript/flavours/glitch/features/picture_in_picture/components/header.jsx new file mode 100644 index 00000000000000..b9b90f1d8f02fc --- /dev/null +++ b/app/javascript/flavours/glitch/features/picture_in_picture/components/header.jsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import IconButton from 'flavours/glitch/components/icon_button'; +import { Link } from 'react-router-dom'; +import Avatar from 'flavours/glitch/components/avatar'; +import DisplayName from 'flavours/glitch/components/display_name'; +import { defineMessages, injectIntl } from 'react-intl'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, +}); + +const mapStateToProps = (state, { accountId }) => ({ + account: state.getIn(['accounts', accountId]), +}); + +class Header extends ImmutablePureComponent { + + static propTypes = { + accountId: PropTypes.string.isRequired, + statusId: PropTypes.string.isRequired, + account: ImmutablePropTypes.map.isRequired, + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + render () { + const { account, statusId, onClose, intl } = this.props; + + return ( +
+ + + + + + +
+ ); + } + +} + +export default connect(mapStateToProps)(injectIntl(Header)); diff --git a/app/javascript/flavours/glitch/features/picture_in_picture/index.jsx b/app/javascript/flavours/glitch/features/picture_in_picture/index.jsx new file mode 100644 index 00000000000000..e6fb64ff99e00a --- /dev/null +++ b/app/javascript/flavours/glitch/features/picture_in_picture/index.jsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import Video from 'flavours/glitch/features/video'; +import Audio from 'flavours/glitch/features/audio'; +import { removePictureInPicture } from 'flavours/glitch/actions/picture_in_picture'; +import Header from './components/header'; +import Footer from './components/footer'; +import classNames from 'classnames'; + +const mapStateToProps = state => ({ + ...state.get('picture_in_picture'), + left: state.getIn(['local_settings', 'media', 'pop_in_position']) === 'left', +}); + +class PictureInPicture extends React.Component { + + static propTypes = { + statusId: PropTypes.string, + accountId: PropTypes.string, + type: PropTypes.string, + src: PropTypes.string, + muted: PropTypes.bool, + volume: PropTypes.number, + currentTime: PropTypes.number, + poster: PropTypes.string, + backgroundColor: PropTypes.string, + foregroundColor: PropTypes.string, + accentColor: PropTypes.string, + dispatch: PropTypes.func.isRequired, + left: PropTypes.bool, + }; + + handleClose = () => { + const { dispatch } = this.props; + dispatch(removePictureInPicture()); + }; + + render () { + const { type, src, currentTime, accountId, statusId, left } = this.props; + + if (!currentTime) { + return null; + } + + let player; + + if (type === 'video') { + player = ( +