mirror of
https://github.com/funamitech/mastodon
synced 2024-11-27 14:29:03 +09:00
Merge branch 'main' of https://github.com/glitch-soc/mastodon
This commit is contained in:
commit
b82a823bd5
@ -1,6 +0,0 @@
|
|||||||
---
|
|
||||||
ignore:
|
|
||||||
# devise-two-factor advisory about brute-forcing TOTP
|
|
||||||
# We have rate-limits on authentication endpoints in place (including second
|
|
||||||
# factor verification) since Mastodon v3.2.0
|
|
||||||
- CVE-2024-0227
|
|
30
.eslintrc.js
30
.eslintrc.js
@ -20,10 +20,6 @@ module.exports = defineConfig({
|
|||||||
es6: true,
|
es6: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
globals: {
|
|
||||||
ATTACHMENT_HOST: false,
|
|
||||||
},
|
|
||||||
|
|
||||||
parser: '@typescript-eslint/parser',
|
parser: '@typescript-eslint/parser',
|
||||||
|
|
||||||
plugins: [
|
plugins: [
|
||||||
@ -79,7 +75,7 @@ module.exports = defineConfig({
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
'no-empty': 'off',
|
'no-empty': ['error', { "allowEmptyCatch": true }],
|
||||||
'no-restricted-properties': [
|
'no-restricted-properties': [
|
||||||
'error',
|
'error',
|
||||||
{ property: 'substring', message: 'Use .slice instead of .substring.' },
|
{ property: 'substring', message: 'Use .slice instead of .substring.' },
|
||||||
@ -94,7 +90,6 @@ module.exports = defineConfig({
|
|||||||
message: "Use '·' (middle dot) instead of '•' (bullet)",
|
message: "Use '·' (middle dot) instead of '•' (bullet)",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
'no-self-assign': 'off',
|
|
||||||
'no-unused-expressions': 'error',
|
'no-unused-expressions': 'error',
|
||||||
'no-unused-vars': 'off',
|
'no-unused-vars': 'off',
|
||||||
'@typescript-eslint/no-unused-vars': [
|
'@typescript-eslint/no-unused-vars': [
|
||||||
@ -119,12 +114,10 @@ module.exports = defineConfig({
|
|||||||
'react/jsx-tag-spacing': 'error',
|
'react/jsx-tag-spacing': 'error',
|
||||||
'react/jsx-uses-react': 'off', // not needed with new JSX transform
|
'react/jsx-uses-react': 'off', // not needed with new JSX transform
|
||||||
'react/jsx-wrap-multilines': 'error',
|
'react/jsx-wrap-multilines': 'error',
|
||||||
'react/no-deprecated': 'off',
|
|
||||||
'react/react-in-jsx-scope': 'off', // not needed with new JSX transform
|
'react/react-in-jsx-scope': 'off', // not needed with new JSX transform
|
||||||
'react/self-closing-comp': 'error',
|
'react/self-closing-comp': 'error',
|
||||||
|
|
||||||
// recommended values found in https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/v6.8.0/src/index.js#L46
|
// recommended values found in https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/v6.8.0/src/index.js#L46
|
||||||
'jsx-a11y/accessible-emoji': 'warn',
|
|
||||||
'jsx-a11y/click-events-have-key-events': 'off',
|
'jsx-a11y/click-events-have-key-events': 'off',
|
||||||
'jsx-a11y/label-has-associated-control': 'off',
|
'jsx-a11y/label-has-associated-control': 'off',
|
||||||
'jsx-a11y/media-has-caption': 'off',
|
'jsx-a11y/media-has-caption': 'off',
|
||||||
@ -139,23 +132,6 @@ module.exports = defineConfig({
|
|||||||
// ],
|
// ],
|
||||||
'jsx-a11y/no-interactive-element-to-noninteractive-role': 'off',
|
'jsx-a11y/no-interactive-element-to-noninteractive-role': 'off',
|
||||||
// recommended rule is:
|
// recommended rule is:
|
||||||
// 'jsx-a11y/no-noninteractive-element-interactions': [
|
|
||||||
// 'error',
|
|
||||||
// {
|
|
||||||
// body: ['onError', 'onLoad'],
|
|
||||||
// iframe: ['onError', 'onLoad'],
|
|
||||||
// img: ['onError', 'onLoad'],
|
|
||||||
// },
|
|
||||||
// ],
|
|
||||||
'jsx-a11y/no-noninteractive-element-interactions': [
|
|
||||||
'warn',
|
|
||||||
{
|
|
||||||
handlers: [
|
|
||||||
'onClick',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
// recommended rule is:
|
|
||||||
// 'jsx-a11y/no-noninteractive-tabindex': [
|
// 'jsx-a11y/no-noninteractive-tabindex': [
|
||||||
// 'error',
|
// 'error',
|
||||||
// {
|
// {
|
||||||
@ -165,7 +141,6 @@ module.exports = defineConfig({
|
|||||||
// },
|
// },
|
||||||
// ],
|
// ],
|
||||||
'jsx-a11y/no-noninteractive-tabindex': 'off',
|
'jsx-a11y/no-noninteractive-tabindex': 'off',
|
||||||
'jsx-a11y/no-onchange': 'off',
|
|
||||||
// recommended is full 'error'
|
// recommended is full 'error'
|
||||||
'jsx-a11y/no-static-element-interactions': [
|
'jsx-a11y/no-static-element-interactions': [
|
||||||
'warn',
|
'warn',
|
||||||
@ -366,6 +341,9 @@ module.exports = defineConfig({
|
|||||||
// Disable formatting rules that have been enabled in the base config
|
// Disable formatting rules that have been enabled in the base config
|
||||||
'indent': 'off',
|
'indent': 'off',
|
||||||
|
|
||||||
|
// This is not needed as we use noImplicitReturns, which handles this in addition to understanding types
|
||||||
|
'consistent-return': 'off',
|
||||||
|
|
||||||
'import/consistent-type-specifier-style': ['error', 'prefer-top-level'],
|
'import/consistent-type-specifier-style': ['error', 'prefer-top-level'],
|
||||||
|
|
||||||
'@typescript-eslint/consistent-type-definitions': ['warn', 'interface'],
|
'@typescript-eslint/consistent-type-definitions': ['warn', 'interface'],
|
||||||
|
13
.github/workflows/bundler-audit.yml
vendored
13
.github/workflows/bundler-audit.yml
vendored
@ -6,14 +6,12 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- 'Gemfile*'
|
- 'Gemfile*'
|
||||||
- '.ruby-version'
|
- '.ruby-version'
|
||||||
- '.bundler-audit.yml'
|
|
||||||
- '.github/workflows/bundler-audit.yml'
|
- '.github/workflows/bundler-audit.yml'
|
||||||
|
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- 'Gemfile*'
|
- 'Gemfile*'
|
||||||
- '.ruby-version'
|
- '.ruby-version'
|
||||||
- '.bundler-audit.yml'
|
|
||||||
- '.github/workflows/bundler-audit.yml'
|
- '.github/workflows/bundler-audit.yml'
|
||||||
|
|
||||||
schedule:
|
schedule:
|
||||||
@ -23,12 +21,17 @@ jobs:
|
|||||||
security:
|
security:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
env:
|
||||||
|
BUNDLE_ONLY: development
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Clone repository
|
- name: Clone repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Ruby environment
|
- name: Set up Ruby
|
||||||
uses: ./.github/actions/setup-ruby
|
uses: ruby/setup-ruby@v1
|
||||||
|
with:
|
||||||
|
bundler-cache: true
|
||||||
|
|
||||||
- name: Run bundler-audit
|
- name: Run bundler-audit
|
||||||
run: bundle exec bundler-audit
|
run: bundle exec bundler-audit check --update
|
||||||
|
10
.github/workflows/lint-haml.yml
vendored
10
.github/workflows/lint-haml.yml
vendored
@ -26,12 +26,18 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
env:
|
||||||
|
BUNDLE_ONLY: development
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Clone repository
|
- name: Clone repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Ruby environment
|
- name: Set up Ruby
|
||||||
uses: ./.github/actions/setup-ruby
|
uses: ruby/setup-ruby@v1
|
||||||
|
with:
|
||||||
|
bundler-cache: true
|
||||||
|
|
||||||
- name: Run haml-lint
|
- name: Run haml-lint
|
||||||
run: |
|
run: |
|
||||||
|
13
.github/workflows/lint-ruby.yml
vendored
13
.github/workflows/lint-ruby.yml
vendored
@ -27,19 +27,24 @@ jobs:
|
|||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
env:
|
||||||
|
BUNDLE_ONLY: development
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Clone repository
|
- name: Clone repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Ruby environment
|
- name: Set up Ruby
|
||||||
uses: ./.github/actions/setup-ruby
|
uses: ruby/setup-ruby@v1
|
||||||
|
with:
|
||||||
|
bundler-cache: true
|
||||||
|
|
||||||
- name: Set-up RuboCop Problem Matcher
|
- name: Set-up RuboCop Problem Matcher
|
||||||
uses: r7kamura/rubocop-problem-matchers-action@v1
|
uses: r7kamura/rubocop-problem-matchers-action@v1
|
||||||
|
|
||||||
- name: Run rubocop
|
- name: Run rubocop
|
||||||
run: bundle exec rubocop
|
run: bin/rubocop
|
||||||
|
|
||||||
- name: Run brakeman
|
- name: Run brakeman
|
||||||
if: always() # Run both checks, even if the first failed
|
if: always() # Run both checks, even if the first failed
|
||||||
run: bundle exec brakeman
|
run: bin/brakeman
|
||||||
|
95
.github/workflows/test-migrations-two-step.yml
vendored
95
.github/workflows/test-migrations-two-step.yml
vendored
@ -1,95 +0,0 @@
|
|||||||
name: Test two step migrations
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches-ignore:
|
|
||||||
- 'dependabot/**'
|
|
||||||
- 'renovate/**'
|
|
||||||
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'
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
|
|
||||||
matrix:
|
|
||||||
postgres:
|
|
||||||
- 14-alpine
|
|
||||||
- 15-alpine
|
|
||||||
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres:${{ matrix.postgres}}
|
|
||||||
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@v4
|
|
||||||
|
|
||||||
- name: Set up Ruby environment
|
|
||||||
uses: ./.github/actions/setup-ruby
|
|
||||||
|
|
||||||
- name: Create database
|
|
||||||
run: './bin/rails db:create'
|
|
||||||
|
|
||||||
- name: Run historical migrations with data population
|
|
||||||
run: './bin/rails tests:migrations:prepare_database'
|
|
||||||
env:
|
|
||||||
SKIP_POST_DEPLOYMENT_MIGRATIONS: true
|
|
||||||
|
|
||||||
- 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'
|
|
@ -1,4 +1,5 @@
|
|||||||
name: Test one step migrations
|
name: Historical data migration test
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches-ignore:
|
branches-ignore:
|
||||||
@ -17,7 +18,7 @@ jobs:
|
|||||||
- id: skip_check
|
- id: skip_check
|
||||||
uses: fkirc/skip-duplicate-actions@v5
|
uses: fkirc/skip-duplicate-actions@v5
|
||||||
with:
|
with:
|
||||||
paths: '["Gemfile*", ".ruby-version", "**/*.rb", ".github/workflows/test-migrations-one-step.yml", "lib/tasks/tests.rake"]'
|
paths: '["Gemfile*", ".ruby-version", "**/*.rb", ".github/workflows/test-migrations.yml", "lib/tasks/tests.rake"]'
|
||||||
|
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@ -40,9 +41,9 @@ jobs:
|
|||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd pg_isready
|
--health-cmd pg_isready
|
||||||
--health-interval 10s
|
--health-interval 10ms
|
||||||
--health-timeout 5s
|
--health-timeout 3s
|
||||||
--health-retries 5
|
--health-retries 50
|
||||||
ports:
|
ports:
|
||||||
- 5432:5432
|
- 5432:5432
|
||||||
|
|
||||||
@ -50,14 +51,13 @@ jobs:
|
|||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd "redis-cli ping"
|
--health-cmd "redis-cli ping"
|
||||||
--health-interval 10s
|
--health-interval 10ms
|
||||||
--health-timeout 5s
|
--health-timeout 3s
|
||||||
--health-retries 5
|
--health-retries 50
|
||||||
ports:
|
ports:
|
||||||
- 6379:6379
|
- 6379:6379
|
||||||
|
|
||||||
env:
|
env:
|
||||||
CONTINUOUS_INTEGRATION: true
|
|
||||||
DB_HOST: localhost
|
DB_HOST: localhost
|
||||||
DB_USER: postgres
|
DB_USER: postgres
|
||||||
DB_PASS: postgres
|
DB_PASS: postgres
|
||||||
@ -65,7 +65,7 @@ jobs:
|
|||||||
RAILS_ENV: test
|
RAILS_ENV: test
|
||||||
BUNDLE_CLEAN: true
|
BUNDLE_CLEAN: true
|
||||||
BUNDLE_FROZEN: true
|
BUNDLE_FROZEN: true
|
||||||
BUNDLE_WITHOUT: 'development production'
|
BUNDLE_WITHOUT: 'development:production'
|
||||||
BUNDLE_JOBS: 3
|
BUNDLE_JOBS: 3
|
||||||
BUNDLE_RETRY: 3
|
BUNDLE_RETRY: 3
|
||||||
|
|
||||||
@ -75,14 +75,19 @@ jobs:
|
|||||||
- name: Set up Ruby environment
|
- name: Set up Ruby environment
|
||||||
uses: ./.github/actions/setup-ruby
|
uses: ./.github/actions/setup-ruby
|
||||||
|
|
||||||
- name: Create database
|
- name: Test "one step migration" flow
|
||||||
run: './bin/rails db:create'
|
run: |
|
||||||
|
bin/rails db:drop
|
||||||
|
bin/rails db:create
|
||||||
|
bin/rails tests:migrations:prepare_database
|
||||||
|
bin/rails db:migrate
|
||||||
|
bin/rails tests:migrations:check_database
|
||||||
|
|
||||||
- name: Run historical migrations with data population
|
- name: Test "two step migration" flow
|
||||||
run: './bin/rails tests:migrations:prepare_database'
|
run: |
|
||||||
|
bin/rails db:drop
|
||||||
- name: Run all remaining migrations
|
bin/rails db:create
|
||||||
run: './bin/rails db:migrate'
|
SKIP_POST_DEPLOYMENT_MIGRATIONS=true bin/rails tests:migrations:prepare_database
|
||||||
|
SKIP_POST_DEPLOYMENT_MIGRATIONS=true bin/rails db:migrate
|
||||||
- name: Check migration result
|
bin/rails db:migrate
|
||||||
run: './bin/rails tests:migrations:check_database'
|
bin/rails tests:migrations:check_database
|
82
.github/workflows/test-ruby.yml
vendored
82
.github/workflows/test-ruby.yml
vendored
@ -28,11 +28,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
RAILS_ENV: ${{ matrix.mode }}
|
RAILS_ENV: ${{ matrix.mode }}
|
||||||
BUNDLE_WITH: ${{ matrix.mode }}
|
BUNDLE_WITH: ${{ matrix.mode }}
|
||||||
ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY: precompile_placeholder
|
SECRET_KEY_BASE_DUMMY: 1
|
||||||
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT: precompile_placeholder
|
|
||||||
ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY: precompile_placeholder
|
|
||||||
OTP_SECRET: precompile_placeholder
|
|
||||||
SECRET_KEY_BASE: precompile_placeholder
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@ -77,9 +73,9 @@ jobs:
|
|||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd pg_isready
|
--health-cmd pg_isready
|
||||||
--health-interval 10s
|
--health-interval 10ms
|
||||||
--health-timeout 5s
|
--health-timeout 3s
|
||||||
--health-retries 5
|
--health-retries 50
|
||||||
ports:
|
ports:
|
||||||
- 5432:5432
|
- 5432:5432
|
||||||
|
|
||||||
@ -87,9 +83,9 @@ jobs:
|
|||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd "redis-cli ping"
|
--health-cmd "redis-cli ping"
|
||||||
--health-interval 10s
|
--health-interval 10ms
|
||||||
--health-timeout 5s
|
--health-timeout 3s
|
||||||
--health-retries 5
|
--health-retries 50
|
||||||
ports:
|
ports:
|
||||||
- 6379:6379
|
- 6379:6379
|
||||||
|
|
||||||
@ -136,15 +132,17 @@ jobs:
|
|||||||
additional-system-dependencies: ffmpeg libpam-dev
|
additional-system-dependencies: ffmpeg libpam-dev
|
||||||
|
|
||||||
- name: Load database schema
|
- name: Load database schema
|
||||||
run: './bin/rails db:create db:schema:load db:seed'
|
run: |
|
||||||
|
bin/rails db:setup
|
||||||
|
bin/flatware fan bin/rails db:test:prepare
|
||||||
|
|
||||||
- run: bin/rspec
|
- run: bin/flatware rspec -r ./spec/flatware_helper.rb
|
||||||
|
|
||||||
- name: Upload coverage reports to Codecov
|
- name: Upload coverage reports to Codecov
|
||||||
if: matrix.ruby-version == '.ruby-version'
|
if: matrix.ruby-version == '.ruby-version'
|
||||||
uses: codecov/codecov-action@v4
|
uses: codecov/codecov-action@v4
|
||||||
with:
|
with:
|
||||||
files: coverage/lcov/mastodon.lcov
|
files: coverage/lcov/*.lcov
|
||||||
env:
|
env:
|
||||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|
||||||
@ -163,9 +161,9 @@ jobs:
|
|||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd pg_isready
|
--health-cmd pg_isready
|
||||||
--health-interval 10s
|
--health-interval 10ms
|
||||||
--health-timeout 5s
|
--health-timeout 3s
|
||||||
--health-retries 5
|
--health-retries 50
|
||||||
ports:
|
ports:
|
||||||
- 5432:5432
|
- 5432:5432
|
||||||
|
|
||||||
@ -173,9 +171,9 @@ jobs:
|
|||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd "redis-cli ping"
|
--health-cmd "redis-cli ping"
|
||||||
--health-interval 10s
|
--health-interval 10ms
|
||||||
--health-timeout 5s
|
--health-timeout 3s
|
||||||
--health-retries 5
|
--health-retries 50
|
||||||
ports:
|
ports:
|
||||||
- 6379:6379
|
- 6379:6379
|
||||||
|
|
||||||
@ -250,9 +248,9 @@ jobs:
|
|||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd pg_isready
|
--health-cmd pg_isready
|
||||||
--health-interval 10s
|
--health-interval 10ms
|
||||||
--health-timeout 5s
|
--health-timeout 3s
|
||||||
--health-retries 5
|
--health-retries 50
|
||||||
ports:
|
ports:
|
||||||
- 5432:5432
|
- 5432:5432
|
||||||
|
|
||||||
@ -260,9 +258,9 @@ jobs:
|
|||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd "redis-cli ping"
|
--health-cmd "redis-cli ping"
|
||||||
--health-interval 10s
|
--health-interval 10ms
|
||||||
--health-timeout 5s
|
--health-timeout 3s
|
||||||
--health-retries 5
|
--health-retries 50
|
||||||
ports:
|
ports:
|
||||||
- 6379:6379
|
- 6379:6379
|
||||||
|
|
||||||
@ -289,9 +287,13 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/download-artifact@v4
|
- uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
path: './public'
|
path: './'
|
||||||
name: ${{ github.sha }}
|
name: ${{ github.sha }}
|
||||||
|
|
||||||
|
- name: Expand archived asset artifacts
|
||||||
|
run: |
|
||||||
|
tar xvzf artifacts.tar.gz
|
||||||
|
|
||||||
- name: Set up Ruby environment
|
- name: Set up Ruby environment
|
||||||
uses: ./.github/actions/setup-ruby
|
uses: ./.github/actions/setup-ruby
|
||||||
with:
|
with:
|
||||||
@ -335,9 +337,9 @@ jobs:
|
|||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd pg_isready
|
--health-cmd pg_isready
|
||||||
--health-interval 10s
|
--health-interval 10ms
|
||||||
--health-timeout 5s
|
--health-timeout 3s
|
||||||
--health-retries 5
|
--health-retries 50
|
||||||
ports:
|
ports:
|
||||||
- 5432:5432
|
- 5432:5432
|
||||||
|
|
||||||
@ -345,9 +347,9 @@ jobs:
|
|||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd "redis-cli ping"
|
--health-cmd "redis-cli ping"
|
||||||
--health-interval 10s
|
--health-interval 10ms
|
||||||
--health-timeout 5s
|
--health-timeout 3s
|
||||||
--health-retries 5
|
--health-retries 50
|
||||||
ports:
|
ports:
|
||||||
- 6379:6379
|
- 6379:6379
|
||||||
|
|
||||||
@ -358,9 +360,9 @@ jobs:
|
|||||||
xpack.security.enabled: false
|
xpack.security.enabled: false
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd "curl http://localhost:9200/_cluster/health"
|
--health-cmd "curl http://localhost:9200/_cluster/health"
|
||||||
--health-interval 10s
|
--health-interval 2s
|
||||||
--health-timeout 5s
|
--health-timeout 3s
|
||||||
--health-retries 10
|
--health-retries 50
|
||||||
ports:
|
ports:
|
||||||
- 9200:9200
|
- 9200:9200
|
||||||
|
|
||||||
@ -372,9 +374,9 @@ jobs:
|
|||||||
DISABLE_SECURITY_PLUGIN: true
|
DISABLE_SECURITY_PLUGIN: true
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd "curl http://localhost:9200/_cluster/health"
|
--health-cmd "curl http://localhost:9200/_cluster/health"
|
||||||
--health-interval 10s
|
--health-interval 2s
|
||||||
--health-timeout 5s
|
--health-timeout 3s
|
||||||
--health-retries 10
|
--health-retries 50
|
||||||
ports:
|
ports:
|
||||||
- 9200:9200
|
- 9200:9200
|
||||||
|
|
||||||
@ -409,7 +411,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/download-artifact@v4
|
- uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
path: './public'
|
path: './'
|
||||||
name: ${{ github.sha }}
|
name: ${{ github.sha }}
|
||||||
|
|
||||||
- name: Set up Ruby environment
|
- name: Set up Ruby environment
|
||||||
|
252
.rubocop.yml
252
.rubocop.yml
@ -1,7 +1,27 @@
|
|||||||
# Can be removed once all rules are addressed or moved to this file as documented overrides
|
---
|
||||||
inherit_from: .rubocop_todo.yml
|
AllCops:
|
||||||
|
CacheRootDirectory: tmp
|
||||||
|
DisplayStyleGuide: true
|
||||||
|
Exclude:
|
||||||
|
- Vagrantfile
|
||||||
|
- config/initializers/json_ld*
|
||||||
|
- lib/mastodon/migration_helpers.rb
|
||||||
|
ExtraDetails: true
|
||||||
|
NewCops: enable
|
||||||
|
TargetRubyVersion: 3.1 # Oldest supported ruby version
|
||||||
|
|
||||||
|
inherit_from:
|
||||||
|
- .rubocop/layout.yml
|
||||||
|
- .rubocop/metrics.yml
|
||||||
|
- .rubocop/naming.yml
|
||||||
|
- .rubocop/rails.yml
|
||||||
|
- .rubocop/rspec_rails.yml
|
||||||
|
- .rubocop/rspec.yml
|
||||||
|
- .rubocop/style.yml
|
||||||
|
- .rubocop/custom.yml
|
||||||
|
- .rubocop_todo.yml
|
||||||
|
- .rubocop/strict.yml
|
||||||
|
|
||||||
# Used for merging with exclude lists with .rubocop_todo.yml
|
|
||||||
inherit_mode:
|
inherit_mode:
|
||||||
merge:
|
merge:
|
||||||
- Exclude
|
- Exclude
|
||||||
@ -12,229 +32,3 @@ require:
|
|||||||
- rubocop-rspec_rails
|
- rubocop-rspec_rails
|
||||||
- rubocop-performance
|
- rubocop-performance
|
||||||
- rubocop-capybara
|
- rubocop-capybara
|
||||||
- ./lib/linter/rubocop_middle_dot
|
|
||||||
|
|
||||||
AllCops:
|
|
||||||
TargetRubyVersion: 3.1 # Set to minimum supported version of CI
|
|
||||||
DisplayCopNames: true
|
|
||||||
DisplayStyleGuide: true
|
|
||||||
ExtraDetails: true
|
|
||||||
UseCache: true
|
|
||||||
CacheRootDirectory: tmp
|
|
||||||
NewCops: enable # Opt-in to newly added rules
|
|
||||||
Exclude:
|
|
||||||
- db/schema.rb
|
|
||||||
- 'bin/*'
|
|
||||||
- 'node_modules/**/*'
|
|
||||||
- 'Vagrantfile'
|
|
||||||
- 'vendor/**/*'
|
|
||||||
- 'config/initializers/json_ld*' # Generated files
|
|
||||||
- 'lib/mastodon/migration_helpers.rb' # Vendored from GitLab
|
|
||||||
- 'lib/templates/**/*'
|
|
||||||
|
|
||||||
# Reason: Prefer Hashes without extreme indentation
|
|
||||||
# https://docs.rubocop.org/rubocop/cops_layout.html#layoutfirsthashelementindentation
|
|
||||||
Layout/FirstHashElementIndentation:
|
|
||||||
EnforcedStyle: consistent
|
|
||||||
|
|
||||||
# Reason: Currently disabled in .rubocop_todo.yml
|
|
||||||
# https://docs.rubocop.org/rubocop/cops_layout.html#layoutlinelength
|
|
||||||
Layout/LineLength:
|
|
||||||
Max: 300 # Default of 120 causes a duplicate entry in generated todo file
|
|
||||||
|
|
||||||
## Disable most Metrics/*Length cops
|
|
||||||
# Reason: those are often triggered and force significant refactors when this happend
|
|
||||||
# but the team feel they are not really improving the code quality.
|
|
||||||
|
|
||||||
# https://docs.rubocop.org/rubocop/cops_metrics.html#metricsblocklength
|
|
||||||
Metrics/BlockLength:
|
|
||||||
Enabled: false
|
|
||||||
|
|
||||||
# https://docs.rubocop.org/rubocop/cops_metrics.html#metricsclasslength
|
|
||||||
Metrics/ClassLength:
|
|
||||||
Enabled: false
|
|
||||||
|
|
||||||
# https://docs.rubocop.org/rubocop/cops_metrics.html#metricsmethodlength
|
|
||||||
Metrics/MethodLength:
|
|
||||||
Enabled: false
|
|
||||||
|
|
||||||
# https://docs.rubocop.org/rubocop/cops_metrics.html#metricsmodulelength
|
|
||||||
Metrics/ModuleLength:
|
|
||||||
Enabled: false
|
|
||||||
|
|
||||||
## End Disable Metrics/*Length cops
|
|
||||||
|
|
||||||
# Reason: Currently disabled in .rubocop_todo.yml
|
|
||||||
# https://docs.rubocop.org/rubocop/cops_metrics.html#metricsabcsize
|
|
||||||
Metrics/AbcSize:
|
|
||||||
Exclude:
|
|
||||||
- 'lib/mastodon/cli/*.rb'
|
|
||||||
|
|
||||||
# Reason: Currently disabled in .rubocop_todo.yml
|
|
||||||
# https://docs.rubocop.org/rubocop/cops_metrics.html#metricscyclomaticcomplexity
|
|
||||||
Metrics/CyclomaticComplexity:
|
|
||||||
Exclude:
|
|
||||||
- lib/mastodon/cli/*.rb
|
|
||||||
|
|
||||||
# Reason:
|
|
||||||
# https://docs.rubocop.org/rubocop/cops_metrics.html#metricsparameterlists
|
|
||||||
Metrics/ParameterLists:
|
|
||||||
CountKeywordArgs: false
|
|
||||||
|
|
||||||
# Reason: Prefer seeing a variable name
|
|
||||||
# https://docs.rubocop.org/rubocop/cops_naming.html#namingblockforwarding
|
|
||||||
Naming/BlockForwarding:
|
|
||||||
EnforcedStyle: explicit
|
|
||||||
|
|
||||||
# Reason: Prevailing style is argument file paths
|
|
||||||
# https://docs.rubocop.org/rubocop-rails/cops_rails.html#railsfilepath
|
|
||||||
Rails/FilePath:
|
|
||||||
EnforcedStyle: arguments
|
|
||||||
|
|
||||||
# Reason: Prevailing style uses numeric status codes, matches RSpec/Rails/HttpStatus
|
|
||||||
# https://docs.rubocop.org/rubocop-rails/cops_rails.html#railshttpstatus
|
|
||||||
Rails/HttpStatus:
|
|
||||||
EnforcedStyle: numeric
|
|
||||||
|
|
||||||
# Reason: Conflicts with `Lint/UselessMethodDefinition` for inherited controller actions
|
|
||||||
# https://docs.rubocop.org/rubocop-rails/cops_rails.html#railslexicallyscopedactionfilter
|
|
||||||
Rails/LexicallyScopedActionFilter:
|
|
||||||
Exclude:
|
|
||||||
- 'app/controllers/auth/*'
|
|
||||||
|
|
||||||
# Reason: These tasks are doing local work which do not need full env loaded
|
|
||||||
# https://docs.rubocop.org/rubocop-rails/cops_rails.html#railsrakeenvironment
|
|
||||||
Rails/RakeEnvironment:
|
|
||||||
Exclude:
|
|
||||||
- 'lib/tasks/auto_annotate_models.rake'
|
|
||||||
- 'lib/tasks/emojis.rake'
|
|
||||||
- 'lib/tasks/mastodon.rake'
|
|
||||||
- 'lib/tasks/repo.rake'
|
|
||||||
- 'lib/tasks/statistics.rake'
|
|
||||||
|
|
||||||
# Reason: There are appropriate times to use these features
|
|
||||||
# https://docs.rubocop.org/rubocop-rails/cops_rails.html#railsskipsmodelvalidations
|
|
||||||
Rails/SkipsModelValidations:
|
|
||||||
Enabled: false
|
|
||||||
|
|
||||||
# Reason: We want to preserve the ability to migrate from arbitrary old versions,
|
|
||||||
# and cannot guarantee that every installation has run every migration as they upgrade.
|
|
||||||
# https://docs.rubocop.org/rubocop-rails/cops_rails.html#railsunusedignoredcolumns
|
|
||||||
Rails/UnusedIgnoredColumns:
|
|
||||||
Enabled: false
|
|
||||||
|
|
||||||
# Reason: Prevailing style choice
|
|
||||||
# https://docs.rubocop.org/rubocop-rails/cops_rails.html#railsnegateinclude
|
|
||||||
Rails/NegateInclude:
|
|
||||||
Enabled: false
|
|
||||||
|
|
||||||
# Reason: Enforce default limit, but allow some elements to span lines
|
|
||||||
# https://docs.rubocop.org/rubocop-rspec/cops_rspec.html#rspecexamplelength
|
|
||||||
RSpec/ExampleLength:
|
|
||||||
CountAsOne: ['array', 'heredoc', 'method_call']
|
|
||||||
|
|
||||||
# Reason: Deprecated cop, will be removed in 3.0, replaced by SpecFilePathFormat
|
|
||||||
# https://docs.rubocop.org/rubocop-rspec/cops_rspec.html#rspecfilepath
|
|
||||||
RSpec/FilePath:
|
|
||||||
Enabled: false
|
|
||||||
|
|
||||||
# Reason:
|
|
||||||
# https://docs.rubocop.org/rubocop-rspec/cops_rspec.html#rspecnamedsubject
|
|
||||||
RSpec/NamedSubject:
|
|
||||||
EnforcedStyle: named_only
|
|
||||||
|
|
||||||
# Reason: Prevailing style choice
|
|
||||||
# https://docs.rubocop.org/rubocop-rspec/cops_rspec.html#rspecnottonot
|
|
||||||
RSpec/NotToNot:
|
|
||||||
EnforcedStyle: to_not
|
|
||||||
|
|
||||||
# Reason: Match overrides from Rspec/FilePath rule above
|
|
||||||
# https://docs.rubocop.org/rubocop-rspec/cops_rspec.html#rspecspecfilepathformat
|
|
||||||
RSpec/SpecFilePathFormat:
|
|
||||||
CustomTransform:
|
|
||||||
ActivityPub: activitypub
|
|
||||||
DeepL: deepl
|
|
||||||
FetchOEmbedService: fetch_oembed_service
|
|
||||||
OEmbedController: oembed_controller
|
|
||||||
OStatus: ostatus
|
|
||||||
|
|
||||||
# Reason: Prevailing style uses numeric status codes, matches Rails/HttpStatus
|
|
||||||
# https://docs.rubocop.org/rubocop-rspec/cops_rspec_rails.html#rspecrailshttpstatus
|
|
||||||
RSpecRails/HttpStatus:
|
|
||||||
EnforcedStyle: numeric
|
|
||||||
|
|
||||||
# Reason:
|
|
||||||
# https://docs.rubocop.org/rubocop/cops_style.html#styleclassandmodulechildren
|
|
||||||
Style/ClassAndModuleChildren:
|
|
||||||
Enabled: false
|
|
||||||
|
|
||||||
# Reason: Classes mostly self-document with their names
|
|
||||||
# https://docs.rubocop.org/rubocop/cops_style.html#styledocumentation
|
|
||||||
Style/Documentation:
|
|
||||||
Enabled: false
|
|
||||||
|
|
||||||
# Reason: Route redirects are not token-formatted and must be skipped
|
|
||||||
# https://docs.rubocop.org/rubocop/cops_style.html#styleformatstringtoken
|
|
||||||
Style/FormatStringToken:
|
|
||||||
inherit_mode:
|
|
||||||
merge:
|
|
||||||
- AllowedMethods # The rubocop-rails config adds `redirect`
|
|
||||||
AllowedMethods:
|
|
||||||
- redirect_with_vary
|
|
||||||
|
|
||||||
# Reason: Prevailing style choice
|
|
||||||
# https://docs.rubocop.org/rubocop/cops_style.html#stylehashaslastarrayitem
|
|
||||||
Style/HashAsLastArrayItem:
|
|
||||||
Enabled: false
|
|
||||||
|
|
||||||
# Reason: Enforce modern Ruby style
|
|
||||||
# https://docs.rubocop.org/rubocop/cops_style.html#stylehashsyntax
|
|
||||||
Style/HashSyntax:
|
|
||||||
EnforcedStyle: ruby19_no_mixed_keys
|
|
||||||
EnforcedShorthandSyntax: either
|
|
||||||
|
|
||||||
# Reason:
|
|
||||||
# https://docs.rubocop.org/rubocop/cops_style.html#stylenumericliterals
|
|
||||||
Style/NumericLiterals:
|
|
||||||
AllowedPatterns:
|
|
||||||
- \d{4}_\d{2}_\d{2}_\d{6} # For DB migration date version number readability
|
|
||||||
|
|
||||||
# Reason:
|
|
||||||
# https://docs.rubocop.org/rubocop/cops_style.html#stylepercentliteraldelimiters
|
|
||||||
Style/PercentLiteralDelimiters:
|
|
||||||
PreferredDelimiters:
|
|
||||||
'%i': '()'
|
|
||||||
'%w': '()'
|
|
||||||
|
|
||||||
# Reason: Prefer less indentation in conditional assignments
|
|
||||||
# https://docs.rubocop.org/rubocop/cops_style.html#styleredundantbegin
|
|
||||||
Style/RedundantBegin:
|
|
||||||
Enabled: false
|
|
||||||
|
|
||||||
# Reason: Prevailing style choice
|
|
||||||
# https://docs.rubocop.org/rubocop/cops_style.html#styleredundantfetchblock
|
|
||||||
Style/RedundantFetchBlock:
|
|
||||||
Enabled: false
|
|
||||||
|
|
||||||
# Reason: Overridden to reduce implicit StandardError rescues
|
|
||||||
# https://docs.rubocop.org/rubocop/cops_style.html#stylerescuestandarderror
|
|
||||||
Style/RescueStandardError:
|
|
||||||
EnforcedStyle: implicit
|
|
||||||
|
|
||||||
# Reason: Originally disabled for CodeClimate, and no config consensus has been found
|
|
||||||
# https://docs.rubocop.org/rubocop/cops_style.html#stylesymbolarray
|
|
||||||
Style/SymbolArray:
|
|
||||||
Enabled: false
|
|
||||||
|
|
||||||
# Reason:
|
|
||||||
# https://docs.rubocop.org/rubocop/cops_style.html#styletrailingcommainarrayliteral
|
|
||||||
Style/TrailingCommaInArrayLiteral:
|
|
||||||
EnforcedStyleForMultiline: 'comma'
|
|
||||||
|
|
||||||
# Reason:
|
|
||||||
# https://docs.rubocop.org/rubocop/cops_style.html#styletrailingcommainhashliteral
|
|
||||||
Style/TrailingCommaInHashLiteral:
|
|
||||||
EnforcedStyleForMultiline: 'comma'
|
|
||||||
|
|
||||||
Style/MiddleDot:
|
|
||||||
Enabled: true
|
|
||||||
|
6
.rubocop/custom.yml
Normal file
6
.rubocop/custom.yml
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
require:
|
||||||
|
- ../lib/linter/rubocop_middle_dot
|
||||||
|
|
||||||
|
Style/MiddleDot:
|
||||||
|
Enabled: true
|
6
.rubocop/layout.yml
Normal file
6
.rubocop/layout.yml
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
Layout/FirstHashElementIndentation:
|
||||||
|
EnforcedStyle: consistent
|
||||||
|
|
||||||
|
Layout/LineLength:
|
||||||
|
Max: 300 # Default of 120 causes a duplicate entry in generated todo file
|
23
.rubocop/metrics.yml
Normal file
23
.rubocop/metrics.yml
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
Metrics/AbcSize:
|
||||||
|
Exclude:
|
||||||
|
- lib/mastodon/cli/*.rb
|
||||||
|
|
||||||
|
Metrics/BlockLength:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Metrics/ClassLength:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Metrics/CyclomaticComplexity:
|
||||||
|
Exclude:
|
||||||
|
- lib/mastodon/cli/*.rb
|
||||||
|
|
||||||
|
Metrics/MethodLength:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Metrics/ModuleLength:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Metrics/ParameterLists:
|
||||||
|
CountKeywordArgs: false
|
3
.rubocop/naming.yml
Normal file
3
.rubocop/naming.yml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
---
|
||||||
|
Naming/BlockForwarding:
|
||||||
|
EnforcedStyle: explicit
|
23
.rubocop/rails.yml
Normal file
23
.rubocop/rails.yml
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
Rails/BulkChangeTable:
|
||||||
|
Enabled: false # Conflicts with strong_migrations features
|
||||||
|
|
||||||
|
Rails/FilePath:
|
||||||
|
EnforcedStyle: arguments
|
||||||
|
|
||||||
|
Rails/HttpStatus:
|
||||||
|
EnforcedStyle: numeric
|
||||||
|
|
||||||
|
Rails/NegateInclude:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Rails/RakeEnvironment:
|
||||||
|
Exclude: # Tasks are doing local work which do not need full env loaded
|
||||||
|
- lib/tasks/auto_annotate_models.rake
|
||||||
|
- lib/tasks/emojis.rake
|
||||||
|
- lib/tasks/mastodon.rake
|
||||||
|
- lib/tasks/repo.rake
|
||||||
|
- lib/tasks/statistics.rake
|
||||||
|
|
||||||
|
Rails/SkipsModelValidations:
|
||||||
|
Enabled: false
|
27
.rubocop/rspec.yml
Normal file
27
.rubocop/rspec.yml
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
RSpec/ExampleLength:
|
||||||
|
CountAsOne: ['array', 'heredoc', 'method_call']
|
||||||
|
Max: 20 # Override default of 5
|
||||||
|
|
||||||
|
RSpec/MultipleExpectations:
|
||||||
|
Max: 10 # Overrides default of 1
|
||||||
|
|
||||||
|
RSpec/MultipleMemoizedHelpers:
|
||||||
|
Max: 20 # Overrides default of 5
|
||||||
|
|
||||||
|
RSpec/NamedSubject:
|
||||||
|
EnforcedStyle: named_only
|
||||||
|
|
||||||
|
RSpec/NestedGroups:
|
||||||
|
Max: 10 # Overrides default of 3
|
||||||
|
|
||||||
|
RSpec/NotToNot:
|
||||||
|
EnforcedStyle: to_not
|
||||||
|
|
||||||
|
RSpec/SpecFilePathFormat:
|
||||||
|
CustomTransform:
|
||||||
|
ActivityPub: activitypub
|
||||||
|
DeepL: deepl
|
||||||
|
FetchOEmbedService: fetch_oembed_service
|
||||||
|
OEmbedController: oembed_controller
|
||||||
|
OStatus: ostatus
|
3
.rubocop/rspec_rails.yml
Normal file
3
.rubocop/rspec_rails.yml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
---
|
||||||
|
RSpecRails/HttpStatus:
|
||||||
|
EnforcedStyle: numeric
|
19
.rubocop/strict.yml
Normal file
19
.rubocop/strict.yml
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
Lint/Debugger: # Remove any `binding.pry`
|
||||||
|
Enabled: true
|
||||||
|
Exclude: []
|
||||||
|
|
||||||
|
RSpec/Focus: # Require full spec run on CI
|
||||||
|
Enabled: true
|
||||||
|
Exclude: []
|
||||||
|
|
||||||
|
Rails/Output: # Remove any `puts` debugging
|
||||||
|
Enabled: true
|
||||||
|
Exclude: []
|
||||||
|
|
||||||
|
Rails/FindEach: # Using `each` could impact performance, use `find_each`
|
||||||
|
Enabled: true
|
||||||
|
Exclude: []
|
||||||
|
|
||||||
|
Rails/UniqBeforePluck: # Require `uniq.pluck` and not `pluck.uniq`
|
||||||
|
Enabled: true
|
||||||
|
Exclude: []
|
47
.rubocop/style.yml
Normal file
47
.rubocop/style.yml
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
---
|
||||||
|
Style/ClassAndModuleChildren:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Style/Documentation:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Style/FormatStringToken:
|
||||||
|
AllowedMethods:
|
||||||
|
- redirect_with_vary # Route redirects are not token-formatted
|
||||||
|
inherit_mode:
|
||||||
|
merge:
|
||||||
|
- AllowedMethods
|
||||||
|
|
||||||
|
Style/HashAsLastArrayItem:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Style/HashSyntax:
|
||||||
|
EnforcedShorthandSyntax: either
|
||||||
|
EnforcedStyle: ruby19_no_mixed_keys
|
||||||
|
|
||||||
|
Style/NumericLiterals:
|
||||||
|
AllowedPatterns:
|
||||||
|
- \d{4}_\d{2}_\d{2}_\d{6}
|
||||||
|
|
||||||
|
Style/PercentLiteralDelimiters:
|
||||||
|
PreferredDelimiters:
|
||||||
|
'%i': ()
|
||||||
|
'%w': ()
|
||||||
|
|
||||||
|
Style/RedundantBegin:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Style/RedundantFetchBlock:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Style/RescueStandardError:
|
||||||
|
EnforcedStyle: implicit
|
||||||
|
|
||||||
|
Style/SymbolArray:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Style/TrailingCommaInArrayLiteral:
|
||||||
|
EnforcedStyleForMultiline: comma
|
||||||
|
|
||||||
|
Style/TrailingCommaInHashLiteral:
|
||||||
|
EnforcedStyleForMultiline: comma
|
@ -1,6 +1,6 @@
|
|||||||
# This configuration was generated by
|
# This configuration was generated by
|
||||||
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-exclude-limit --no-offense-counts --no-auto-gen-timestamp`
|
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp`
|
||||||
# using RuboCop version 1.63.5.
|
# using RuboCop version 1.64.1.
|
||||||
# The point is for the user to remove these configuration records
|
# The point is for the user to remove these configuration records
|
||||||
# one by one as the offenses are removed from the code base.
|
# one by one as the offenses are removed from the code base.
|
||||||
# Note that changes in the inspected code, or installation of new
|
# Note that changes in the inspected code, or installation of new
|
||||||
@ -27,33 +27,10 @@ Metrics/CyclomaticComplexity:
|
|||||||
Metrics/PerceivedComplexity:
|
Metrics/PerceivedComplexity:
|
||||||
Max: 27
|
Max: 27
|
||||||
|
|
||||||
# Configuration parameters: CountAsOne.
|
|
||||||
RSpec/ExampleLength:
|
|
||||||
Max: 18
|
|
||||||
|
|
||||||
RSpec/MultipleExpectations:
|
|
||||||
Max: 7
|
|
||||||
|
|
||||||
# Configuration parameters: AllowSubject.
|
|
||||||
RSpec/MultipleMemoizedHelpers:
|
|
||||||
Max: 17
|
|
||||||
|
|
||||||
# Configuration parameters: AllowedGroups.
|
|
||||||
RSpec/NestedGroups:
|
|
||||||
Max: 6
|
|
||||||
|
|
||||||
Rails/OutputSafety:
|
Rails/OutputSafety:
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'config/initializers/simple_form.rb'
|
- 'config/initializers/simple_form.rb'
|
||||||
|
|
||||||
# This cop supports unsafe autocorrection (--autocorrect-all).
|
|
||||||
# Configuration parameters: AllowedMethods, AllowedPatterns.
|
|
||||||
# AllowedMethods: ==, equal?, eql?
|
|
||||||
Style/ClassEqualityComparison:
|
|
||||||
Exclude:
|
|
||||||
- 'app/helpers/jsonld_helper.rb'
|
|
||||||
- 'app/serializers/activitypub/outbox_serializer.rb'
|
|
||||||
|
|
||||||
# This cop supports safe autocorrection (--autocorrect).
|
# This cop supports safe autocorrection (--autocorrect).
|
||||||
# Configuration parameters: AllowedVars.
|
# Configuration parameters: AllowedVars.
|
||||||
Style/FetchEnvVar:
|
Style/FetchEnvVar:
|
||||||
@ -70,7 +47,6 @@ Style/FetchEnvVar:
|
|||||||
- 'config/initializers/vapid.rb'
|
- 'config/initializers/vapid.rb'
|
||||||
- 'lib/mastodon/redis_config.rb'
|
- 'lib/mastodon/redis_config.rb'
|
||||||
- 'lib/tasks/repo.rake'
|
- 'lib/tasks/repo.rake'
|
||||||
- 'spec/system/profile_spec.rb'
|
|
||||||
|
|
||||||
# This cop supports safe autocorrection (--autocorrect).
|
# This cop supports safe autocorrection (--autocorrect).
|
||||||
# Configuration parameters: EnforcedStyle, MaxUnannotatedPlaceholdersAllowed, AllowedMethods, AllowedPatterns.
|
# Configuration parameters: EnforcedStyle, MaxUnannotatedPlaceholdersAllowed, AllowedMethods, AllowedPatterns.
|
||||||
@ -81,40 +57,10 @@ Style/FormatStringToken:
|
|||||||
- 'config/initializers/devise.rb'
|
- 'config/initializers/devise.rb'
|
||||||
- 'lib/paperclip/color_extractor.rb'
|
- 'lib/paperclip/color_extractor.rb'
|
||||||
|
|
||||||
# This cop supports unsafe autocorrection (--autocorrect-all).
|
|
||||||
Style/GlobalStdStream:
|
|
||||||
Exclude:
|
|
||||||
- 'config/environments/development.rb'
|
|
||||||
- 'config/environments/production.rb'
|
|
||||||
|
|
||||||
# This cop supports safe autocorrection (--autocorrect).
|
# This cop supports safe autocorrection (--autocorrect).
|
||||||
# Configuration parameters: MinBodyLength, AllowConsecutiveConditionals.
|
# Configuration parameters: MinBodyLength, AllowConsecutiveConditionals.
|
||||||
Style/GuardClause:
|
Style/GuardClause:
|
||||||
Exclude:
|
Enabled: false
|
||||||
- 'app/lib/activitypub/activity/block.rb'
|
|
||||||
- 'app/lib/request.rb'
|
|
||||||
- 'app/lib/request_pool.rb'
|
|
||||||
- 'app/lib/webfinger.rb'
|
|
||||||
- 'app/lib/webfinger_resource.rb'
|
|
||||||
- 'app/models/concerns/account/counters.rb'
|
|
||||||
- 'app/models/concerns/user/ldap_authenticable.rb'
|
|
||||||
- 'app/models/tag.rb'
|
|
||||||
- 'app/models/user.rb'
|
|
||||||
- 'app/services/fan_out_on_write_service.rb'
|
|
||||||
- 'app/services/post_status_service.rb'
|
|
||||||
- 'app/services/process_hashtags_service.rb'
|
|
||||||
- 'app/workers/move_worker.rb'
|
|
||||||
- 'app/workers/redownload_avatar_worker.rb'
|
|
||||||
- 'app/workers/redownload_header_worker.rb'
|
|
||||||
- 'app/workers/redownload_media_worker.rb'
|
|
||||||
- 'app/workers/remote_account_refresh_worker.rb'
|
|
||||||
- 'config/initializers/devise.rb'
|
|
||||||
- 'lib/devise/strategies/two_factor_ldap_authenticatable.rb'
|
|
||||||
- 'lib/devise/strategies/two_factor_pam_authenticatable.rb'
|
|
||||||
- 'lib/mastodon/cli/accounts.rb'
|
|
||||||
- 'lib/mastodon/cli/maintenance.rb'
|
|
||||||
- 'lib/mastodon/cli/media.rb'
|
|
||||||
- 'lib/tasks/repo.rake'
|
|
||||||
|
|
||||||
# This cop supports unsafe autocorrection (--autocorrect-all).
|
# This cop supports unsafe autocorrection (--autocorrect-all).
|
||||||
Style/HashTransformValues:
|
Style/HashTransformValues:
|
||||||
@ -136,16 +82,10 @@ Style/MutableConstant:
|
|||||||
- 'app/services/delete_account_service.rb'
|
- 'app/services/delete_account_service.rb'
|
||||||
- 'lib/mastodon/migration_warning.rb'
|
- 'lib/mastodon/migration_warning.rb'
|
||||||
|
|
||||||
# This cop supports safe autocorrection (--autocorrect).
|
|
||||||
Style/NilLambda:
|
|
||||||
Exclude:
|
|
||||||
- 'config/initializers/paperclip.rb'
|
|
||||||
|
|
||||||
# Configuration parameters: AllowedMethods.
|
# Configuration parameters: AllowedMethods.
|
||||||
# AllowedMethods: respond_to_missing?
|
# AllowedMethods: respond_to_missing?
|
||||||
Style/OptionalBooleanParameter:
|
Style/OptionalBooleanParameter:
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'app/helpers/admin/account_moderation_notes_helper.rb'
|
|
||||||
- 'app/helpers/jsonld_helper.rb'
|
- 'app/helpers/jsonld_helper.rb'
|
||||||
- 'app/lib/admin/system_check/message.rb'
|
- 'app/lib/admin/system_check/message.rb'
|
||||||
- 'app/lib/request.rb'
|
- 'app/lib/request.rb'
|
||||||
@ -169,13 +109,6 @@ Style/RedundantConstantBase:
|
|||||||
- 'config/environments/production.rb'
|
- 'config/environments/production.rb'
|
||||||
- 'config/initializers/sidekiq.rb'
|
- 'config/initializers/sidekiq.rb'
|
||||||
|
|
||||||
# This cop supports unsafe autocorrection (--autocorrect-all).
|
|
||||||
# Configuration parameters: ConvertCodeThatCanStartToReturnNil, AllowedMethods, MaxChainLength.
|
|
||||||
# AllowedMethods: present?, blank?, presence, try, try!
|
|
||||||
Style/SafeNavigation:
|
|
||||||
Exclude:
|
|
||||||
- 'app/models/concerns/account/finder_concern.rb'
|
|
||||||
|
|
||||||
# This cop supports safe autocorrection (--autocorrect).
|
# This cop supports safe autocorrection (--autocorrect).
|
||||||
# Configuration parameters: WordRegex.
|
# Configuration parameters: WordRegex.
|
||||||
# SupportedStyles: percent, brackets
|
# SupportedStyles: percent, brackets
|
||||||
|
@ -1 +1 @@
|
|||||||
3.3.2
|
3.3.3
|
||||||
|
31
CHANGELOG.md
31
CHANGELOG.md
@ -2,6 +2,37 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [4.2.10] - 2024-07-04
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Fix incorrect permission checking on multiple API endpoints ([GHSA-58x8-3qxw-6hm7](https://github.com/mastodon/mastodon/security/advisories/GHSA-58x8-3qxw-6hm7))
|
||||||
|
- Fix incorrect authorship checking when processing some activities (CVE-2024-37903, [GHSA-xjvf-fm67-4qc3](https://github.com/mastodon/mastodon/security/advisories/GHSA-xjvf-fm67-4qc3))
|
||||||
|
- Fix ongoing streaming sessions not being invalidated when application tokens get revoked ([GHSA-vp5r-5pgw-jwqx](https://github.com/mastodon/mastodon/security/advisories/GHSA-vp5r-5pgw-jwqx))
|
||||||
|
- Update dependencies
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Add yarn version specification to avoid confusion with Yarn 3 and Yarn 4
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Change preview cards generation to skip unusually long URLs ([oneiros](https://github.com/mastodon/mastodon/pull/30854))
|
||||||
|
- Change search modifiers to be case-insensitive ([Gargron](https://github.com/mastodon/mastodon/pull/30865))
|
||||||
|
- Change `STATSD_ADDR` handling to emit a warning rather than crashing if the address is unreachable ([timothyjrogers](https://github.com/mastodon/mastodon/pull/30691))
|
||||||
|
- Change PWA start URL from `/home` to `/` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27377))
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- Removed dependency on `posix-spawn` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18559))
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fix scheduled statuses scheduled in less than 5 minutes being immediately published ([danielmbrasil](https://github.com/mastodon/mastodon/pull/30584))
|
||||||
|
- Fix encoding detection for link cards ([oneiros](https://github.com/mastodon/mastodon/pull/30780))
|
||||||
|
- Fix `/admin/accounts/:account_id/statuses/:id` for edited posts with media attachments ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/30819))
|
||||||
|
- Fix duplicate `@context` attribute in user archive export ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/30653))
|
||||||
|
|
||||||
## [4.2.9] - 2024-05-30
|
## [4.2.9] - 2024-05-30
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
190
Dockerfile
190
Dockerfile
@ -1,4 +1,4 @@
|
|||||||
# syntax=docker/dockerfile:1.7
|
# syntax=docker/dockerfile:1.8
|
||||||
|
|
||||||
# This file is designed for production server deployment, not local development work
|
# This file is designed for production server deployment, not local development work
|
||||||
# For a containerized local dev environment, see: https://github.com/mastodon/mastodon/blob/main/README.md#docker
|
# For a containerized local dev environment, see: https://github.com/mastodon/mastodon/blob/main/README.md#docker
|
||||||
@ -12,22 +12,22 @@ ARG BUILDPLATFORM=${BUILDPLATFORM}
|
|||||||
|
|
||||||
# Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.3.x"]
|
# Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.3.x"]
|
||||||
# renovate: datasource=docker depName=docker.io/ruby
|
# renovate: datasource=docker depName=docker.io/ruby
|
||||||
ARG RUBY_VERSION="3.3.2"
|
ARG RUBY_VERSION="3.3.3"
|
||||||
# # Node version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"]
|
# # Node version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"]
|
||||||
# renovate: datasource=node-version depName=node
|
# renovate: datasource=node-version depName=node
|
||||||
ARG NODE_MAJOR_VERSION="20"
|
ARG NODE_MAJOR_VERSION="20"
|
||||||
# Debian image to use for base image, change with [--build-arg DEBIAN_VERSION="bookworm"]
|
# Debian image to use for base image, change with [--build-arg DEBIAN_VERSION="bookworm"]
|
||||||
ARG DEBIAN_VERSION="bookworm"
|
ARG DEBIAN_VERSION="bookworm"
|
||||||
# Node image to use for base image based on combined variables (ex: 20-bookworm-slim)
|
# Node image to use for base image based on combined variables (ex: 20-bookworm-slim)
|
||||||
FROM docker.io/node:${NODE_MAJOR_VERSION}-${DEBIAN_VERSION}-slim as node
|
FROM docker.io/node:${NODE_MAJOR_VERSION}-${DEBIAN_VERSION}-slim AS node
|
||||||
# Ruby image to use for base image based on combined variables (ex: 3.3.x-slim-bookworm)
|
# Ruby image to use for base image based on combined variables (ex: 3.3.x-slim-bookworm)
|
||||||
FROM docker.io/ruby:${RUBY_VERSION}-slim-${DEBIAN_VERSION} as ruby
|
FROM docker.io/ruby:${RUBY_VERSION}-slim-${DEBIAN_VERSION} AS ruby
|
||||||
|
|
||||||
# Resulting version string is vX.X.X-MASTODON_VERSION_PRERELEASE+MASTODON_VERSION_METADATA
|
# Resulting version string is vX.X.X-MASTODON_VERSION_PRERELEASE+MASTODON_VERSION_METADATA
|
||||||
# Example: v4.2.0-nightly.2023.11.09+something
|
# Example: v4.3.0-nightly.2023.11.09+pr-123456
|
||||||
# Overwrite existence of 'alpha.0' in version.rb [--build-arg MASTODON_VERSION_PRERELEASE="nightly.2023.11.09"]
|
# Overwrite existence of 'alpha.X' in version.rb [--build-arg MASTODON_VERSION_PRERELEASE="nightly.2023.11.09"]
|
||||||
ARG MASTODON_VERSION_PRERELEASE=""
|
ARG MASTODON_VERSION_PRERELEASE=""
|
||||||
# Append build metadata or fork information to version.rb [--build-arg MASTODON_VERSION_METADATA="pr-12345"]
|
# Append build metadata or fork information to version.rb [--build-arg MASTODON_VERSION_METADATA="pr-123456"]
|
||||||
ARG MASTODON_VERSION_METADATA=""
|
ARG MASTODON_VERSION_METADATA=""
|
||||||
|
|
||||||
# Allow Ruby on Rails to serve static files
|
# Allow Ruby on Rails to serve static files
|
||||||
@ -48,8 +48,6 @@ ENV \
|
|||||||
# Apply Mastodon version information
|
# Apply Mastodon version information
|
||||||
MASTODON_VERSION_PRERELEASE="${MASTODON_VERSION_PRERELEASE}" \
|
MASTODON_VERSION_PRERELEASE="${MASTODON_VERSION_PRERELEASE}" \
|
||||||
MASTODON_VERSION_METADATA="${MASTODON_VERSION_METADATA}" \
|
MASTODON_VERSION_METADATA="${MASTODON_VERSION_METADATA}" \
|
||||||
# Enable libvips
|
|
||||||
MASTODON_USE_LIBVIPS=true \
|
|
||||||
# Apply Mastodon static files and YJIT options
|
# Apply Mastodon static files and YJIT options
|
||||||
RAILS_SERVE_STATIC_FILES=${RAILS_SERVE_STATIC_FILES} \
|
RAILS_SERVE_STATIC_FILES=${RAILS_SERVE_STATIC_FILES} \
|
||||||
RUBY_YJIT_ENABLE=${RUBY_YJIT_ENABLE} \
|
RUBY_YJIT_ENABLE=${RUBY_YJIT_ENABLE} \
|
||||||
@ -67,7 +65,9 @@ ENV \
|
|||||||
DEBIAN_FRONTEND="noninteractive" \
|
DEBIAN_FRONTEND="noninteractive" \
|
||||||
PATH="${PATH}:/opt/ruby/bin:/opt/mastodon/bin" \
|
PATH="${PATH}:/opt/ruby/bin:/opt/mastodon/bin" \
|
||||||
# Optimize jemalloc 5.x performance
|
# Optimize jemalloc 5.x performance
|
||||||
MALLOC_CONF="narenas:2,background_thread:true,thp:never,dirty_decay_ms:1000,muzzy_decay_ms:0"
|
MALLOC_CONF="narenas:2,background_thread:true,thp:never,dirty_decay_ms:1000,muzzy_decay_ms:0" \
|
||||||
|
# Enable libvips, should not be changed
|
||||||
|
MASTODON_USE_LIBVIPS=true
|
||||||
|
|
||||||
# Set default shell used for running commands
|
# Set default shell used for running commands
|
||||||
SHELL ["/bin/bash", "-o", "pipefail", "-o", "errexit", "-c"]
|
SHELL ["/bin/bash", "-o", "pipefail", "-o", "errexit", "-c"]
|
||||||
@ -100,11 +100,8 @@ RUN \
|
|||||||
apt-get dist-upgrade -yq; \
|
apt-get dist-upgrade -yq; \
|
||||||
# Install jemalloc, curl and other necessary components
|
# Install jemalloc, curl and other necessary components
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
ca-certificates \
|
|
||||||
curl \
|
curl \
|
||||||
ffmpeg \
|
|
||||||
file \
|
file \
|
||||||
libvips42 \
|
|
||||||
libjemalloc2 \
|
libjemalloc2 \
|
||||||
patchelf \
|
patchelf \
|
||||||
procps \
|
procps \
|
||||||
@ -120,7 +117,7 @@ RUN \
|
|||||||
;
|
;
|
||||||
|
|
||||||
# Create temporary build layer from base image
|
# Create temporary build layer from base image
|
||||||
FROM ruby as build
|
FROM ruby AS build
|
||||||
|
|
||||||
# Copy Node package configuration files into working directory
|
# Copy Node package configuration files into working directory
|
||||||
COPY package.json yarn.lock .yarnrc.yml /opt/mastodon/
|
COPY package.json yarn.lock .yarnrc.yml /opt/mastodon/
|
||||||
@ -138,18 +135,47 @@ RUN \
|
|||||||
--mount=type=cache,id=apt-lib-${TARGETPLATFORM},target=/var/lib/apt,sharing=locked \
|
--mount=type=cache,id=apt-lib-${TARGETPLATFORM},target=/var/lib/apt,sharing=locked \
|
||||||
# Install build tools and bundler dependencies from APT
|
# Install build tools and bundler dependencies from APT
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
g++ \
|
autoconf \
|
||||||
gcc \
|
automake \
|
||||||
|
build-essential \
|
||||||
|
cmake \
|
||||||
git \
|
git \
|
||||||
libgdbm-dev \
|
libgdbm-dev \
|
||||||
|
libglib2.0-dev \
|
||||||
libgmp-dev \
|
libgmp-dev \
|
||||||
libicu-dev \
|
libicu-dev \
|
||||||
libidn-dev \
|
libidn-dev \
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
libssl-dev \
|
libssl-dev \
|
||||||
make \
|
libtool \
|
||||||
|
meson \
|
||||||
|
nasm \
|
||||||
|
pkg-config \
|
||||||
shared-mime-info \
|
shared-mime-info \
|
||||||
zlib1g-dev \
|
xz-utils \
|
||||||
|
# libvips components
|
||||||
|
libcgif-dev \
|
||||||
|
libexif-dev \
|
||||||
|
libexpat1-dev \
|
||||||
|
libgirepository1.0-dev \
|
||||||
|
libheif-dev \
|
||||||
|
libimagequant-dev \
|
||||||
|
libjpeg62-turbo-dev \
|
||||||
|
liblcms2-dev \
|
||||||
|
liborc-dev \
|
||||||
|
libspng-dev \
|
||||||
|
libtiff-dev \
|
||||||
|
libwebp-dev \
|
||||||
|
# ffmpeg components
|
||||||
|
libdav1d-dev \
|
||||||
|
liblzma-dev \
|
||||||
|
libmp3lame-dev \
|
||||||
|
libopus-dev \
|
||||||
|
libsnappy-dev \
|
||||||
|
libvorbis-dev \
|
||||||
|
libvpx-dev \
|
||||||
|
libx264-dev \
|
||||||
|
libx265-dev \
|
||||||
;
|
;
|
||||||
|
|
||||||
RUN \
|
RUN \
|
||||||
@ -158,8 +184,70 @@ RUN \
|
|||||||
corepack enable; \
|
corepack enable; \
|
||||||
corepack prepare --activate;
|
corepack prepare --activate;
|
||||||
|
|
||||||
|
# Create temporary libvips specific build layer from build layer
|
||||||
|
FROM build AS libvips
|
||||||
|
|
||||||
|
# libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"]
|
||||||
|
# renovate: datasource=github-releases depName=libvips packageName=libvips/libvips
|
||||||
|
ARG VIPS_VERSION=8.15.2
|
||||||
|
# libvips download URL, change with [--build-arg VIPS_URL="https://github.com/libvips/libvips/releases/download"]
|
||||||
|
ARG VIPS_URL=https://github.com/libvips/libvips/releases/download
|
||||||
|
|
||||||
|
WORKDIR /usr/local/libvips/src
|
||||||
|
|
||||||
|
RUN \
|
||||||
|
curl -sSL -o vips-${VIPS_VERSION}.tar.xz ${VIPS_URL}/v${VIPS_VERSION}/vips-${VIPS_VERSION}.tar.xz; \
|
||||||
|
tar xf vips-${VIPS_VERSION}.tar.xz; \
|
||||||
|
cd vips-${VIPS_VERSION}; \
|
||||||
|
meson setup build --prefix /usr/local/libvips --libdir=lib -Ddeprecated=false -Dintrospection=disabled -Dmodules=disabled -Dexamples=false; \
|
||||||
|
cd build; \
|
||||||
|
ninja; \
|
||||||
|
ninja install;
|
||||||
|
|
||||||
|
# Create temporary ffmpeg specific build layer from build layer
|
||||||
|
FROM build AS ffmpeg
|
||||||
|
|
||||||
|
# ffmpeg version to compile, change with [--build-arg FFMPEG_VERSION="7.0.x"]
|
||||||
|
# renovate: datasource=repology depName=ffmpeg packageName=openpkg_current/ffmpeg
|
||||||
|
ARG FFMPEG_VERSION=7.0.1
|
||||||
|
# ffmpeg download URL, change with [--build-arg FFMPEG_URL="https://ffmpeg.org/releases"]
|
||||||
|
ARG FFMPEG_URL=https://ffmpeg.org/releases
|
||||||
|
|
||||||
|
WORKDIR /usr/local/ffmpeg/src
|
||||||
|
|
||||||
|
RUN \
|
||||||
|
curl -sSL -o ffmpeg-${FFMPEG_VERSION}.tar.xz ${FFMPEG_URL}/ffmpeg-${FFMPEG_VERSION}.tar.xz; \
|
||||||
|
tar xf ffmpeg-${FFMPEG_VERSION}.tar.xz; \
|
||||||
|
cd ffmpeg-${FFMPEG_VERSION}; \
|
||||||
|
./configure \
|
||||||
|
--prefix=/usr/local/ffmpeg \
|
||||||
|
--toolchain=hardened \
|
||||||
|
--disable-debug \
|
||||||
|
--disable-devices \
|
||||||
|
--disable-doc \
|
||||||
|
--disable-ffplay \
|
||||||
|
--disable-network \
|
||||||
|
--disable-static \
|
||||||
|
--enable-ffmpeg \
|
||||||
|
--enable-ffprobe \
|
||||||
|
--enable-gpl \
|
||||||
|
--enable-libdav1d \
|
||||||
|
--enable-libmp3lame \
|
||||||
|
--enable-libopus \
|
||||||
|
--enable-libsnappy \
|
||||||
|
--enable-libvorbis \
|
||||||
|
--enable-libvpx \
|
||||||
|
--enable-libwebp \
|
||||||
|
--enable-libx264 \
|
||||||
|
--enable-libx265 \
|
||||||
|
--enable-shared \
|
||||||
|
--enable-version3 \
|
||||||
|
; \
|
||||||
|
make -j$(nproc); \
|
||||||
|
make install;
|
||||||
|
|
||||||
# Create temporary bundler specific build layer from build layer
|
# Create temporary bundler specific build layer from build layer
|
||||||
FROM build as bundler
|
FROM build AS bundler
|
||||||
|
|
||||||
ARG TARGETPLATFORM
|
ARG TARGETPLATFORM
|
||||||
|
|
||||||
@ -181,7 +269,7 @@ RUN \
|
|||||||
bundle install -j"$(nproc)";
|
bundle install -j"$(nproc)";
|
||||||
|
|
||||||
# Create temporary node specific build layer from build layer
|
# Create temporary node specific build layer from build layer
|
||||||
FROM build as yarn
|
FROM build AS yarn
|
||||||
|
|
||||||
ARG TARGETPLATFORM
|
ARG TARGETPLATFORM
|
||||||
|
|
||||||
@ -198,7 +286,7 @@ RUN \
|
|||||||
yarn workspaces focus --production @mastodon/mastodon;
|
yarn workspaces focus --production @mastodon/mastodon;
|
||||||
|
|
||||||
# Create temporary assets build layer from build layer
|
# Create temporary assets build layer from build layer
|
||||||
FROM build as precompiler
|
FROM build AS precompiler
|
||||||
|
|
||||||
# Copy Mastodon sources into precompiler layer
|
# Copy Mastodon sources into precompiler layer
|
||||||
COPY . /opt/mastodon/
|
COPY . /opt/mastodon/
|
||||||
@ -207,22 +295,22 @@ COPY . /opt/mastodon/
|
|||||||
COPY --from=yarn /opt/mastodon /opt/mastodon/
|
COPY --from=yarn /opt/mastodon /opt/mastodon/
|
||||||
COPY --from=bundler /opt/mastodon /opt/mastodon/
|
COPY --from=bundler /opt/mastodon /opt/mastodon/
|
||||||
COPY --from=bundler /usr/local/bundle/ /usr/local/bundle/
|
COPY --from=bundler /usr/local/bundle/ /usr/local/bundle/
|
||||||
|
# Copy libvips components to layer for precompiler
|
||||||
|
COPY --from=libvips /usr/local/libvips/bin /usr/local/bin
|
||||||
|
COPY --from=libvips /usr/local/libvips/lib /usr/local/lib
|
||||||
|
|
||||||
ARG TARGETPLATFORM
|
ARG TARGETPLATFORM
|
||||||
|
|
||||||
RUN \
|
RUN \
|
||||||
|
ldconfig; \
|
||||||
# Use Ruby on Rails to create Mastodon assets
|
# Use Ruby on Rails to create Mastodon assets
|
||||||
ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=precompile_placeholder \
|
SECRET_KEY_BASE_DUMMY=1 \
|
||||||
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=precompile_placeholder \
|
|
||||||
ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=precompile_placeholder \
|
|
||||||
OTP_SECRET=precompile_placeholder \
|
|
||||||
SECRET_KEY_BASE=precompile_placeholder \
|
|
||||||
bundle exec rails assets:precompile; \
|
bundle exec rails assets:precompile; \
|
||||||
# Cleanup temporary files
|
# Cleanup temporary files
|
||||||
rm -fr /opt/mastodon/tmp;
|
rm -fr /opt/mastodon/tmp;
|
||||||
|
|
||||||
# Prep final Mastodon Ruby layer
|
# Prep final Mastodon Ruby layer
|
||||||
FROM ruby as mastodon
|
FROM ruby AS mastodon
|
||||||
|
|
||||||
ARG TARGETPLATFORM
|
ARG TARGETPLATFORM
|
||||||
|
|
||||||
@ -236,12 +324,41 @@ RUN \
|
|||||||
--mount=type=cache,id=yarn-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/yarn,sharing=locked \
|
--mount=type=cache,id=yarn-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/yarn,sharing=locked \
|
||||||
# Apt update install non-dev versions of necessary components
|
# Apt update install non-dev versions of necessary components
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
libssl3 \
|
libexpat1 \
|
||||||
libpq5 \
|
libglib2.0-0 \
|
||||||
libicu72 \
|
libicu72 \
|
||||||
libidn12 \
|
libidn12 \
|
||||||
|
libpq5 \
|
||||||
libreadline8 \
|
libreadline8 \
|
||||||
|
libssl3 \
|
||||||
libyaml-0-2 \
|
libyaml-0-2 \
|
||||||
|
# libvips components
|
||||||
|
libcgif0 \
|
||||||
|
libexif12 \
|
||||||
|
libheif1 \
|
||||||
|
libimagequant0 \
|
||||||
|
libjpeg62-turbo \
|
||||||
|
liblcms2-2 \
|
||||||
|
liborc-0.4-0 \
|
||||||
|
libspng0 \
|
||||||
|
libtiff6 \
|
||||||
|
libwebp7 \
|
||||||
|
libwebpdemux2 \
|
||||||
|
libwebpmux3 \
|
||||||
|
# ffmpeg components
|
||||||
|
libdav1d6 \
|
||||||
|
libmp3lame0 \
|
||||||
|
libopencore-amrnb0 \
|
||||||
|
libopencore-amrwb0 \
|
||||||
|
libopus0 \
|
||||||
|
libsnappy1v5 \
|
||||||
|
libtheora0 \
|
||||||
|
libvorbis0a \
|
||||||
|
libvorbisenc2 \
|
||||||
|
libvorbisfile3 \
|
||||||
|
libvpx7 \
|
||||||
|
libx264-164 \
|
||||||
|
libx265-199 \
|
||||||
;
|
;
|
||||||
|
|
||||||
# Copy Mastodon sources into final layer
|
# Copy Mastodon sources into final layer
|
||||||
@ -252,9 +369,22 @@ COPY --from=precompiler /opt/mastodon/public/packs /opt/mastodon/public/packs
|
|||||||
COPY --from=precompiler /opt/mastodon/public/assets /opt/mastodon/public/assets
|
COPY --from=precompiler /opt/mastodon/public/assets /opt/mastodon/public/assets
|
||||||
# Copy bundler components to layer
|
# Copy bundler components to layer
|
||||||
COPY --from=bundler /usr/local/bundle/ /usr/local/bundle/
|
COPY --from=bundler /usr/local/bundle/ /usr/local/bundle/
|
||||||
|
# Copy libvips components to layer
|
||||||
|
COPY --from=libvips /usr/local/libvips/bin /usr/local/bin
|
||||||
|
COPY --from=libvips /usr/local/libvips/lib /usr/local/lib
|
||||||
|
# Copy ffpmeg components to layer
|
||||||
|
COPY --from=ffmpeg /usr/local/ffmpeg/bin /usr/local/bin
|
||||||
|
COPY --from=ffmpeg /usr/local/ffmpeg/lib /usr/local/lib
|
||||||
|
|
||||||
RUN \
|
RUN \
|
||||||
# Precompile bootsnap code for faster Rails startup
|
ldconfig; \
|
||||||
|
# Smoketest media processors
|
||||||
|
vips -v; \
|
||||||
|
ffmpeg -version; \
|
||||||
|
ffprobe -version;
|
||||||
|
|
||||||
|
RUN \
|
||||||
|
# Precompile bootsnap code for faster Rails startup
|
||||||
bundle exec bootsnap precompile --gemfile app/ lib/;
|
bundle exec bootsnap precompile --gemfile app/ lib/;
|
||||||
|
|
||||||
RUN \
|
RUN \
|
||||||
|
18
Gemfile
18
Gemfile
@ -9,9 +9,6 @@ gem 'rack', '~> 2.2.7'
|
|||||||
gem 'rails', '~> 7.1.1'
|
gem 'rails', '~> 7.1.1'
|
||||||
gem 'thor', '~> 1.2'
|
gem 'thor', '~> 1.2'
|
||||||
|
|
||||||
# For why irb is in the Gemfile, see: https://ruby.social/@st0012/111444685161478182
|
|
||||||
gem 'irb', '~> 1.8'
|
|
||||||
|
|
||||||
gem 'dotenv'
|
gem 'dotenv'
|
||||||
gem 'haml-rails', '~>2.0'
|
gem 'haml-rails', '~>2.0'
|
||||||
gem 'pg', '~> 1.5'
|
gem 'pg', '~> 1.5'
|
||||||
@ -28,7 +25,7 @@ gem 'ruby-vips', '~> 2.2', require: false
|
|||||||
gem 'active_model_serializers', '~> 0.10'
|
gem 'active_model_serializers', '~> 0.10'
|
||||||
gem 'addressable', '~> 2.8'
|
gem 'addressable', '~> 2.8'
|
||||||
gem 'bootsnap', '~> 1.18.0', require: false
|
gem 'bootsnap', '~> 1.18.0', require: false
|
||||||
gem 'browser'
|
gem 'browser', '< 6' # https://github.com/fnando/browser/issues/543
|
||||||
gem 'charlock_holmes', '~> 0.7.7'
|
gem 'charlock_holmes', '~> 0.7.7'
|
||||||
gem 'chewy', '~> 7.3'
|
gem 'chewy', '~> 7.3'
|
||||||
gem 'devise', '~> 4.9'
|
gem 'devise', '~> 4.9'
|
||||||
@ -61,6 +58,7 @@ gem 'httplog', '~> 1.7.0'
|
|||||||
gem 'i18n'
|
gem 'i18n'
|
||||||
gem 'idn-ruby', require: 'idn'
|
gem 'idn-ruby', require: 'idn'
|
||||||
gem 'inline_svg'
|
gem 'inline_svg'
|
||||||
|
gem 'irb', '~> 1.8'
|
||||||
gem 'kaminari', '~> 1.2'
|
gem 'kaminari', '~> 1.2'
|
||||||
gem 'link_header', '~> 0.0'
|
gem 'link_header', '~> 0.0'
|
||||||
gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock'
|
gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock'
|
||||||
@ -71,7 +69,7 @@ gem 'oj', '~> 3.14'
|
|||||||
gem 'ox', '~> 2.14'
|
gem 'ox', '~> 2.14'
|
||||||
gem 'parslet'
|
gem 'parslet'
|
||||||
gem 'premailer-rails'
|
gem 'premailer-rails'
|
||||||
gem 'public_suffix', '~> 5.0'
|
gem 'public_suffix', '~> 6.0'
|
||||||
gem 'pundit', '~> 2.3'
|
gem 'pundit', '~> 2.3'
|
||||||
gem 'rack-attack', '~> 6.6'
|
gem 'rack-attack', '~> 6.6'
|
||||||
gem 'rack-cors', '~> 2.0', require: 'rack/cors'
|
gem 'rack-cors', '~> 2.0', require: 'rack/cors'
|
||||||
@ -102,12 +100,10 @@ gem 'json-ld'
|
|||||||
gem 'json-ld-preloaded', '~> 3.2'
|
gem 'json-ld-preloaded', '~> 3.2'
|
||||||
gem 'rdf-normalize', '~> 0.5'
|
gem 'rdf-normalize', '~> 0.5'
|
||||||
|
|
||||||
gem 'private_address_check', '~> 0.5'
|
|
||||||
|
|
||||||
gem 'opentelemetry-api', '~> 1.2.5'
|
gem 'opentelemetry-api', '~> 1.2.5'
|
||||||
|
|
||||||
group :opentelemetry do
|
group :opentelemetry do
|
||||||
gem 'opentelemetry-exporter-otlp', '~> 0.27.0', require: false
|
gem 'opentelemetry-exporter-otlp', '~> 0.28.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-active_job', '~> 0.7.1', require: false
|
gem 'opentelemetry-instrumentation-active_job', '~> 0.7.1', require: false
|
||||||
gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.20.1', require: false
|
gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.20.1', require: false
|
||||||
gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.21.2', require: false
|
gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.21.2', require: false
|
||||||
@ -118,13 +114,16 @@ group :opentelemetry do
|
|||||||
gem 'opentelemetry-instrumentation-net_http', '~> 0.22.4', require: false
|
gem 'opentelemetry-instrumentation-net_http', '~> 0.22.4', require: false
|
||||||
gem 'opentelemetry-instrumentation-pg', '~> 0.27.1', require: false
|
gem 'opentelemetry-instrumentation-pg', '~> 0.27.1', require: false
|
||||||
gem 'opentelemetry-instrumentation-rack', '~> 0.24.1', require: false
|
gem 'opentelemetry-instrumentation-rack', '~> 0.24.1', require: false
|
||||||
gem 'opentelemetry-instrumentation-rails', '~> 0.30.0', require: false
|
gem 'opentelemetry-instrumentation-rails', '~> 0.31.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-redis', '~> 0.25.3', require: false
|
gem 'opentelemetry-instrumentation-redis', '~> 0.25.3', require: false
|
||||||
gem 'opentelemetry-instrumentation-sidekiq', '~> 0.25.2', require: false
|
gem 'opentelemetry-instrumentation-sidekiq', '~> 0.25.2', require: false
|
||||||
gem 'opentelemetry-sdk', '~> 1.4', require: false
|
gem 'opentelemetry-sdk', '~> 1.4', require: false
|
||||||
end
|
end
|
||||||
|
|
||||||
group :test do
|
group :test do
|
||||||
|
# Enable usage of all available CPUs/cores during spec runs
|
||||||
|
gem 'flatware-rspec'
|
||||||
|
|
||||||
# Adds RSpec Error/Warning annotations to GitHub PRs on the Files tab
|
# Adds RSpec Error/Warning annotations to GitHub PRs on the Files tab
|
||||||
gem 'rspec-github', '~> 2.4', require: false
|
gem 'rspec-github', '~> 2.4', require: false
|
||||||
|
|
||||||
@ -171,6 +170,7 @@ group :development do
|
|||||||
gem 'rubocop-performance', require: false
|
gem 'rubocop-performance', require: false
|
||||||
gem 'rubocop-rails', require: false
|
gem 'rubocop-rails', require: false
|
||||||
gem 'rubocop-rspec', require: false
|
gem 'rubocop-rspec', require: false
|
||||||
|
gem 'rubocop-rspec_rails', require: false
|
||||||
|
|
||||||
# Annotates modules with schema
|
# Annotates modules with schema
|
||||||
gem 'annotate', '~> 3.2'
|
gem 'annotate', '~> 3.2'
|
||||||
|
147
Gemfile.lock
147
Gemfile.lock
@ -89,8 +89,8 @@ GEM
|
|||||||
minitest (>= 5.1)
|
minitest (>= 5.1)
|
||||||
mutex_m
|
mutex_m
|
||||||
tzinfo (~> 2.0)
|
tzinfo (~> 2.0)
|
||||||
addressable (2.8.6)
|
addressable (2.8.7)
|
||||||
public_suffix (>= 2.0.2, < 6.0)
|
public_suffix (>= 2.0.2, < 7.0)
|
||||||
aes_key_wrap (1.1.0)
|
aes_key_wrap (1.1.0)
|
||||||
android_key_attestation (0.3.0)
|
android_key_attestation (0.3.0)
|
||||||
annotate (3.2.0)
|
annotate (3.2.0)
|
||||||
@ -100,19 +100,19 @@ GEM
|
|||||||
attr_required (1.0.2)
|
attr_required (1.0.2)
|
||||||
awrence (1.2.1)
|
awrence (1.2.1)
|
||||||
aws-eventstream (1.3.0)
|
aws-eventstream (1.3.0)
|
||||||
aws-partitions (1.940.0)
|
aws-partitions (1.950.0)
|
||||||
aws-sdk-core (3.197.0)
|
aws-sdk-core (3.201.0)
|
||||||
aws-eventstream (~> 1, >= 1.3.0)
|
aws-eventstream (~> 1, >= 1.3.0)
|
||||||
aws-partitions (~> 1, >= 1.651.0)
|
aws-partitions (~> 1, >= 1.651.0)
|
||||||
aws-sigv4 (~> 1.8)
|
aws-sigv4 (~> 1.8)
|
||||||
jmespath (~> 1, >= 1.6.1)
|
jmespath (~> 1, >= 1.6.1)
|
||||||
aws-sdk-kms (1.83.0)
|
aws-sdk-kms (1.88.0)
|
||||||
aws-sdk-core (~> 3, >= 3.197.0)
|
aws-sdk-core (~> 3, >= 3.201.0)
|
||||||
aws-sigv4 (~> 1.1)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sdk-s3 (1.152.0)
|
aws-sdk-s3 (1.156.0)
|
||||||
aws-sdk-core (~> 3, >= 3.197.0)
|
aws-sdk-core (~> 3, >= 3.201.0)
|
||||||
aws-sdk-kms (~> 1)
|
aws-sdk-kms (~> 1)
|
||||||
aws-sigv4 (~> 1.8)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sigv4 (1.8.0)
|
aws-sigv4 (1.8.0)
|
||||||
aws-eventstream (~> 1, >= 1.0.2)
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
azure-storage-blob (2.0.3)
|
azure-storage-blob (2.0.3)
|
||||||
@ -143,7 +143,7 @@ GEM
|
|||||||
brpoplpush-redis_script (0.1.3)
|
brpoplpush-redis_script (0.1.3)
|
||||||
concurrent-ruby (~> 1.0, >= 1.0.5)
|
concurrent-ruby (~> 1.0, >= 1.0.5)
|
||||||
redis (>= 1.0, < 6)
|
redis (>= 1.0, < 6)
|
||||||
builder (3.2.4)
|
builder (3.3.0)
|
||||||
bundler-audit (0.9.1)
|
bundler-audit (0.9.1)
|
||||||
bundler (>= 1.2.0, < 3)
|
bundler (>= 1.2.0, < 3)
|
||||||
thor (~> 1.0)
|
thor (~> 1.0)
|
||||||
@ -159,7 +159,7 @@ GEM
|
|||||||
case_transform (0.2)
|
case_transform (0.2)
|
||||||
activesupport
|
activesupport
|
||||||
cbor (0.5.9.8)
|
cbor (0.5.9.8)
|
||||||
charlock_holmes (0.7.7)
|
charlock_holmes (0.7.8)
|
||||||
chewy (7.6.0)
|
chewy (7.6.0)
|
||||||
activesupport (>= 5.2)
|
activesupport (>= 5.2)
|
||||||
elasticsearch (>= 7.14.0, < 8)
|
elasticsearch (>= 7.14.0, < 8)
|
||||||
@ -195,7 +195,7 @@ GEM
|
|||||||
railties (>= 4.1.0)
|
railties (>= 4.1.0)
|
||||||
responders
|
responders
|
||||||
warden (~> 1.2.3)
|
warden (~> 1.2.3)
|
||||||
devise-two-factor (5.0.0)
|
devise-two-factor (5.1.0)
|
||||||
activesupport (~> 7.0)
|
activesupport (~> 7.0)
|
||||||
devise (~> 4.0)
|
devise (~> 4.0)
|
||||||
railties (~> 7.0)
|
railties (~> 7.0)
|
||||||
@ -208,7 +208,7 @@ GEM
|
|||||||
activerecord (>= 4.2, < 8)
|
activerecord (>= 4.2, < 8)
|
||||||
docile (1.4.0)
|
docile (1.4.0)
|
||||||
domain_name (0.6.20240107)
|
domain_name (0.6.20240107)
|
||||||
doorkeeper (5.6.9)
|
doorkeeper (5.7.1)
|
||||||
railties (>= 5)
|
railties (>= 5)
|
||||||
dotenv (3.1.2)
|
dotenv (3.1.2)
|
||||||
drb (2.2.1)
|
drb (2.2.1)
|
||||||
@ -226,7 +226,7 @@ GEM
|
|||||||
htmlentities (~> 4.3.3)
|
htmlentities (~> 4.3.3)
|
||||||
launchy (~> 2.1)
|
launchy (~> 2.1)
|
||||||
mail (~> 2.7)
|
mail (~> 2.7)
|
||||||
erubi (1.12.0)
|
erubi (1.13.0)
|
||||||
et-orbi (1.2.11)
|
et-orbi (1.2.11)
|
||||||
tzinfo
|
tzinfo
|
||||||
excon (0.110.0)
|
excon (0.110.0)
|
||||||
@ -264,6 +264,11 @@ GEM
|
|||||||
ffi-compiler (1.3.2)
|
ffi-compiler (1.3.2)
|
||||||
ffi (>= 1.15.5)
|
ffi (>= 1.15.5)
|
||||||
rake
|
rake
|
||||||
|
flatware (2.3.2)
|
||||||
|
thor (< 2.0)
|
||||||
|
flatware-rspec (2.3.2)
|
||||||
|
flatware (= 2.3.2)
|
||||||
|
rspec (>= 3.6)
|
||||||
fog-core (2.4.0)
|
fog-core (2.4.0)
|
||||||
builder
|
builder
|
||||||
excon (~> 0.71)
|
excon (~> 0.71)
|
||||||
@ -272,7 +277,7 @@ GEM
|
|||||||
fog-json (1.2.0)
|
fog-json (1.2.0)
|
||||||
fog-core
|
fog-core
|
||||||
multi_json (~> 1.10)
|
multi_json (~> 1.10)
|
||||||
fog-openstack (1.1.1)
|
fog-openstack (1.1.3)
|
||||||
fog-core (~> 2.1)
|
fog-core (~> 2.1)
|
||||||
fog-json (>= 1.0)
|
fog-json (>= 1.0)
|
||||||
formatador (1.1.0)
|
formatador (1.1.0)
|
||||||
@ -341,7 +346,7 @@ GEM
|
|||||||
activesupport (>= 3.0)
|
activesupport (>= 3.0)
|
||||||
nokogiri (>= 1.6)
|
nokogiri (>= 1.6)
|
||||||
io-console (0.7.2)
|
io-console (0.7.2)
|
||||||
irb (1.13.1)
|
irb (1.13.2)
|
||||||
rdoc (>= 4.0.0)
|
rdoc (>= 4.0.0)
|
||||||
reline (>= 0.4.2)
|
reline (>= 0.4.2)
|
||||||
jmespath (1.6.2)
|
jmespath (1.6.2)
|
||||||
@ -398,6 +403,7 @@ GEM
|
|||||||
llhttp-ffi (0.5.0)
|
llhttp-ffi (0.5.0)
|
||||||
ffi-compiler (~> 1.0)
|
ffi-compiler (~> 1.0)
|
||||||
rake (~> 13.0)
|
rake (~> 13.0)
|
||||||
|
logger (1.6.0)
|
||||||
lograge (0.14.0)
|
lograge (0.14.0)
|
||||||
actionpack (>= 4)
|
actionpack (>= 4)
|
||||||
activesupport (>= 4)
|
activesupport (>= 4)
|
||||||
@ -419,13 +425,13 @@ GEM
|
|||||||
addressable (~> 2.5)
|
addressable (~> 2.5)
|
||||||
azure-storage-blob (~> 2.0.1)
|
azure-storage-blob (~> 2.0.1)
|
||||||
hashie (~> 5.0)
|
hashie (~> 5.0)
|
||||||
memory_profiler (1.0.1)
|
memory_profiler (1.0.2)
|
||||||
mime-types (3.5.2)
|
mime-types (3.5.2)
|
||||||
mime-types-data (~> 3.2015)
|
mime-types-data (~> 3.2015)
|
||||||
mime-types-data (3.2024.0507)
|
mime-types-data (3.2024.0604)
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
mini_portile2 (2.8.7)
|
mini_portile2 (2.8.7)
|
||||||
minitest (5.23.1)
|
minitest (5.24.1)
|
||||||
msgpack (1.7.2)
|
msgpack (1.7.2)
|
||||||
multi_json (1.15.0)
|
multi_json (1.15.0)
|
||||||
multipart-post (2.4.0)
|
multipart-post (2.4.0)
|
||||||
@ -445,7 +451,7 @@ GEM
|
|||||||
net-smtp (0.5.0)
|
net-smtp (0.5.0)
|
||||||
net-protocol
|
net-protocol
|
||||||
nio4r (2.7.3)
|
nio4r (2.7.3)
|
||||||
nokogiri (1.16.5)
|
nokogiri (1.16.6)
|
||||||
mini_portile2 (~> 2.8.2)
|
mini_portile2 (~> 2.8.2)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nsa (0.3.0)
|
nsa (0.3.0)
|
||||||
@ -489,8 +495,8 @@ GEM
|
|||||||
opentelemetry-api (1.2.5)
|
opentelemetry-api (1.2.5)
|
||||||
opentelemetry-common (0.20.1)
|
opentelemetry-common (0.20.1)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-exporter-otlp (0.27.0)
|
opentelemetry-exporter-otlp (0.28.0)
|
||||||
google-protobuf (~> 3.14)
|
google-protobuf (>= 3.18)
|
||||||
googleapis-common-protos-types (~> 1.3)
|
googleapis-common-protos-types (~> 1.3)
|
||||||
opentelemetry-api (~> 1.1)
|
opentelemetry-api (~> 1.1)
|
||||||
opentelemetry-common (~> 0.20)
|
opentelemetry-common (~> 0.20)
|
||||||
@ -498,6 +504,10 @@ GEM
|
|||||||
opentelemetry-semantic_conventions
|
opentelemetry-semantic_conventions
|
||||||
opentelemetry-helpers-sql-obfuscation (0.1.0)
|
opentelemetry-helpers-sql-obfuscation (0.1.0)
|
||||||
opentelemetry-common (~> 0.20)
|
opentelemetry-common (~> 0.20)
|
||||||
|
opentelemetry-instrumentation-action_mailer (0.1.0)
|
||||||
|
opentelemetry-api (~> 1.0)
|
||||||
|
opentelemetry-instrumentation-active_support (~> 0.1)
|
||||||
|
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||||
opentelemetry-instrumentation-action_pack (0.9.0)
|
opentelemetry-instrumentation-action_pack (0.9.0)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||||
@ -506,7 +516,7 @@ GEM
|
|||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-active_support (~> 0.1)
|
opentelemetry-instrumentation-active_support (~> 0.1)
|
||||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||||
opentelemetry-instrumentation-active_job (0.7.1)
|
opentelemetry-instrumentation-active_job (0.7.2)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||||
opentelemetry-instrumentation-active_model_serializers (0.20.1)
|
opentelemetry-instrumentation-active_model_serializers (0.20.1)
|
||||||
@ -515,7 +525,7 @@ GEM
|
|||||||
opentelemetry-instrumentation-active_record (0.7.2)
|
opentelemetry-instrumentation-active_record (0.7.2)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||||
opentelemetry-instrumentation-active_support (0.5.1)
|
opentelemetry-instrumentation-active_support (0.6.0)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||||
opentelemetry-instrumentation-base (0.22.3)
|
opentelemetry-instrumentation-base (0.22.3)
|
||||||
@ -524,48 +534,42 @@ GEM
|
|||||||
opentelemetry-instrumentation-concurrent_ruby (0.21.3)
|
opentelemetry-instrumentation-concurrent_ruby (0.21.3)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||||
opentelemetry-instrumentation-excon (0.22.1)
|
opentelemetry-instrumentation-excon (0.22.3)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-common (~> 0.20.0)
|
|
||||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||||
opentelemetry-instrumentation-faraday (0.24.2)
|
opentelemetry-instrumentation-faraday (0.24.5)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-common (~> 0.20.0)
|
|
||||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||||
opentelemetry-instrumentation-http (0.23.3)
|
opentelemetry-instrumentation-http (0.23.3)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||||
opentelemetry-instrumentation-http_client (0.22.4)
|
opentelemetry-instrumentation-http_client (0.22.6)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-common (~> 0.20.0)
|
|
||||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||||
opentelemetry-instrumentation-net_http (0.22.4)
|
opentelemetry-instrumentation-net_http (0.22.6)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-common (~> 0.20.0)
|
|
||||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||||
opentelemetry-instrumentation-pg (0.27.3)
|
opentelemetry-instrumentation-pg (0.27.3)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-helpers-sql-obfuscation
|
opentelemetry-helpers-sql-obfuscation
|
||||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||||
opentelemetry-instrumentation-rack (0.24.3)
|
opentelemetry-instrumentation-rack (0.24.5)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-common (~> 0.20.0)
|
|
||||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||||
opentelemetry-instrumentation-rails (0.30.1)
|
opentelemetry-instrumentation-rails (0.31.0)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
|
opentelemetry-instrumentation-action_mailer (~> 0.1.0)
|
||||||
opentelemetry-instrumentation-action_pack (~> 0.9.0)
|
opentelemetry-instrumentation-action_pack (~> 0.9.0)
|
||||||
opentelemetry-instrumentation-action_view (~> 0.7.0)
|
opentelemetry-instrumentation-action_view (~> 0.7.0)
|
||||||
opentelemetry-instrumentation-active_job (~> 0.7.0)
|
opentelemetry-instrumentation-active_job (~> 0.7.0)
|
||||||
opentelemetry-instrumentation-active_record (~> 0.7.0)
|
opentelemetry-instrumentation-active_record (~> 0.7.0)
|
||||||
opentelemetry-instrumentation-active_support (~> 0.5.0)
|
opentelemetry-instrumentation-active_support (~> 0.6.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||||
opentelemetry-instrumentation-redis (0.25.4)
|
opentelemetry-instrumentation-redis (0.25.6)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-common (~> 0.20.0)
|
|
||||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||||
opentelemetry-instrumentation-sidekiq (0.25.3)
|
opentelemetry-instrumentation-sidekiq (0.25.6)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-common (~> 0.20.0)
|
|
||||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||||
opentelemetry-registry (0.3.1)
|
opentelemetry-registry (0.3.1)
|
||||||
opentelemetry-api (~> 1.1)
|
opentelemetry-api (~> 1.1)
|
||||||
@ -579,7 +583,7 @@ GEM
|
|||||||
orm_adapter (0.5.0)
|
orm_adapter (0.5.0)
|
||||||
ox (2.14.18)
|
ox (2.14.18)
|
||||||
parallel (1.25.1)
|
parallel (1.25.1)
|
||||||
parser (3.3.2.0)
|
parser (3.3.3.0)
|
||||||
ast (~> 2.4.1)
|
ast (~> 2.4.1)
|
||||||
racc
|
racc
|
||||||
parslet (2.0.0)
|
parslet (2.0.0)
|
||||||
@ -596,7 +600,6 @@ GEM
|
|||||||
actionmailer (>= 3)
|
actionmailer (>= 3)
|
||||||
net-smtp
|
net-smtp
|
||||||
premailer (~> 1.7, >= 1.7.9)
|
premailer (~> 1.7, >= 1.7.9)
|
||||||
private_address_check (0.5.0)
|
|
||||||
propshaft (0.9.0)
|
propshaft (0.9.0)
|
||||||
actionpack (>= 7.0.0)
|
actionpack (>= 7.0.0)
|
||||||
activesupport (>= 7.0.0)
|
activesupport (>= 7.0.0)
|
||||||
@ -604,7 +607,7 @@ GEM
|
|||||||
railties (>= 7.0.0)
|
railties (>= 7.0.0)
|
||||||
psych (5.1.2)
|
psych (5.1.2)
|
||||||
stringio
|
stringio
|
||||||
public_suffix (5.0.5)
|
public_suffix (6.0.0)
|
||||||
puma (6.4.2)
|
puma (6.4.2)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
pundit (2.3.2)
|
pundit (2.3.2)
|
||||||
@ -677,7 +680,7 @@ GEM
|
|||||||
link_header (~> 0.0, >= 0.0.8)
|
link_header (~> 0.0, >= 0.0.8)
|
||||||
rdf-normalize (0.7.0)
|
rdf-normalize (0.7.0)
|
||||||
rdf (~> 3.3)
|
rdf (~> 3.3)
|
||||||
rdoc (6.6.3.1)
|
rdoc (6.7.0)
|
||||||
psych (>= 4.0.0)
|
psych (>= 4.0.0)
|
||||||
redcarpet (3.6.0)
|
redcarpet (3.6.0)
|
||||||
redis (4.8.1)
|
redis (4.8.1)
|
||||||
@ -686,15 +689,15 @@ GEM
|
|||||||
redlock (1.3.2)
|
redlock (1.3.2)
|
||||||
redis (>= 3.0.0, < 6.0)
|
redis (>= 3.0.0, < 6.0)
|
||||||
regexp_parser (2.9.2)
|
regexp_parser (2.9.2)
|
||||||
reline (0.5.8)
|
reline (0.5.9)
|
||||||
io-console (~> 0.5)
|
io-console (~> 0.5)
|
||||||
request_store (1.6.0)
|
request_store (1.6.0)
|
||||||
rack (>= 1.4)
|
rack (>= 1.4)
|
||||||
responders (3.1.1)
|
responders (3.1.1)
|
||||||
actionpack (>= 5.2)
|
actionpack (>= 5.2)
|
||||||
railties (>= 5.2)
|
railties (>= 5.2)
|
||||||
rexml (3.2.8)
|
rexml (3.3.1)
|
||||||
strscan (>= 3.0.9)
|
strscan
|
||||||
rotp (6.3.0)
|
rotp (6.3.0)
|
||||||
rouge (4.2.1)
|
rouge (4.2.1)
|
||||||
rpam2 (4.0.2)
|
rpam2 (4.0.2)
|
||||||
@ -702,9 +705,13 @@ GEM
|
|||||||
chunky_png (~> 1.0)
|
chunky_png (~> 1.0)
|
||||||
rqrcode_core (~> 1.0)
|
rqrcode_core (~> 1.0)
|
||||||
rqrcode_core (1.2.0)
|
rqrcode_core (1.2.0)
|
||||||
|
rspec (3.13.0)
|
||||||
|
rspec-core (~> 3.13.0)
|
||||||
|
rspec-expectations (~> 3.13.0)
|
||||||
|
rspec-mocks (~> 3.13.0)
|
||||||
rspec-core (3.13.0)
|
rspec-core (3.13.0)
|
||||||
rspec-support (~> 3.13.0)
|
rspec-support (~> 3.13.0)
|
||||||
rspec-expectations (3.13.0)
|
rspec-expectations (3.13.1)
|
||||||
diff-lcs (>= 1.2.0, < 2.0)
|
diff-lcs (>= 1.2.0, < 2.0)
|
||||||
rspec-support (~> 3.13.0)
|
rspec-support (~> 3.13.0)
|
||||||
rspec-github (2.4.0)
|
rspec-github (2.4.0)
|
||||||
@ -712,7 +719,7 @@ GEM
|
|||||||
rspec-mocks (3.13.1)
|
rspec-mocks (3.13.1)
|
||||||
diff-lcs (>= 1.2.0, < 2.0)
|
diff-lcs (>= 1.2.0, < 2.0)
|
||||||
rspec-support (~> 3.13.0)
|
rspec-support (~> 3.13.0)
|
||||||
rspec-rails (6.1.2)
|
rspec-rails (6.1.3)
|
||||||
actionpack (>= 6.1)
|
actionpack (>= 6.1)
|
||||||
activesupport (>= 6.1)
|
activesupport (>= 6.1)
|
||||||
railties (>= 6.1)
|
railties (>= 6.1)
|
||||||
@ -741,23 +748,19 @@ GEM
|
|||||||
parser (>= 3.3.1.0)
|
parser (>= 3.3.1.0)
|
||||||
rubocop-capybara (2.21.0)
|
rubocop-capybara (2.21.0)
|
||||||
rubocop (~> 1.41)
|
rubocop (~> 1.41)
|
||||||
rubocop-factory_bot (2.25.1)
|
rubocop-performance (1.21.1)
|
||||||
rubocop (~> 1.41)
|
|
||||||
rubocop-performance (1.21.0)
|
|
||||||
rubocop (>= 1.48.1, < 2.0)
|
rubocop (>= 1.48.1, < 2.0)
|
||||||
rubocop-ast (>= 1.31.1, < 2.0)
|
rubocop-ast (>= 1.31.1, < 2.0)
|
||||||
rubocop-rails (2.25.0)
|
rubocop-rails (2.25.1)
|
||||||
activesupport (>= 4.2.0)
|
activesupport (>= 4.2.0)
|
||||||
rack (>= 1.1)
|
rack (>= 1.1)
|
||||||
rubocop (>= 1.33.0, < 2.0)
|
rubocop (>= 1.33.0, < 2.0)
|
||||||
rubocop-ast (>= 1.31.1, < 2.0)
|
rubocop-ast (>= 1.31.1, < 2.0)
|
||||||
rubocop-rspec (2.31.0)
|
rubocop-rspec (3.0.2)
|
||||||
rubocop (~> 1.40)
|
rubocop (~> 1.61)
|
||||||
rubocop-capybara (~> 2.17)
|
rubocop-rspec_rails (2.30.0)
|
||||||
rubocop-factory_bot (~> 2.22)
|
rubocop (~> 1.61)
|
||||||
rubocop-rspec_rails (~> 2.28)
|
rubocop-rspec (~> 3, >= 3.0.1)
|
||||||
rubocop-rspec_rails (2.28.3)
|
|
||||||
rubocop (~> 1.40)
|
|
||||||
ruby-prof (1.7.0)
|
ruby-prof (1.7.0)
|
||||||
ruby-progressbar (1.13.0)
|
ruby-progressbar (1.13.0)
|
||||||
ruby-saml (1.16.0)
|
ruby-saml (1.16.0)
|
||||||
@ -771,14 +774,15 @@ GEM
|
|||||||
fugit (~> 1.1, >= 1.1.6)
|
fugit (~> 1.1, >= 1.1.6)
|
||||||
safety_net_attestation (0.4.0)
|
safety_net_attestation (0.4.0)
|
||||||
jwt (~> 2.0)
|
jwt (~> 2.0)
|
||||||
sanitize (6.1.0)
|
sanitize (6.1.1)
|
||||||
crass (~> 1.0.2)
|
crass (~> 1.0.2)
|
||||||
nokogiri (>= 1.12.0)
|
nokogiri (>= 1.12.0)
|
||||||
scenic (1.8.0)
|
scenic (1.8.0)
|
||||||
activerecord (>= 4.0.0)
|
activerecord (>= 4.0.0)
|
||||||
railties (>= 4.0.0)
|
railties (>= 4.0.0)
|
||||||
selenium-webdriver (4.21.1)
|
selenium-webdriver (4.22.0)
|
||||||
base64 (~> 0.2)
|
base64 (~> 0.2)
|
||||||
|
logger (~> 1.4)
|
||||||
rexml (~> 3.2, >= 3.2.5)
|
rexml (~> 3.2, >= 3.2.5)
|
||||||
rubyzip (>= 1.2.2, < 3.0)
|
rubyzip (>= 1.2.2, < 3.0)
|
||||||
websocket (~> 1.0)
|
websocket (~> 1.0)
|
||||||
@ -815,7 +819,7 @@ GEM
|
|||||||
statsd-ruby (1.5.0)
|
statsd-ruby (1.5.0)
|
||||||
stoplight (4.1.0)
|
stoplight (4.1.0)
|
||||||
redlock (~> 1.0)
|
redlock (~> 1.0)
|
||||||
stringio (3.1.0)
|
stringio (3.1.1)
|
||||||
strong_migrations (1.8.0)
|
strong_migrations (1.8.0)
|
||||||
activerecord (>= 5.2)
|
activerecord (>= 5.2)
|
||||||
strscan (3.1.0)
|
strscan (3.1.0)
|
||||||
@ -829,7 +833,7 @@ GEM
|
|||||||
unicode-display_width (>= 1.1.1, < 3)
|
unicode-display_width (>= 1.1.1, < 3)
|
||||||
terrapin (1.0.1)
|
terrapin (1.0.1)
|
||||||
climate_control
|
climate_control
|
||||||
test-prof (1.3.3)
|
test-prof (1.3.3.1)
|
||||||
thor (1.3.1)
|
thor (1.3.1)
|
||||||
tilt (2.3.0)
|
tilt (2.3.0)
|
||||||
timeout (0.4.1)
|
timeout (0.4.1)
|
||||||
@ -897,7 +901,7 @@ GEM
|
|||||||
xorcist (1.1.3)
|
xorcist (1.1.3)
|
||||||
xpath (3.2.0)
|
xpath (3.2.0)
|
||||||
nokogiri (~> 1.8)
|
nokogiri (~> 1.8)
|
||||||
zeitwerk (2.6.15)
|
zeitwerk (2.6.16)
|
||||||
|
|
||||||
PLATFORMS
|
PLATFORMS
|
||||||
ruby
|
ruby
|
||||||
@ -912,7 +916,7 @@ DEPENDENCIES
|
|||||||
blurhash (~> 0.1)
|
blurhash (~> 0.1)
|
||||||
bootsnap (~> 1.18.0)
|
bootsnap (~> 1.18.0)
|
||||||
brakeman (~> 6.0)
|
brakeman (~> 6.0)
|
||||||
browser
|
browser (< 6)
|
||||||
bundler-audit (~> 0.9)
|
bundler-audit (~> 0.9)
|
||||||
capybara (~> 3.39)
|
capybara (~> 3.39)
|
||||||
charlock_holmes (~> 0.7.7)
|
charlock_holmes (~> 0.7.7)
|
||||||
@ -937,6 +941,7 @@ DEPENDENCIES
|
|||||||
faker (~> 3.2)
|
faker (~> 3.2)
|
||||||
fast_blank (~> 1.0)
|
fast_blank (~> 1.0)
|
||||||
fastimage
|
fastimage
|
||||||
|
flatware-rspec
|
||||||
fog-core (<= 2.4.0)
|
fog-core (<= 2.4.0)
|
||||||
fog-openstack (~> 1.0)
|
fog-openstack (~> 1.0)
|
||||||
fuubar (~> 2.5)
|
fuubar (~> 2.5)
|
||||||
@ -978,7 +983,7 @@ DEPENDENCIES
|
|||||||
omniauth-saml (~> 2.0)
|
omniauth-saml (~> 2.0)
|
||||||
omniauth_openid_connect (~> 0.6.1)
|
omniauth_openid_connect (~> 0.6.1)
|
||||||
opentelemetry-api (~> 1.2.5)
|
opentelemetry-api (~> 1.2.5)
|
||||||
opentelemetry-exporter-otlp (~> 0.27.0)
|
opentelemetry-exporter-otlp (~> 0.28.0)
|
||||||
opentelemetry-instrumentation-active_job (~> 0.7.1)
|
opentelemetry-instrumentation-active_job (~> 0.7.1)
|
||||||
opentelemetry-instrumentation-active_model_serializers (~> 0.20.1)
|
opentelemetry-instrumentation-active_model_serializers (~> 0.20.1)
|
||||||
opentelemetry-instrumentation-concurrent_ruby (~> 0.21.2)
|
opentelemetry-instrumentation-concurrent_ruby (~> 0.21.2)
|
||||||
@ -989,7 +994,7 @@ DEPENDENCIES
|
|||||||
opentelemetry-instrumentation-net_http (~> 0.22.4)
|
opentelemetry-instrumentation-net_http (~> 0.22.4)
|
||||||
opentelemetry-instrumentation-pg (~> 0.27.1)
|
opentelemetry-instrumentation-pg (~> 0.27.1)
|
||||||
opentelemetry-instrumentation-rack (~> 0.24.1)
|
opentelemetry-instrumentation-rack (~> 0.24.1)
|
||||||
opentelemetry-instrumentation-rails (~> 0.30.0)
|
opentelemetry-instrumentation-rails (~> 0.31.0)
|
||||||
opentelemetry-instrumentation-redis (~> 0.25.3)
|
opentelemetry-instrumentation-redis (~> 0.25.3)
|
||||||
opentelemetry-instrumentation-sidekiq (~> 0.25.2)
|
opentelemetry-instrumentation-sidekiq (~> 0.25.2)
|
||||||
opentelemetry-sdk (~> 1.4)
|
opentelemetry-sdk (~> 1.4)
|
||||||
@ -998,9 +1003,8 @@ DEPENDENCIES
|
|||||||
pg (~> 1.5)
|
pg (~> 1.5)
|
||||||
pghero
|
pghero
|
||||||
premailer-rails
|
premailer-rails
|
||||||
private_address_check (~> 0.5)
|
|
||||||
propshaft
|
propshaft
|
||||||
public_suffix (~> 5.0)
|
public_suffix (~> 6.0)
|
||||||
puma (~> 6.3)
|
puma (~> 6.3)
|
||||||
pundit (~> 2.3)
|
pundit (~> 2.3)
|
||||||
rack (~> 2.2.7)
|
rack (~> 2.2.7)
|
||||||
@ -1023,6 +1027,7 @@ DEPENDENCIES
|
|||||||
rubocop-performance
|
rubocop-performance
|
||||||
rubocop-rails
|
rubocop-rails
|
||||||
rubocop-rspec
|
rubocop-rspec
|
||||||
|
rubocop-rspec_rails
|
||||||
ruby-prof
|
ruby-prof
|
||||||
ruby-progressbar (~> 1.13)
|
ruby-progressbar (~> 1.13)
|
||||||
ruby-vips (~> 2.2)
|
ruby-vips (~> 2.2)
|
||||||
|
@ -90,7 +90,7 @@ Mastodon acts as an OAuth2 provider, so 3rd party apps can use the REST and Stre
|
|||||||
### Tech stack
|
### Tech stack
|
||||||
|
|
||||||
- **Ruby on Rails** powers the REST API and other web pages
|
- **Ruby on Rails** powers the REST API and other web pages
|
||||||
- **React.js** and Redux are used for the dynamic parts of the interface
|
- **React.js** and **Redux** are used for the dynamic parts of the interface
|
||||||
- **Node.js** powers the streaming API
|
- **Node.js** powers the streaming API
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
@ -4,6 +4,18 @@ module Admin
|
|||||||
class DomainBlocksController < BaseController
|
class DomainBlocksController < BaseController
|
||||||
before_action :set_domain_block, only: [:destroy, :edit, :update]
|
before_action :set_domain_block, only: [:destroy, :edit, :update]
|
||||||
|
|
||||||
|
PERMITTED_PARAMS = %i(
|
||||||
|
domain
|
||||||
|
obfuscate
|
||||||
|
private_comment
|
||||||
|
public_comment
|
||||||
|
reject_media
|
||||||
|
reject_reports
|
||||||
|
severity
|
||||||
|
).freeze
|
||||||
|
|
||||||
|
PERMITTED_UPDATE_PARAMS = PERMITTED_PARAMS.without(:domain).freeze
|
||||||
|
|
||||||
def batch
|
def batch
|
||||||
authorize :domain_block, :create?
|
authorize :domain_block, :create?
|
||||||
@form = Form::DomainBlockBatch.new(form_domain_block_batch_params.merge(current_account: current_account, action: action_from_button))
|
@form = Form::DomainBlockBatch.new(form_domain_block_batch_params.merge(current_account: current_account, action: action_from_button))
|
||||||
@ -88,11 +100,17 @@ module Admin
|
|||||||
end
|
end
|
||||||
|
|
||||||
def update_params
|
def update_params
|
||||||
params.require(:domain_block).permit(:severity, :reject_media, :reject_reports, :private_comment, :public_comment, :obfuscate)
|
params
|
||||||
|
.require(:domain_block)
|
||||||
|
.slice(*PERMITTED_UPDATE_PARAMS)
|
||||||
|
.permit(*PERMITTED_UPDATE_PARAMS)
|
||||||
end
|
end
|
||||||
|
|
||||||
def resource_params
|
def resource_params
|
||||||
params.require(:domain_block).permit(:domain, :severity, :reject_media, :reject_reports, :private_comment, :public_comment, :obfuscate)
|
params
|
||||||
|
.require(:domain_block)
|
||||||
|
.slice(*PERMITTED_PARAMS)
|
||||||
|
.permit(*PERMITTED_PARAMS)
|
||||||
end
|
end
|
||||||
|
|
||||||
def form_domain_block_batch_params
|
def form_domain_block_batch_params
|
||||||
|
@ -13,6 +13,13 @@ class Api::V1::Admin::TagsController < Api::BaseController
|
|||||||
|
|
||||||
LIMIT = 100
|
LIMIT = 100
|
||||||
|
|
||||||
|
PERMITTED_PARAMS = %i(
|
||||||
|
display_name
|
||||||
|
listable
|
||||||
|
trendable
|
||||||
|
usable
|
||||||
|
).freeze
|
||||||
|
|
||||||
def index
|
def index
|
||||||
authorize :tag, :index?
|
authorize :tag, :index?
|
||||||
render json: @tags, each_serializer: REST::Admin::TagSerializer
|
render json: @tags, each_serializer: REST::Admin::TagSerializer
|
||||||
@ -40,7 +47,9 @@ class Api::V1::Admin::TagsController < Api::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def tag_params
|
def tag_params
|
||||||
params.permit(:display_name, :trendable, :usable, :listable)
|
params
|
||||||
|
.slice(*PERMITTED_PARAMS)
|
||||||
|
.permit(*PERMITTED_PARAMS)
|
||||||
end
|
end
|
||||||
|
|
||||||
def next_path
|
def next_path
|
||||||
|
@ -6,6 +6,7 @@ class Api::V1::ScheduledStatusesController < Api::BaseController
|
|||||||
before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, except: [:update, :destroy]
|
before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, except: [:update, :destroy]
|
||||||
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:update, :destroy]
|
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:update, :destroy]
|
||||||
|
|
||||||
|
before_action :require_user!
|
||||||
before_action :set_statuses, only: :index
|
before_action :set_statuses, only: :index
|
||||||
before_action :set_status, except: :index
|
before_action :set_status, except: :index
|
||||||
|
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
class Api::V1::Statuses::TranslationsController < Api::V1::Statuses::BaseController
|
class Api::V1::Statuses::TranslationsController < Api::V1::Statuses::BaseController
|
||||||
before_action -> { doorkeeper_authorize! :read, :'read:statuses' }
|
before_action -> { doorkeeper_authorize! :read, :'read:statuses' }
|
||||||
|
before_action :require_user!
|
||||||
before_action :set_translation
|
before_action :set_translation
|
||||||
|
|
||||||
rescue_from TranslationService::NotConfiguredError, with: :not_found
|
rescue_from TranslationService::NotConfiguredError, with: :not_found
|
||||||
|
@ -3,8 +3,14 @@
|
|||||||
class Api::V1::Timelines::BaseController < Api::BaseController
|
class Api::V1::Timelines::BaseController < Api::BaseController
|
||||||
after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
|
after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
|
||||||
|
|
||||||
|
before_action :require_user!, if: :require_auth?
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def require_auth?
|
||||||
|
!Setting.timeline_preview
|
||||||
|
end
|
||||||
|
|
||||||
def pagination_collection
|
def pagination_collection
|
||||||
@statuses
|
@statuses
|
||||||
end
|
end
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class Api::V1::Timelines::LinkController < Api::V1::Timelines::BaseController
|
class Api::V1::Timelines::LinkController < Api::V1::Timelines::BaseController
|
||||||
before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, only: :show, if: :require_auth?
|
before_action -> { authorize_if_got_token! :read, :'read:statuses' }
|
||||||
before_action :set_preview_card
|
before_action :set_preview_card
|
||||||
before_action :set_statuses
|
before_action :set_statuses
|
||||||
|
|
||||||
@ -17,10 +17,6 @@ class Api::V1::Timelines::LinkController < Api::V1::Timelines::BaseController
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def require_auth?
|
|
||||||
!Setting.timeline_preview
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_preview_card
|
def set_preview_card
|
||||||
@preview_card = PreviewCard.joins(:trend).merge(PreviewCardTrend.allowed).find_by!(url: params[:url])
|
@preview_card = PreviewCard.joins(:trend).merge(PreviewCardTrend.allowed).find_by!(url: params[:url])
|
||||||
end
|
end
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class Api::V1::Timelines::PublicController < Api::V1::Timelines::BaseController
|
class Api::V1::Timelines::PublicController < Api::V1::Timelines::BaseController
|
||||||
before_action :require_user!, only: [:show], if: :require_auth?
|
before_action -> { authorize_if_got_token! :read, :'read:statuses' }
|
||||||
|
|
||||||
PERMITTED_PARAMS = %i(local remote limit only_media allow_local_only).freeze
|
PERMITTED_PARAMS = %i(local remote limit only_media allow_local_only).freeze
|
||||||
|
|
||||||
@ -13,10 +13,6 @@ class Api::V1::Timelines::PublicController < Api::V1::Timelines::BaseController
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def require_auth?
|
|
||||||
!Setting.timeline_preview
|
|
||||||
end
|
|
||||||
|
|
||||||
def load_statuses
|
def load_statuses
|
||||||
preloaded_public_statuses_page
|
preloaded_public_statuses_page
|
||||||
end
|
end
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class Api::V1::Timelines::TagController < Api::V1::Timelines::BaseController
|
class Api::V1::Timelines::TagController < Api::V1::Timelines::BaseController
|
||||||
before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, only: :show, if: :require_auth?
|
before_action -> { authorize_if_got_token! :read, :'read:statuses' }
|
||||||
before_action :load_tag
|
before_action :load_tag
|
||||||
|
|
||||||
PERMITTED_PARAMS = %i(local limit only_media).freeze
|
PERMITTED_PARAMS = %i(local limit only_media).freeze
|
||||||
|
@ -15,7 +15,7 @@ class Api::V2Alpha::NotificationsController < Api::BaseController
|
|||||||
@relationships = StatusRelationshipsPresenter.new(target_statuses_from_notifications, current_user&.account_id)
|
@relationships = StatusRelationshipsPresenter.new(target_statuses_from_notifications, current_user&.account_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
render json: @notifications.map { |notification| NotificationGroup.from_notification(notification) }, each_serializer: REST::NotificationGroupSerializer, relationships: @relationships, group_metadata: @group_metadata
|
render json: @notifications.map { |notification| NotificationGroup.from_notification(notification, max_id: @group_metadata.dig(notification.group_key, :max_id)) }, each_serializer: REST::NotificationGroupSerializer, relationships: @relationships, group_metadata: @group_metadata
|
||||||
end
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
|
@ -25,6 +25,14 @@ class Auth::RegistrationsController < Devise::RegistrationsController
|
|||||||
super(&:build_invite_request)
|
super(&:build_invite_request)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def edit # rubocop:disable Lint/UselessMethodDefinition
|
||||||
|
super
|
||||||
|
end
|
||||||
|
|
||||||
|
def create # rubocop:disable Lint/UselessMethodDefinition
|
||||||
|
super
|
||||||
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
super do |resource|
|
super do |resource|
|
||||||
resource.clear_other_sessions(current_session.session_id) if resource.saved_change_to_encrypted_password?
|
resource.clear_other_sessions(current_session.session_id) if resource.saved_change_to_encrypted_password?
|
||||||
|
@ -17,6 +17,7 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio
|
|||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
Web::PushSubscription.unsubscribe_for(params[:id], current_resource_owner)
|
Web::PushSubscription.unsubscribe_for(params[:id], current_resource_owner)
|
||||||
|
Doorkeeper::Application.find_by(id: params[:id])&.close_streaming_sessions(current_resource_owner)
|
||||||
super
|
super
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -4,27 +4,42 @@ module Admin::AccountModerationNotesHelper
|
|||||||
def admin_account_link_to(account, path: nil)
|
def admin_account_link_to(account, path: nil)
|
||||||
return if account.nil?
|
return if account.nil?
|
||||||
|
|
||||||
link_to path || admin_account_path(account.id), class: name_tag_classes(account), title: account.acct do
|
link_to(
|
||||||
safe_join([
|
labeled_account_avatar(account),
|
||||||
image_tag(account.avatar.url, width: 15, height: 15, alt: '', class: 'avatar'),
|
path || admin_account_path(account.id),
|
||||||
content_tag(:span, account.acct, class: 'username'),
|
class: class_names('name-tag', suspended: suspended_account?(account)),
|
||||||
], ' ')
|
title: account.acct
|
||||||
end
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def admin_account_inline_link_to(account)
|
def admin_account_inline_link_to(account)
|
||||||
return if account.nil?
|
return if account.nil?
|
||||||
|
|
||||||
link_to admin_account_path(account.id), class: name_tag_classes(account, true), title: account.acct do
|
link_to(
|
||||||
content_tag(:span, account.acct, class: 'username')
|
account_inline_text(account),
|
||||||
end
|
admin_account_path(account.id),
|
||||||
|
class: class_names('inline-name-tag', suspended: suspended_account?(account)),
|
||||||
|
title: account.acct
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def name_tag_classes(account, inline = false)
|
def labeled_account_avatar(account)
|
||||||
classes = [inline ? 'inline-name-tag' : 'name-tag']
|
safe_join(
|
||||||
classes << 'suspended' if account.suspended? || (account.local? && account.user.nil?)
|
[
|
||||||
classes.join(' ')
|
image_tag(account.avatar.url, width: 15, height: 15, alt: '', class: 'avatar'),
|
||||||
|
account_inline_text(account),
|
||||||
|
],
|
||||||
|
' '
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def account_inline_text(account)
|
||||||
|
content_tag(:span, account.acct, class: 'username')
|
||||||
|
end
|
||||||
|
|
||||||
|
def suspended_account?(account)
|
||||||
|
account.suspended? || (account.local? && account.user.nil?)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -15,15 +15,15 @@ module Admin::ActionLogsHelper
|
|||||||
link_to log.human_identifier, admin_roles_path(log.target_id)
|
link_to log.human_identifier, admin_roles_path(log.target_id)
|
||||||
when 'Report'
|
when 'Report'
|
||||||
link_to "##{log.human_identifier.presence || log.target_id}", admin_report_path(log.target_id)
|
link_to "##{log.human_identifier.presence || log.target_id}", admin_report_path(log.target_id)
|
||||||
when 'DomainBlock', 'DomainAllow', 'EmailDomainBlock', 'UnavailableDomain'
|
when 'Instance', 'DomainBlock', 'DomainAllow', 'UnavailableDomain'
|
||||||
link_to log.human_identifier, "https://#{log.human_identifier.presence}"
|
log.human_identifier.present? ? link_to(log.human_identifier, admin_instance_path(log.human_identifier)) : I18n.t('admin.action_logs.unavailable_instance')
|
||||||
when 'Status'
|
when 'Status'
|
||||||
link_to log.human_identifier, log.permalink
|
link_to log.human_identifier, log.permalink
|
||||||
when 'AccountWarning'
|
when 'AccountWarning'
|
||||||
link_to log.human_identifier, disputes_strike_path(log.target_id)
|
link_to log.human_identifier, disputes_strike_path(log.target_id)
|
||||||
when 'Announcement'
|
when 'Announcement'
|
||||||
link_to truncate(log.human_identifier), edit_admin_announcement_path(log.target_id)
|
link_to truncate(log.human_identifier), edit_admin_announcement_path(log.target_id)
|
||||||
when 'IpBlock', 'Instance', 'CustomEmoji'
|
when 'IpBlock', 'EmailDomainBlock', 'CustomEmoji'
|
||||||
log.human_identifier
|
log.human_identifier
|
||||||
when 'CanonicalEmailBlock'
|
when 'CanonicalEmailBlock'
|
||||||
content_tag(:samp, (log.human_identifier.presence || '')[0...7], title: log.human_identifier)
|
content_tag(:samp, (log.human_identifier.presence || '')[0...7], title: log.human_identifier)
|
||||||
|
@ -257,6 +257,10 @@ module ApplicationHelper
|
|||||||
instance_presenter.app_icon&.file&.url(size)
|
instance_presenter.app_icon&.file&.url(size)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def use_mask_icon?
|
||||||
|
instance_presenter.app_icon.blank?
|
||||||
|
end
|
||||||
|
|
||||||
# glitch-soc addition to handle the multiple flavors
|
# glitch-soc addition to handle the multiple flavors
|
||||||
def preload_locale_pack
|
def preload_locale_pack
|
||||||
supported_locales = Themes.instance.flavour(current_flavour)['locales']
|
supported_locales = Themes.instance.flavour(current_flavour)['locales']
|
||||||
|
@ -141,7 +141,7 @@ module JsonLdHelper
|
|||||||
def safe_for_forwarding?(original, compacted)
|
def safe_for_forwarding?(original, compacted)
|
||||||
original.without('@context', 'signature').all? do |key, value|
|
original.without('@context', 'signature').all? do |key, value|
|
||||||
compacted_value = compacted[key]
|
compacted_value = compacted[key]
|
||||||
return false unless value.class == compacted_value.class
|
return false unless value.instance_of?(compacted_value.class)
|
||||||
|
|
||||||
if value.is_a?(Hash)
|
if value.is_a?(Hash)
|
||||||
safe_for_forwarding?(value, compacted_value)
|
safe_for_forwarding?(value, compacted_value)
|
||||||
|
@ -1,62 +0,0 @@
|
|||||||
import api from '../api';
|
|
||||||
|
|
||||||
import { fetchRelationships } from './accounts';
|
|
||||||
import { importFetchedAccounts } from './importer';
|
|
||||||
|
|
||||||
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) => {
|
|
||||||
dispatch(fetchDirectoryRequest());
|
|
||||||
|
|
||||||
api().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().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,
|
|
||||||
});
|
|
37
app/javascript/flavours/glitch/actions/directory.ts
Normal file
37
app/javascript/flavours/glitch/actions/directory.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import type { List as ImmutableList } from 'immutable';
|
||||||
|
|
||||||
|
import { apiGetDirectory } from 'flavours/glitch/api/directory';
|
||||||
|
import { createDataLoadingThunk } from 'flavours/glitch/store/typed_functions';
|
||||||
|
|
||||||
|
import { fetchRelationships } from './accounts';
|
||||||
|
import { importFetchedAccounts } from './importer';
|
||||||
|
|
||||||
|
export const fetchDirectory = createDataLoadingThunk(
|
||||||
|
'directory/fetch',
|
||||||
|
async (params: Parameters<typeof apiGetDirectory>[0]) =>
|
||||||
|
apiGetDirectory(params),
|
||||||
|
(data, { dispatch }) => {
|
||||||
|
dispatch(importFetchedAccounts(data));
|
||||||
|
dispatch(fetchRelationships(data.map((x) => x.id)));
|
||||||
|
|
||||||
|
return { accounts: data };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const expandDirectory = createDataLoadingThunk(
|
||||||
|
'directory/expand',
|
||||||
|
async (params: Parameters<typeof apiGetDirectory>[0], { getState }) => {
|
||||||
|
const loadedItems = getState().user_lists.getIn([
|
||||||
|
'directory',
|
||||||
|
'items',
|
||||||
|
]) as ImmutableList<unknown>;
|
||||||
|
|
||||||
|
return apiGetDirectory({ ...params, offset: loadedItems.size }, 20);
|
||||||
|
},
|
||||||
|
(data, { dispatch }) => {
|
||||||
|
dispatch(importFetchedAccounts(data));
|
||||||
|
dispatch(fetchRelationships(data.map((x) => x.id)));
|
||||||
|
|
||||||
|
return { accounts: data };
|
||||||
|
},
|
||||||
|
);
|
@ -68,13 +68,17 @@ export function importFetchedStatuses(statuses) {
|
|||||||
status.filtered.forEach(result => pushUnique(filters, result.filter));
|
status.filtered.forEach(result => pushUnique(filters, result.filter));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status.reblog && status.reblog.id) {
|
if (status.reblog?.id) {
|
||||||
processStatus(status.reblog);
|
processStatus(status.reblog);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status.poll && status.poll.id) {
|
if (status.poll?.id) {
|
||||||
pushUnique(polls, normalizePoll(status.poll, getState().getIn(['polls', status.poll.id])));
|
pushUnique(polls, normalizePoll(status.poll, getState().getIn(['polls', status.poll.id])));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (status.card) {
|
||||||
|
status.card.authors.forEach(author => author.account && pushUnique(accounts, author.account));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
statuses.forEach(processStatus);
|
statuses.forEach(processStatus);
|
||||||
|
@ -36,6 +36,17 @@ export function normalizeStatus(status, normalOldStatus, settings) {
|
|||||||
normalStatus.poll = status.poll.id;
|
normalStatus.poll = status.poll.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (status.card) {
|
||||||
|
normalStatus.card = {
|
||||||
|
...status.card,
|
||||||
|
authors: status.card.authors.map(author => ({
|
||||||
|
...author,
|
||||||
|
accountId: author.account?.id,
|
||||||
|
account: undefined,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (status.filtered) {
|
if (status.filtered) {
|
||||||
normalStatus.filtered = status.filtered.map(normalizeFilterResult);
|
normalStatus.filtered = status.filtered.map(normalizeFilterResult);
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,16 @@
|
|||||||
|
import {
|
||||||
|
apiGetNotificationPolicy,
|
||||||
|
apiUpdateNotificationsPolicy,
|
||||||
|
} from 'flavours/glitch/api/notification_policies';
|
||||||
|
import type { NotificationPolicy } from 'flavours/glitch/models/notification_policy';
|
||||||
|
import { createDataLoadingThunk } from 'flavours/glitch/store/typed_functions';
|
||||||
|
|
||||||
|
export const fetchNotificationPolicy = createDataLoadingThunk(
|
||||||
|
'notificationPolicy/fetch',
|
||||||
|
() => apiGetNotificationPolicy(),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const updateNotificationsPolicy = createDataLoadingThunk(
|
||||||
|
'notificationPolicy/update',
|
||||||
|
(policy: Partial<NotificationPolicy>) => apiUpdateNotificationsPolicy(policy),
|
||||||
|
);
|
@ -57,10 +57,6 @@ export const NOTIFICATIONS_MARK_AS_READ = 'NOTIFICATIONS_MARK_AS_READ';
|
|||||||
export const NOTIFICATIONS_SET_BROWSER_SUPPORT = 'NOTIFICATIONS_SET_BROWSER_SUPPORT';
|
export const NOTIFICATIONS_SET_BROWSER_SUPPORT = 'NOTIFICATIONS_SET_BROWSER_SUPPORT';
|
||||||
export const NOTIFICATIONS_SET_BROWSER_PERMISSION = 'NOTIFICATIONS_SET_BROWSER_PERMISSION';
|
export const NOTIFICATIONS_SET_BROWSER_PERMISSION = 'NOTIFICATIONS_SET_BROWSER_PERMISSION';
|
||||||
|
|
||||||
export const NOTIFICATION_POLICY_FETCH_REQUEST = 'NOTIFICATION_POLICY_FETCH_REQUEST';
|
|
||||||
export const NOTIFICATION_POLICY_FETCH_SUCCESS = 'NOTIFICATION_POLICY_FETCH_SUCCESS';
|
|
||||||
export const NOTIFICATION_POLICY_FETCH_FAIL = 'NOTIFICATION_POLICY_FETCH_FAIL';
|
|
||||||
|
|
||||||
export const NOTIFICATION_REQUESTS_FETCH_REQUEST = 'NOTIFICATION_REQUESTS_FETCH_REQUEST';
|
export const NOTIFICATION_REQUESTS_FETCH_REQUEST = 'NOTIFICATION_REQUESTS_FETCH_REQUEST';
|
||||||
export const NOTIFICATION_REQUESTS_FETCH_SUCCESS = 'NOTIFICATION_REQUESTS_FETCH_SUCCESS';
|
export const NOTIFICATION_REQUESTS_FETCH_SUCCESS = 'NOTIFICATION_REQUESTS_FETCH_SUCCESS';
|
||||||
export const NOTIFICATION_REQUESTS_FETCH_FAIL = 'NOTIFICATION_REQUESTS_FETCH_FAIL';
|
export const NOTIFICATION_REQUESTS_FETCH_FAIL = 'NOTIFICATION_REQUESTS_FETCH_FAIL';
|
||||||
@ -435,40 +431,6 @@ export function setBrowserPermission (value) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fetchNotificationPolicy = () => (dispatch) => {
|
|
||||||
dispatch(fetchNotificationPolicyRequest());
|
|
||||||
|
|
||||||
api().get('/api/v1/notifications/policy').then(({ data }) => {
|
|
||||||
dispatch(fetchNotificationPolicySuccess(data));
|
|
||||||
}).catch(err => {
|
|
||||||
dispatch(fetchNotificationPolicyFail(err));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const fetchNotificationPolicyRequest = () => ({
|
|
||||||
type: NOTIFICATION_POLICY_FETCH_REQUEST,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const fetchNotificationPolicySuccess = policy => ({
|
|
||||||
type: NOTIFICATION_POLICY_FETCH_SUCCESS,
|
|
||||||
policy,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const fetchNotificationPolicyFail = error => ({
|
|
||||||
type: NOTIFICATION_POLICY_FETCH_FAIL,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const updateNotificationsPolicy = params => (dispatch) => {
|
|
||||||
dispatch(fetchNotificationPolicyRequest());
|
|
||||||
|
|
||||||
api().put('/api/v1/notifications/policy', params).then(({ data }) => {
|
|
||||||
dispatch(fetchNotificationPolicySuccess(data));
|
|
||||||
}).catch(err => {
|
|
||||||
dispatch(fetchNotificationPolicyFail(err));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const fetchNotificationRequests = () => (dispatch, getState) => {
|
export const fetchNotificationRequests = () => (dispatch, getState) => {
|
||||||
const params = {};
|
const params = {};
|
||||||
|
|
||||||
|
@ -77,7 +77,7 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
|
|||||||
},
|
},
|
||||||
|
|
||||||
onDisconnect() {
|
onDisconnect() {
|
||||||
dispatch(disconnectTimeline(timelineId));
|
dispatch(disconnectTimeline({ timeline: timelineId }));
|
||||||
|
|
||||||
if (options.fallback) {
|
if (options.fallback) {
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
|
@ -7,9 +7,11 @@ import { toServerSideType } from 'flavours/glitch/utils/filters';
|
|||||||
|
|
||||||
import { importFetchedStatus, importFetchedStatuses } from './importer';
|
import { importFetchedStatus, importFetchedStatuses } from './importer';
|
||||||
import { submitMarkers } from './markers';
|
import { submitMarkers } from './markers';
|
||||||
|
import {timelineDelete} from './timelines_typed';
|
||||||
|
|
||||||
|
export { disconnectTimeline } from './timelines_typed';
|
||||||
|
|
||||||
export const TIMELINE_UPDATE = 'TIMELINE_UPDATE';
|
export const TIMELINE_UPDATE = 'TIMELINE_UPDATE';
|
||||||
export const TIMELINE_DELETE = 'TIMELINE_DELETE';
|
|
||||||
export const TIMELINE_CLEAR = 'TIMELINE_CLEAR';
|
export const TIMELINE_CLEAR = 'TIMELINE_CLEAR';
|
||||||
|
|
||||||
export const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST';
|
export const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST';
|
||||||
@ -18,7 +20,6 @@ export const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL';
|
|||||||
|
|
||||||
export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP';
|
export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP';
|
||||||
export const TIMELINE_LOAD_PENDING = 'TIMELINE_LOAD_PENDING';
|
export const TIMELINE_LOAD_PENDING = 'TIMELINE_LOAD_PENDING';
|
||||||
export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT';
|
|
||||||
export const TIMELINE_CONNECT = 'TIMELINE_CONNECT';
|
export const TIMELINE_CONNECT = 'TIMELINE_CONNECT';
|
||||||
|
|
||||||
export const TIMELINE_MARK_AS_PARTIAL = 'TIMELINE_MARK_AS_PARTIAL';
|
export const TIMELINE_MARK_AS_PARTIAL = 'TIMELINE_MARK_AS_PARTIAL';
|
||||||
@ -73,16 +74,10 @@ export function updateTimeline(timeline, status, accept) {
|
|||||||
export function deleteFromTimelines(id) {
|
export function deleteFromTimelines(id) {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
const accountId = getState().getIn(['statuses', id, 'account']);
|
const accountId = getState().getIn(['statuses', id, 'account']);
|
||||||
const references = getState().get('statuses').filter(status => status.get('reblog') === id).map(status => status.get('id'));
|
const references = getState().get('statuses').filter(status => status.get('reblog') === id).map(status => status.get('id')).valueSeq().toJSON();
|
||||||
const reblogOf = getState().getIn(['statuses', id, 'reblog'], null);
|
const reblogOf = getState().getIn(['statuses', id, 'reblog'], null);
|
||||||
|
|
||||||
dispatch({
|
dispatch(timelineDelete({ statusId: id, accountId, references, reblogOf }));
|
||||||
type: TIMELINE_DELETE,
|
|
||||||
id,
|
|
||||||
accountId,
|
|
||||||
references,
|
|
||||||
reblogOf,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -175,6 +170,7 @@ export const expandAccountTimeline = (accountId, { maxId, withReplies, t
|
|||||||
export const expandAccountFeaturedTimeline = (accountId, { tagged } = {}) => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true, tagged });
|
export const expandAccountFeaturedTimeline = (accountId, { tagged } = {}) => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true, tagged });
|
||||||
export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 });
|
export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 });
|
||||||
export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done);
|
export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done);
|
||||||
|
export const expandLinkTimeline = (url, { maxId } = {}, done = noOp) => expandTimeline(`link:${url}`, `/api/v1/timelines/link`, { url, max_id: maxId }, done);
|
||||||
export const expandHashtagTimeline = (hashtag, { maxId, tags, local } = {}, done = noOp) => {
|
export const expandHashtagTimeline = (hashtag, { maxId, tags, local } = {}, done = noOp) => {
|
||||||
return expandTimeline(`hashtag:${hashtag}${local ? ':local' : ''}`, `/api/v1/timelines/tag/${hashtag}`, {
|
return expandTimeline(`hashtag:${hashtag}${local ? ':local' : ''}`, `/api/v1/timelines/tag/${hashtag}`, {
|
||||||
max_id: maxId,
|
max_id: maxId,
|
||||||
@ -237,12 +233,6 @@ export function connectTimeline(timeline) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const disconnectTimeline = timeline => ({
|
|
||||||
type: TIMELINE_DISCONNECT,
|
|
||||||
timeline,
|
|
||||||
usePendingItems: preferPendingItems,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const markAsPartial = timeline => ({
|
export const markAsPartial = timeline => ({
|
||||||
type: TIMELINE_MARK_AS_PARTIAL,
|
type: TIMELINE_MARK_AS_PARTIAL,
|
||||||
timeline,
|
timeline,
|
||||||
|
20
app/javascript/flavours/glitch/actions/timelines_typed.ts
Normal file
20
app/javascript/flavours/glitch/actions/timelines_typed.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { createAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
import { usePendingItems as preferPendingItems } from 'flavours/glitch/initial_state';
|
||||||
|
|
||||||
|
export const disconnectTimeline = createAction(
|
||||||
|
'timeline/disconnect',
|
||||||
|
({ timeline }: { timeline: string }) => ({
|
||||||
|
payload: {
|
||||||
|
timeline,
|
||||||
|
usePendingItems: preferPendingItems,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const timelineDelete = createAction<{
|
||||||
|
statusId: string;
|
||||||
|
accountId: string;
|
||||||
|
references: string[];
|
||||||
|
reblogOf: string | null;
|
||||||
|
}>('timelines/delete');
|
@ -1,6 +1,6 @@
|
|||||||
import api, { getLinks } from '../api';
|
import api, { getLinks } from '../api';
|
||||||
|
|
||||||
import { importFetchedStatuses } from './importer';
|
import { importFetchedStatuses, importFetchedAccounts } from './importer';
|
||||||
|
|
||||||
export const TRENDS_TAGS_FETCH_REQUEST = 'TRENDS_TAGS_FETCH_REQUEST';
|
export const TRENDS_TAGS_FETCH_REQUEST = 'TRENDS_TAGS_FETCH_REQUEST';
|
||||||
export const TRENDS_TAGS_FETCH_SUCCESS = 'TRENDS_TAGS_FETCH_SUCCESS';
|
export const TRENDS_TAGS_FETCH_SUCCESS = 'TRENDS_TAGS_FETCH_SUCCESS';
|
||||||
@ -49,8 +49,11 @@ export const fetchTrendingLinks = () => (dispatch) => {
|
|||||||
dispatch(fetchTrendingLinksRequest());
|
dispatch(fetchTrendingLinksRequest());
|
||||||
|
|
||||||
api()
|
api()
|
||||||
.get('/api/v1/trends/links')
|
.get('/api/v1/trends/links', { params: { limit: 20 } })
|
||||||
.then(({ data }) => dispatch(fetchTrendingLinksSuccess(data)))
|
.then(({ data }) => {
|
||||||
|
dispatch(importFetchedAccounts(data.flatMap(link => link.authors.map(author => author.account)).filter(account => !!account)));
|
||||||
|
dispatch(fetchTrendingLinksSuccess(data));
|
||||||
|
})
|
||||||
.catch(err => dispatch(fetchTrendingLinksFail(err)));
|
.catch(err => dispatch(fetchTrendingLinksFail(err)));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -59,16 +59,49 @@ export default function api(withAuthorization = true) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RequestParamsOrData = Record<string, unknown>;
|
||||||
|
|
||||||
export async function apiRequest<ApiResponse = unknown>(
|
export async function apiRequest<ApiResponse = unknown>(
|
||||||
method: Method,
|
method: Method,
|
||||||
url: string,
|
url: string,
|
||||||
params?: Record<string, unknown>,
|
args: {
|
||||||
|
params?: RequestParamsOrData;
|
||||||
|
data?: RequestParamsOrData;
|
||||||
|
} = {},
|
||||||
) {
|
) {
|
||||||
const { data } = await api().request<ApiResponse>({
|
const { data } = await api().request<ApiResponse>({
|
||||||
method,
|
method,
|
||||||
url: '/api/' + url,
|
url: '/api/' + url,
|
||||||
data: params,
|
...args,
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function apiRequestGet<ApiResponse = unknown>(
|
||||||
|
url: string,
|
||||||
|
params?: RequestParamsOrData,
|
||||||
|
) {
|
||||||
|
return apiRequest<ApiResponse>('GET', url, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiRequestPost<ApiResponse = unknown>(
|
||||||
|
url: string,
|
||||||
|
data?: RequestParamsOrData,
|
||||||
|
) {
|
||||||
|
return apiRequest<ApiResponse>('POST', url, { data });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiRequestPut<ApiResponse = unknown>(
|
||||||
|
url: string,
|
||||||
|
data?: RequestParamsOrData,
|
||||||
|
) {
|
||||||
|
return apiRequest<ApiResponse>('PUT', url, { data });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiRequestDelete<ApiResponse = unknown>(
|
||||||
|
url: string,
|
||||||
|
params?: RequestParamsOrData,
|
||||||
|
) {
|
||||||
|
return apiRequest<ApiResponse>('DELETE', url, { params });
|
||||||
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { apiRequest } from 'flavours/glitch/api';
|
import { apiRequestPost } from 'flavours/glitch/api';
|
||||||
import type { ApiRelationshipJSON } from 'flavours/glitch/api_types/relationships';
|
import type { ApiRelationshipJSON } from 'flavours/glitch/api_types/relationships';
|
||||||
|
|
||||||
export const apiSubmitAccountNote = (id: string, value: string) =>
|
export const apiSubmitAccountNote = (id: string, value: string) =>
|
||||||
apiRequest<ApiRelationshipJSON>('post', `v1/accounts/${id}/note`, {
|
apiRequestPost<ApiRelationshipJSON>(`v1/accounts/${id}/note`, {
|
||||||
comment: value,
|
comment: value,
|
||||||
});
|
});
|
||||||
|
15
app/javascript/flavours/glitch/api/directory.ts
Normal file
15
app/javascript/flavours/glitch/api/directory.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { apiRequestGet } from 'flavours/glitch/api';
|
||||||
|
import type { ApiAccountJSON } from 'flavours/glitch/api_types/accounts';
|
||||||
|
|
||||||
|
export const apiGetDirectory = (
|
||||||
|
params: {
|
||||||
|
order: string;
|
||||||
|
local: boolean;
|
||||||
|
offset?: number;
|
||||||
|
},
|
||||||
|
limit = 20,
|
||||||
|
) =>
|
||||||
|
apiRequestGet<ApiAccountJSON[]>('v1/directory', {
|
||||||
|
...params,
|
||||||
|
limit,
|
||||||
|
});
|
@ -1,10 +1,10 @@
|
|||||||
import { apiRequest } from 'flavours/glitch/api';
|
import { apiRequestPost } from 'flavours/glitch/api';
|
||||||
import type { Status, StatusVisibility } from 'flavours/glitch/models/status';
|
import type { Status, StatusVisibility } from 'flavours/glitch/models/status';
|
||||||
|
|
||||||
export const apiReblog = (statusId: string, visibility: StatusVisibility) =>
|
export const apiReblog = (statusId: string, visibility: StatusVisibility) =>
|
||||||
apiRequest<{ reblog: Status }>('post', `v1/statuses/${statusId}/reblog`, {
|
apiRequestPost<{ reblog: Status }>(`v1/statuses/${statusId}/reblog`, {
|
||||||
visibility,
|
visibility,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apiUnreblog = (statusId: string) =>
|
export const apiUnreblog = (statusId: string) =>
|
||||||
apiRequest<Status>('post', `v1/statuses/${statusId}/unreblog`);
|
apiRequestPost<Status>(`v1/statuses/${statusId}/unreblog`);
|
||||||
|
@ -0,0 +1,9 @@
|
|||||||
|
import { apiRequestGet, apiRequestPut } from 'flavours/glitch/api';
|
||||||
|
import type { NotificationPolicyJSON } from 'flavours/glitch/api_types/notification_policies';
|
||||||
|
|
||||||
|
export const apiGetNotificationPolicy = () =>
|
||||||
|
apiRequestGet<NotificationPolicyJSON>('/v1/notifications/policy');
|
||||||
|
|
||||||
|
export const apiUpdateNotificationsPolicy = (
|
||||||
|
policy: Partial<NotificationPolicyJSON>,
|
||||||
|
) => apiRequestPut<NotificationPolicyJSON>('/v1/notifications/policy', policy);
|
@ -0,0 +1,12 @@
|
|||||||
|
// See app/serializers/rest/notification_policy_serializer.rb
|
||||||
|
|
||||||
|
export interface NotificationPolicyJSON {
|
||||||
|
filter_not_following: boolean;
|
||||||
|
filter_not_followers: boolean;
|
||||||
|
filter_new_accounts: boolean;
|
||||||
|
filter_private_mentions: boolean;
|
||||||
|
summary: {
|
||||||
|
pending_requests_count: number;
|
||||||
|
pending_notifications_count: number;
|
||||||
|
};
|
||||||
|
}
|
@ -30,6 +30,12 @@ export interface ApiMentionJSON {
|
|||||||
acct: string;
|
acct: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ApiPreviewCardAuthorJSON {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
account?: ApiAccountJSON;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ApiPreviewCardJSON {
|
export interface ApiPreviewCardJSON {
|
||||||
url: string;
|
url: string;
|
||||||
title: string;
|
title: string;
|
||||||
@ -38,6 +44,7 @@ export interface ApiPreviewCardJSON {
|
|||||||
type: string;
|
type: string;
|
||||||
author_name: string;
|
author_name: string;
|
||||||
author_url: string;
|
author_url: string;
|
||||||
|
author_account?: ApiAccountJSON;
|
||||||
provider_name: string;
|
provider_name: string;
|
||||||
provider_url: string;
|
provider_url: string;
|
||||||
html: string;
|
html: string;
|
||||||
@ -48,6 +55,7 @@ export interface ApiPreviewCardJSON {
|
|||||||
embed_url: string;
|
embed_url: string;
|
||||||
blurhash: string;
|
blurhash: string;
|
||||||
published_at: string;
|
published_at: string;
|
||||||
|
authors: ApiPreviewCardAuthorJSON[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiStatusJSON {
|
export interface ApiStatusJSON {
|
||||||
|
20
app/javascript/flavours/glitch/components/account_bio.tsx
Normal file
20
app/javascript/flavours/glitch/components/account_bio.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { useLinks } from 'flavours/glitch/hooks/useLinks';
|
||||||
|
|
||||||
|
export const AccountBio: React.FC<{
|
||||||
|
note: string;
|
||||||
|
className: string;
|
||||||
|
}> = ({ note, className }) => {
|
||||||
|
const handleClick = useLinks();
|
||||||
|
|
||||||
|
if (note.length === 0 || note === '<p></p>') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${className} translate`}
|
||||||
|
dangerouslySetInnerHTML={{ __html: note }}
|
||||||
|
onClickCapture={handleClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
42
app/javascript/flavours/glitch/components/account_fields.tsx
Normal file
42
app/javascript/flavours/glitch/components/account_fields.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||||
|
import { Icon } from 'flavours/glitch/components/icon';
|
||||||
|
import { useLinks } from 'flavours/glitch/hooks/useLinks';
|
||||||
|
import type { Account } from 'flavours/glitch/models/account';
|
||||||
|
|
||||||
|
export const AccountFields: React.FC<{
|
||||||
|
fields: Account['fields'];
|
||||||
|
limit: number;
|
||||||
|
}> = ({ fields, limit = -1 }) => {
|
||||||
|
const handleClick = useLinks();
|
||||||
|
|
||||||
|
if (fields.size === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='account-fields' onClickCapture={handleClick}>
|
||||||
|
{fields.take(limit).map((pair, i) => (
|
||||||
|
<dl
|
||||||
|
key={i}
|
||||||
|
className={classNames({ verified: pair.get('verified_at') })}
|
||||||
|
>
|
||||||
|
<dt
|
||||||
|
dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }}
|
||||||
|
className='translate'
|
||||||
|
/>
|
||||||
|
|
||||||
|
<dd className='translate' title={pair.get('value_plain') ?? ''}>
|
||||||
|
{pair.get('verified_at') && (
|
||||||
|
<Icon id='check' icon={CheckIcon} className='verified__mark' />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }}
|
||||||
|
/>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -1,233 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import { PureComponent, useCallback } from 'react';
|
|
||||||
|
|
||||||
import { FormattedMessage, injectIntl, defineMessages, useIntl } from 'react-intl';
|
|
||||||
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import { withRouter } from 'react-router-dom';
|
|
||||||
|
|
||||||
import AddIcon from '@/material-icons/400-24px/add.svg?react';
|
|
||||||
import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.svg?react';
|
|
||||||
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
|
|
||||||
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
|
||||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
|
||||||
import SettingsIcon from '@/material-icons/400-24px/settings.svg?react';
|
|
||||||
import { Icon } from 'flavours/glitch/components/icon';
|
|
||||||
import { ButtonInTabsBar } from 'flavours/glitch/features/ui/util/columns_context';
|
|
||||||
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
|
|
||||||
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
|
|
||||||
|
|
||||||
|
|
||||||
import { useAppHistory } from './router';
|
|
||||||
|
|
||||||
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' },
|
|
||||||
back: { id: 'column_back_button.label', defaultMessage: 'Back' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const BackButton = ({ onlyIcon }) => {
|
|
||||||
const history = useAppHistory();
|
|
||||||
const intl = useIntl();
|
|
||||||
|
|
||||||
const handleBackClick = useCallback(() => {
|
|
||||||
if (history.location?.state?.fromMastodon) {
|
|
||||||
history.goBack();
|
|
||||||
} else {
|
|
||||||
history.push('/');
|
|
||||||
}
|
|
||||||
}, [history]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button onClick={handleBackClick} className={classNames('column-header__back-button', { 'compact': onlyIcon })} aria-label={intl.formatMessage(messages.back)}>
|
|
||||||
<Icon id='chevron-left' icon={ArrowBackIcon} className='column-back-button__icon' />
|
|
||||||
{!onlyIcon && <FormattedMessage id='column_back_button.label' defaultMessage='Back' />}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
BackButton.propTypes = {
|
|
||||||
onlyIcon: PropTypes.bool,
|
|
||||||
};
|
|
||||||
|
|
||||||
class ColumnHeader extends PureComponent {
|
|
||||||
static propTypes = {
|
|
||||||
identity: identityContextPropShape,
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
title: PropTypes.node,
|
|
||||||
icon: PropTypes.string,
|
|
||||||
iconComponent: PropTypes.func,
|
|
||||||
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,
|
|
||||||
...WithRouterPropTypes,
|
|
||||||
};
|
|
||||||
|
|
||||||
state = {
|
|
||||||
collapsed: true,
|
|
||||||
animating: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
handleTransitionEnd = () => {
|
|
||||||
this.setState({ animating: false });
|
|
||||||
};
|
|
||||||
|
|
||||||
handlePin = () => {
|
|
||||||
if (!this.props.pinned) {
|
|
||||||
this.props.history.replace('/');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.props.onPin();
|
|
||||||
};
|
|
||||||
|
|
||||||
render () {
|
|
||||||
const { title, icon, iconComponent, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent, collapseIssues, history } = 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 = (
|
|
||||||
<div key='extra-content' className='column-header__collapsible__extra'>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (multiColumn && pinned) {
|
|
||||||
pinButton = <button className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='times' icon={CloseIcon} /> <FormattedMessage id='column_header.unpin' defaultMessage='Unpin' /></button>;
|
|
||||||
|
|
||||||
moveButtons = (
|
|
||||||
<div className='column-header__setting-arrows'>
|
|
||||||
<button title={formatMessage(messages.moveLeft)} aria-label={formatMessage(messages.moveLeft)} className='icon-button column-header__setting-btn' onClick={this.handleMoveLeft}><Icon id='chevron-left' icon={ChevronLeftIcon} /></button>
|
|
||||||
<button title={formatMessage(messages.moveRight)} aria-label={formatMessage(messages.moveRight)} className='icon-button column-header__setting-btn' onClick={this.handleMoveRight}><Icon id='chevron-right' icon={ChevronRightIcon} /></button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else if (multiColumn && this.props.onPin) {
|
|
||||||
pinButton = <button className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='plus' icon={AddIcon} /> <FormattedMessage id='column_header.pin' defaultMessage='Pin' /></button>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (history && !pinned && ((multiColumn && history.location?.state?.fromMastodon) || showBackButton)) {
|
|
||||||
backButton = <BackButton onlyIcon={!!title} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const collapsedContent = [
|
|
||||||
extraContent,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (multiColumn) {
|
|
||||||
collapsedContent.push(
|
|
||||||
<div key='buttons' className='column-header__advanced-buttons'>
|
|
||||||
{pinButton}
|
|
||||||
{moveButtons}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.props.identity.signedIn && (children || (multiColumn && this.props.onPin))) {
|
|
||||||
collapseButton = (
|
|
||||||
<button
|
|
||||||
className={collapsibleButtonClassName}
|
|
||||||
title={formatMessage(collapsed ? messages.show : messages.hide)}
|
|
||||||
aria-label={formatMessage(collapsed ? messages.show : messages.hide)}
|
|
||||||
onClick={this.handleToggleClick}
|
|
||||||
>
|
|
||||||
<i className='icon-with-badge'>
|
|
||||||
<Icon id='sliders' icon={SettingsIcon} />
|
|
||||||
{collapseIssues && <i className='icon-with-badge__issue-badge' />}
|
|
||||||
</i>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasTitle = (icon || iconComponent) && title;
|
|
||||||
|
|
||||||
const component = (
|
|
||||||
<div className={wrapperClassName}>
|
|
||||||
<h1 className={buttonClassName}>
|
|
||||||
{hasTitle && (
|
|
||||||
<>
|
|
||||||
{backButton}
|
|
||||||
|
|
||||||
<button onClick={this.handleTitleClick} className='column-header__title'>
|
|
||||||
{!backButton && <Icon id={icon} icon={iconComponent} className='column-header__icon' />}
|
|
||||||
{title}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!hasTitle && backButton}
|
|
||||||
|
|
||||||
<div className='column-header__buttons'>
|
|
||||||
{extraButton}
|
|
||||||
{collapseButton}
|
|
||||||
</div>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div className={collapsibleClassName} tabIndex={collapsed ? -1 : null} onTransitionEnd={this.handleTransitionEnd}>
|
|
||||||
<div className='column-header__collapsible-inner'>
|
|
||||||
{(!collapsed || animating) && collapsedContent}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{appendContent}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (placeholder) {
|
|
||||||
return component;
|
|
||||||
} else {
|
|
||||||
return (<ButtonInTabsBar>
|
|
||||||
{component}
|
|
||||||
</ButtonInTabsBar>);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default injectIntl(withIdentity(withRouter(ColumnHeader)));
|
|
301
app/javascript/flavours/glitch/components/column_header.tsx
Normal file
301
app/javascript/flavours/glitch/components/column_header.tsx
Normal file
@ -0,0 +1,301 @@
|
|||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import AddIcon from '@/material-icons/400-24px/add.svg?react';
|
||||||
|
import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.svg?react';
|
||||||
|
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
|
||||||
|
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
||||||
|
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||||
|
import SettingsIcon from '@/material-icons/400-24px/settings.svg?react';
|
||||||
|
import type { IconProp } from 'flavours/glitch/components/icon';
|
||||||
|
import { Icon } from 'flavours/glitch/components/icon';
|
||||||
|
import { ButtonInTabsBar } from 'flavours/glitch/features/ui/util/columns_context';
|
||||||
|
import { useIdentity } from 'flavours/glitch/identity_context';
|
||||||
|
|
||||||
|
import { useAppHistory } from './router';
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
back: { id: 'column_back_button.label', defaultMessage: 'Back' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const BackButton: React.FC<{
|
||||||
|
onlyIcon: boolean;
|
||||||
|
}> = ({ onlyIcon }) => {
|
||||||
|
const history = useAppHistory();
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const handleBackClick = useCallback(() => {
|
||||||
|
if (history.location.state?.fromMastodon) {
|
||||||
|
history.goBack();
|
||||||
|
} else {
|
||||||
|
history.push('/');
|
||||||
|
}
|
||||||
|
}, [history]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleBackClick}
|
||||||
|
className={classNames('column-header__back-button', {
|
||||||
|
compact: onlyIcon,
|
||||||
|
})}
|
||||||
|
aria-label={intl.formatMessage(messages.back)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
id='chevron-left'
|
||||||
|
icon={ArrowBackIcon}
|
||||||
|
className='column-back-button__icon'
|
||||||
|
/>
|
||||||
|
{!onlyIcon && (
|
||||||
|
<FormattedMessage id='column_back_button.label' defaultMessage='Back' />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
title?: string;
|
||||||
|
icon?: string;
|
||||||
|
iconComponent?: IconProp;
|
||||||
|
active?: boolean;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
pinned?: boolean;
|
||||||
|
multiColumn?: boolean;
|
||||||
|
extraButton?: React.ReactNode;
|
||||||
|
showBackButton?: boolean;
|
||||||
|
placeholder?: boolean;
|
||||||
|
appendContent?: React.ReactNode;
|
||||||
|
collapseIssues?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
onMove?: (arg0: number) => void;
|
||||||
|
onPin?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ColumnHeader: React.FC<Props> = ({
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
iconComponent,
|
||||||
|
active,
|
||||||
|
children,
|
||||||
|
pinned,
|
||||||
|
multiColumn,
|
||||||
|
extraButton,
|
||||||
|
showBackButton,
|
||||||
|
placeholder,
|
||||||
|
appendContent,
|
||||||
|
collapseIssues,
|
||||||
|
onClick,
|
||||||
|
onMove,
|
||||||
|
onPin,
|
||||||
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const { signedIn } = useIdentity();
|
||||||
|
const history = useAppHistory();
|
||||||
|
const [collapsed, setCollapsed] = useState(true);
|
||||||
|
const [animating, setAnimating] = useState(false);
|
||||||
|
|
||||||
|
const handleToggleClick = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setCollapsed((value) => !value);
|
||||||
|
setAnimating(true);
|
||||||
|
},
|
||||||
|
[setCollapsed, setAnimating],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTitleClick = useCallback(() => {
|
||||||
|
onClick?.();
|
||||||
|
}, [onClick]);
|
||||||
|
|
||||||
|
const handleMoveLeft = useCallback(() => {
|
||||||
|
onMove?.(-1);
|
||||||
|
}, [onMove]);
|
||||||
|
|
||||||
|
const handleMoveRight = useCallback(() => {
|
||||||
|
onMove?.(1);
|
||||||
|
}, [onMove]);
|
||||||
|
|
||||||
|
const handleTransitionEnd = useCallback(() => {
|
||||||
|
setAnimating(false);
|
||||||
|
}, [setAnimating]);
|
||||||
|
|
||||||
|
const handlePin = useCallback(() => {
|
||||||
|
if (!pinned) {
|
||||||
|
history.replace('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
onPin?.();
|
||||||
|
}, [history, pinned, onPin]);
|
||||||
|
|
||||||
|
const wrapperClassName = classNames('column-header__wrapper', {
|
||||||
|
active,
|
||||||
|
});
|
||||||
|
|
||||||
|
const buttonClassName = classNames('column-header', {
|
||||||
|
active,
|
||||||
|
});
|
||||||
|
|
||||||
|
const collapsibleClassName = classNames('column-header__collapsible', {
|
||||||
|
collapsed,
|
||||||
|
animating,
|
||||||
|
});
|
||||||
|
|
||||||
|
const collapsibleButtonClassName = classNames('column-header__button', {
|
||||||
|
active: !collapsed,
|
||||||
|
});
|
||||||
|
|
||||||
|
let extraContent, pinButton, moveButtons, backButton, collapseButton;
|
||||||
|
|
||||||
|
if (children) {
|
||||||
|
extraContent = (
|
||||||
|
<div key='extra-content' className='column-header__collapsible__extra'>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (multiColumn && pinned) {
|
||||||
|
pinButton = (
|
||||||
|
<button
|
||||||
|
className='text-btn column-header__setting-btn'
|
||||||
|
onClick={handlePin}
|
||||||
|
>
|
||||||
|
<Icon id='times' icon={CloseIcon} />{' '}
|
||||||
|
<FormattedMessage id='column_header.unpin' defaultMessage='Unpin' />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
moveButtons = (
|
||||||
|
<div className='column-header__setting-arrows'>
|
||||||
|
<button
|
||||||
|
title={intl.formatMessage(messages.moveLeft)}
|
||||||
|
aria-label={intl.formatMessage(messages.moveLeft)}
|
||||||
|
className='icon-button column-header__setting-btn'
|
||||||
|
onClick={handleMoveLeft}
|
||||||
|
>
|
||||||
|
<Icon id='chevron-left' icon={ChevronLeftIcon} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
title={intl.formatMessage(messages.moveRight)}
|
||||||
|
aria-label={intl.formatMessage(messages.moveRight)}
|
||||||
|
className='icon-button column-header__setting-btn'
|
||||||
|
onClick={handleMoveRight}
|
||||||
|
>
|
||||||
|
<Icon id='chevron-right' icon={ChevronRightIcon} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (multiColumn && onPin) {
|
||||||
|
pinButton = (
|
||||||
|
<button
|
||||||
|
className='text-btn column-header__setting-btn'
|
||||||
|
onClick={handlePin}
|
||||||
|
>
|
||||||
|
<Icon id='plus' icon={AddIcon} />{' '}
|
||||||
|
<FormattedMessage id='column_header.pin' defaultMessage='Pin' />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!pinned &&
|
||||||
|
((multiColumn && history.location.state?.fromMastodon) || showBackButton)
|
||||||
|
) {
|
||||||
|
backButton = <BackButton onlyIcon={!!title} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const collapsedContent = [extraContent];
|
||||||
|
|
||||||
|
if (multiColumn) {
|
||||||
|
collapsedContent.push(
|
||||||
|
<div key='buttons' className='column-header__advanced-buttons'>
|
||||||
|
{pinButton}
|
||||||
|
{moveButtons}
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signedIn && (children || (multiColumn && onPin))) {
|
||||||
|
collapseButton = (
|
||||||
|
<button
|
||||||
|
className={collapsibleButtonClassName}
|
||||||
|
title={intl.formatMessage(collapsed ? messages.show : messages.hide)}
|
||||||
|
aria-label={intl.formatMessage(
|
||||||
|
collapsed ? messages.show : messages.hide,
|
||||||
|
)}
|
||||||
|
onClick={handleToggleClick}
|
||||||
|
>
|
||||||
|
<i className='icon-with-badge'>
|
||||||
|
<Icon id='sliders' icon={SettingsIcon} />
|
||||||
|
{collapseIssues && <i className='icon-with-badge__issue-badge' />}
|
||||||
|
</i>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasIcon = icon && iconComponent;
|
||||||
|
const hasTitle = hasIcon && title;
|
||||||
|
|
||||||
|
const component = (
|
||||||
|
<div className={wrapperClassName}>
|
||||||
|
<h1 className={buttonClassName}>
|
||||||
|
{hasTitle && (
|
||||||
|
<>
|
||||||
|
{backButton}
|
||||||
|
|
||||||
|
<button onClick={handleTitleClick} className='column-header__title'>
|
||||||
|
{!backButton && (
|
||||||
|
<Icon
|
||||||
|
id={icon}
|
||||||
|
icon={iconComponent}
|
||||||
|
className='column-header__icon'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{title}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasTitle && backButton}
|
||||||
|
|
||||||
|
<div className='column-header__buttons'>
|
||||||
|
{extraButton}
|
||||||
|
{collapseButton}
|
||||||
|
</div>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={collapsibleClassName}
|
||||||
|
tabIndex={collapsed ? -1 : undefined}
|
||||||
|
onTransitionEnd={handleTransitionEnd}
|
||||||
|
>
|
||||||
|
<div className='column-header__collapsible-inner'>
|
||||||
|
{(!collapsed || animating) && collapsedContent}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{appendContent}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (placeholder) {
|
||||||
|
return component;
|
||||||
|
} else {
|
||||||
|
return <ButtonInTabsBar>{component}</ButtonInTabsBar>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/no-default-export
|
||||||
|
export default ColumnHeader;
|
125
app/javascript/flavours/glitch/components/follow_button.tsx
Normal file
125
app/javascript/flavours/glitch/components/follow_button.tsx
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import { useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
|
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { useIdentity } from '@/flavours/glitch/identity_context';
|
||||||
|
import {
|
||||||
|
fetchRelationships,
|
||||||
|
followAccount,
|
||||||
|
unfollowAccount,
|
||||||
|
} from 'flavours/glitch/actions/accounts';
|
||||||
|
import { openModal } from 'flavours/glitch/actions/modal';
|
||||||
|
import { Button } from 'flavours/glitch/components/button';
|
||||||
|
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
||||||
|
import { me } from 'flavours/glitch/initial_state';
|
||||||
|
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
||||||
|
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
||||||
|
followBack: { id: 'account.follow_back', defaultMessage: 'Follow back' },
|
||||||
|
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const FollowButton: React.FC<{
|
||||||
|
accountId?: string;
|
||||||
|
}> = ({ accountId }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { signedIn } = useIdentity();
|
||||||
|
const account = useAppSelector((state) =>
|
||||||
|
accountId ? state.accounts.get(accountId) : undefined,
|
||||||
|
);
|
||||||
|
const relationship = useAppSelector((state) =>
|
||||||
|
accountId ? state.relationships.get(accountId) : undefined,
|
||||||
|
);
|
||||||
|
const following = relationship?.following || relationship?.requested;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (accountId && signedIn) {
|
||||||
|
dispatch(fetchRelationships([accountId]));
|
||||||
|
}
|
||||||
|
}, [dispatch, accountId, signedIn]);
|
||||||
|
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
if (!signedIn) {
|
||||||
|
dispatch(
|
||||||
|
openModal({
|
||||||
|
modalType: 'INTERACTION',
|
||||||
|
modalProps: {
|
||||||
|
type: 'follow',
|
||||||
|
accountId: accountId,
|
||||||
|
url: account?.url,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!relationship) return;
|
||||||
|
|
||||||
|
if (accountId === me) {
|
||||||
|
return;
|
||||||
|
} else if (relationship.following || relationship.requested) {
|
||||||
|
dispatch(
|
||||||
|
openModal({
|
||||||
|
modalType: 'CONFIRM',
|
||||||
|
modalProps: {
|
||||||
|
message: (
|
||||||
|
<FormattedMessage
|
||||||
|
id='confirmations.unfollow.message'
|
||||||
|
defaultMessage='Are you sure you want to unfollow {name}?'
|
||||||
|
values={{ name: <strong>@{account?.acct}</strong> }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
confirm: intl.formatMessage(messages.unfollow),
|
||||||
|
onConfirm: () => {
|
||||||
|
dispatch(unfollowAccount(accountId));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
dispatch(followAccount(accountId));
|
||||||
|
}
|
||||||
|
}, [dispatch, intl, accountId, relationship, account, signedIn]);
|
||||||
|
|
||||||
|
let label;
|
||||||
|
|
||||||
|
if (!signedIn) {
|
||||||
|
label = intl.formatMessage(messages.follow);
|
||||||
|
} else if (accountId === me) {
|
||||||
|
label = intl.formatMessage(messages.edit_profile);
|
||||||
|
} else if (!relationship) {
|
||||||
|
label = <LoadingIndicator />;
|
||||||
|
} else if (!relationship.following && relationship.followed_by) {
|
||||||
|
label = intl.formatMessage(messages.followBack);
|
||||||
|
} else if (relationship.following || relationship.requested) {
|
||||||
|
label = intl.formatMessage(messages.unfollow);
|
||||||
|
} else {
|
||||||
|
label = intl.formatMessage(messages.follow);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accountId === me) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href='/settings/profile'
|
||||||
|
target='_blank'
|
||||||
|
rel='noreferrer noopener'
|
||||||
|
className='button button-secondary'
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
onClick={handleClick}
|
||||||
|
disabled={relationship?.blocked_by || relationship?.blocking}
|
||||||
|
secondary={following}
|
||||||
|
className={following ? 'button--destructive' : undefined}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,78 @@
|
|||||||
|
import { useEffect, forwardRef } from 'react';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import { fetchAccount } from 'flavours/glitch/actions/accounts';
|
||||||
|
import { AccountBio } from 'flavours/glitch/components/account_bio';
|
||||||
|
import { AccountFields } from 'flavours/glitch/components/account_fields';
|
||||||
|
import { Avatar } from 'flavours/glitch/components/avatar';
|
||||||
|
import { FollowersCounter } from 'flavours/glitch/components/counters';
|
||||||
|
import { DisplayName } from 'flavours/glitch/components/display_name';
|
||||||
|
import { FollowButton } from 'flavours/glitch/components/follow_button';
|
||||||
|
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
||||||
|
import { Permalink } from 'flavours/glitch/components/permalink';
|
||||||
|
import { ShortNumber } from 'flavours/glitch/components/short_number';
|
||||||
|
import { domain } from 'flavours/glitch/initial_state';
|
||||||
|
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
|
||||||
|
|
||||||
|
export const HoverCardAccount = forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
{ accountId?: string }
|
||||||
|
>(({ accountId }, ref) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const account = useAppSelector((state) =>
|
||||||
|
accountId ? state.accounts.get(accountId) : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (accountId && !account) {
|
||||||
|
dispatch(fetchAccount(accountId));
|
||||||
|
}
|
||||||
|
}, [dispatch, accountId, account]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
id='hover-card'
|
||||||
|
role='tooltip'
|
||||||
|
className={classNames('hover-card dropdown-animation', {
|
||||||
|
'hover-card--loading': !account,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{account ? (
|
||||||
|
<>
|
||||||
|
<Permalink
|
||||||
|
to={`/@${account.acct}`}
|
||||||
|
href={account.get('url')}
|
||||||
|
className='hover-card__name'
|
||||||
|
>
|
||||||
|
<Avatar account={account} size={46} />
|
||||||
|
<DisplayName account={account} localDomain={domain} />
|
||||||
|
</Permalink>
|
||||||
|
|
||||||
|
<div className='hover-card__text-row'>
|
||||||
|
<AccountBio
|
||||||
|
note={account.note_emojified}
|
||||||
|
className='hover-card__bio'
|
||||||
|
/>
|
||||||
|
<AccountFields fields={account.fields} limit={2} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='hover-card__number'>
|
||||||
|
<ShortNumber
|
||||||
|
value={account.followers_count}
|
||||||
|
renderer={FollowersCounter}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FollowButton accountId={accountId} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<LoadingIndicator />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
HoverCardAccount.displayName = 'HoverCardAccount';
|
@ -0,0 +1,176 @@
|
|||||||
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
import Overlay from 'react-overlays/Overlay';
|
||||||
|
import type {
|
||||||
|
OffsetValue,
|
||||||
|
UsePopperOptions,
|
||||||
|
} from 'react-overlays/esm/usePopper';
|
||||||
|
|
||||||
|
import { HoverCardAccount } from 'flavours/glitch/components/hover_card_account';
|
||||||
|
import { useTimeout } from 'flavours/glitch/hooks/useTimeout';
|
||||||
|
|
||||||
|
const offset = [-12, 4] as OffsetValue;
|
||||||
|
const enterDelay = 750;
|
||||||
|
const leaveDelay = 150;
|
||||||
|
const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
|
||||||
|
|
||||||
|
const isHoverCardAnchor = (element: HTMLElement) =>
|
||||||
|
element.matches('[data-hover-card-account]');
|
||||||
|
|
||||||
|
export const HoverCardController: React.FC = () => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [accountId, setAccountId] = useState<string | undefined>();
|
||||||
|
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
|
||||||
|
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [setLeaveTimeout, cancelLeaveTimeout] = useTimeout();
|
||||||
|
const [setEnterTimeout, cancelEnterTimeout, delayEnterTimeout] = useTimeout();
|
||||||
|
const [setScrollTimeout] = useTimeout();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
cancelEnterTimeout();
|
||||||
|
cancelLeaveTimeout();
|
||||||
|
setOpen(false);
|
||||||
|
setAnchor(null);
|
||||||
|
}, [cancelEnterTimeout, cancelLeaveTimeout, setOpen, setAnchor]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
handleClose();
|
||||||
|
}, [handleClose, location]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isScrolling = false;
|
||||||
|
let currentAnchor: HTMLElement | null = null;
|
||||||
|
|
||||||
|
const open = (target: HTMLElement) => {
|
||||||
|
target.setAttribute('aria-describedby', 'hover-card');
|
||||||
|
setOpen(true);
|
||||||
|
setAnchor(target);
|
||||||
|
setAccountId(target.getAttribute('data-hover-card-account') ?? undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
currentAnchor?.removeAttribute('aria-describedby');
|
||||||
|
currentAnchor = null;
|
||||||
|
setOpen(false);
|
||||||
|
setAnchor(null);
|
||||||
|
setAccountId(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseEnter = (e: MouseEvent) => {
|
||||||
|
const { target } = e;
|
||||||
|
|
||||||
|
// We've exited the window
|
||||||
|
if (!(target instanceof HTMLElement)) {
|
||||||
|
close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We've entered an anchor
|
||||||
|
if (!isScrolling && isHoverCardAnchor(target)) {
|
||||||
|
cancelLeaveTimeout();
|
||||||
|
|
||||||
|
currentAnchor?.removeAttribute('aria-describedby');
|
||||||
|
currentAnchor = target;
|
||||||
|
|
||||||
|
setEnterTimeout(() => {
|
||||||
|
open(target);
|
||||||
|
}, enterDelay);
|
||||||
|
}
|
||||||
|
|
||||||
|
// We've entered the hover card
|
||||||
|
if (
|
||||||
|
!isScrolling &&
|
||||||
|
(target === currentAnchor || target === cardRef.current)
|
||||||
|
) {
|
||||||
|
cancelLeaveTimeout();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = (e: MouseEvent) => {
|
||||||
|
if (!currentAnchor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.target === currentAnchor || e.target === cardRef.current) {
|
||||||
|
cancelEnterTimeout();
|
||||||
|
|
||||||
|
setLeaveTimeout(() => {
|
||||||
|
close();
|
||||||
|
}, leaveDelay);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScrollEnd = () => {
|
||||||
|
isScrolling = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
isScrolling = true;
|
||||||
|
cancelEnterTimeout();
|
||||||
|
setScrollTimeout(handleScrollEnd, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = () => {
|
||||||
|
delayEnterTimeout(enterDelay);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.body.addEventListener('mouseenter', handleMouseEnter, {
|
||||||
|
passive: true,
|
||||||
|
capture: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.addEventListener('mousemove', handleMouseMove, {
|
||||||
|
passive: true,
|
||||||
|
capture: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.addEventListener('mouseleave', handleMouseLeave, {
|
||||||
|
passive: true,
|
||||||
|
capture: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('scroll', handleScroll, {
|
||||||
|
passive: true,
|
||||||
|
capture: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.body.removeEventListener('mouseenter', handleMouseEnter);
|
||||||
|
document.body.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.body.removeEventListener('mouseleave', handleMouseLeave);
|
||||||
|
document.removeEventListener('scroll', handleScroll);
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
setEnterTimeout,
|
||||||
|
setLeaveTimeout,
|
||||||
|
setScrollTimeout,
|
||||||
|
cancelEnterTimeout,
|
||||||
|
cancelLeaveTimeout,
|
||||||
|
delayEnterTimeout,
|
||||||
|
setOpen,
|
||||||
|
setAccountId,
|
||||||
|
setAnchor,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Overlay
|
||||||
|
rootClose
|
||||||
|
onHide={handleClose}
|
||||||
|
show={open}
|
||||||
|
target={anchor}
|
||||||
|
placement='bottom-start'
|
||||||
|
flip
|
||||||
|
offset={offset}
|
||||||
|
popperConfig={popperConfig}
|
||||||
|
>
|
||||||
|
{({ props }) => (
|
||||||
|
<div {...props} className='hover-card-controller'>
|
||||||
|
<HoverCardAccount accountId={accountId} ref={cardRef} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Overlay>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,19 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { AuthorLink } from 'flavours/glitch/features/explore/components/author_link';
|
||||||
|
|
||||||
|
export const MoreFromAuthor = ({ accountId }) => (
|
||||||
|
<div className='more-from-author'>
|
||||||
|
<svg viewBox='0 0 79 79' className='logo logo--icon' role='img'>
|
||||||
|
<use xlinkHref='#logo-symbol-icon' />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<FormattedMessage id='link_preview.more_from_author' defaultMessage='More from {name}' values={{ name: <AuthorLink accountId={accountId} /> }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
MoreFromAuthor.propTypes = {
|
||||||
|
accountId: PropTypes.string.isRequired,
|
||||||
|
};
|
@ -42,10 +42,12 @@ class ServerBanner extends PureComponent {
|
|||||||
return (
|
return (
|
||||||
<div className='server-banner'>
|
<div className='server-banner'>
|
||||||
<div className='server-banner__introduction'>
|
<div className='server-banner__introduction'>
|
||||||
<FormattedMessage id='server_banner.introduction' defaultMessage='{domain} is part of the decentralized social network powered by {mastodon}.' values={{ domain: <strong>{domain}</strong>, mastodon: <a href='https://joinmastodon.org' target='_blank'>Mastodon</a> }} />
|
<FormattedMessage id='server_banner.is_one_of_many' defaultMessage='{domain} is one of the many independent Mastodon servers you can use to participate in the fediverse.' values={{ domain: <strong>{domain}</strong>, mastodon: <a href='https://joinmastodon.org' target='_blank'>Mastodon</a> }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ServerHeroImage blurhash={server.getIn(['thumbnail', 'blurhash'])} src={server.getIn(['thumbnail', 'url'])} className='server-banner__hero' />
|
<Link to='/about'>
|
||||||
|
<ServerHeroImage blurhash={server.getIn(['thumbnail', 'blurhash'])} src={server.getIn(['thumbnail', 'url'])} className='server-banner__hero' />
|
||||||
|
</Link>
|
||||||
|
|
||||||
<div className='server-banner__description'>
|
<div className='server-banner__description'>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@ -84,10 +86,6 @@ class ServerBanner extends PureComponent {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr className='spacer' />
|
|
||||||
|
|
||||||
<Link className='button button--block button-secondary' to='/about'><FormattedMessage id='server_banner.learn_more' defaultMessage='Learn more' /></Link>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -810,7 +810,7 @@ class Status extends ImmutablePureComponent {
|
|||||||
{prepend}
|
{prepend}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), 'status--in-thread': !!rootId, 'status--first-in-thread': previousId && (!connectUp || connectToRoot), muted: this.props.muted, 'has-background': isCollapsed && background, collapsed: isCollapsed })}
|
className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), 'status--in-thread': !!rootId, 'status--first-in-thread': previousId && (!connectUp || connectToRoot), muted: this.props.muted, 'has-background': isCollapsed && background })}
|
||||||
data-id={status.get('id')}
|
data-id={status.get('id')}
|
||||||
style={isCollapsed && background ? { backgroundImage: `url(${background})` } : null}
|
style={isCollapsed && background ? { backgroundImage: `url(${background})` } : null}
|
||||||
>
|
>
|
||||||
|
@ -181,7 +181,8 @@ class StatusContent extends PureComponent {
|
|||||||
|
|
||||||
if (mention) {
|
if (mention) {
|
||||||
link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
|
link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
|
||||||
link.setAttribute('title', `@${mention.get('acct')}`);
|
link.removeAttribute('title');
|
||||||
|
link.setAttribute('data-hover-card-account', mention.get('id'));
|
||||||
if (rewriteMentions !== 'no') {
|
if (rewriteMentions !== 'no') {
|
||||||
while (link.firstChild) link.removeChild(link.firstChild);
|
while (link.firstChild) link.removeChild(link.firstChild);
|
||||||
link.appendChild(document.createTextNode('@'));
|
link.appendChild(document.createTextNode('@'));
|
||||||
|
@ -51,6 +51,7 @@ export default class StatusHeader extends PureComponent {
|
|||||||
target='_blank'
|
target='_blank'
|
||||||
onClick={this.handleAccountClick}
|
onClick={this.handleAccountClick}
|
||||||
rel='noopener noreferrer'
|
rel='noopener noreferrer'
|
||||||
|
data-hover-card-account={status.getIn(['account', 'id'])}
|
||||||
>
|
>
|
||||||
<div className='status__avatar'>
|
<div className='status__avatar'>
|
||||||
{statusAvatar}
|
{statusAvatar}
|
||||||
|
@ -33,6 +33,7 @@ export default class StatusList extends ImmutablePureComponent {
|
|||||||
withCounters: PropTypes.bool,
|
withCounters: PropTypes.bool,
|
||||||
timelineId: PropTypes.string.isRequired,
|
timelineId: PropTypes.string.isRequired,
|
||||||
lastId: PropTypes.string,
|
lastId: PropTypes.string,
|
||||||
|
bindToDocument: PropTypes.bool,
|
||||||
regex: PropTypes.string,
|
regex: PropTypes.string,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -40,6 +40,7 @@ export default class StatusPrepend extends PureComponent {
|
|||||||
onClick={this.handleClick}
|
onClick={this.handleClick}
|
||||||
href={account.get('url')}
|
href={account.get('url')}
|
||||||
className='status__display-name'
|
className='status__display-name'
|
||||||
|
data-hover-card-account={account.get('id')}
|
||||||
>
|
>
|
||||||
<bdi>
|
<bdi>
|
||||||
<strong
|
<strong
|
||||||
|
@ -23,7 +23,6 @@ import { makeGetAccount, getAccountHidden } from '../../../selectors';
|
|||||||
import Header from '../components/header';
|
import Header from '../components/header';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
cancelFollowRequestConfirm: { id: 'confirmations.cancel_follow_request.confirm', defaultMessage: 'Withdraw request' },
|
|
||||||
unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
|
unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
|
||||||
blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Block entire domain' },
|
blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Block entire domain' },
|
||||||
});
|
});
|
||||||
@ -43,7 +42,7 @@ const makeMapStateToProps = () => {
|
|||||||
const mapDispatchToProps = (dispatch, { intl }) => ({
|
const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||||
|
|
||||||
onFollow (account) {
|
onFollow (account) {
|
||||||
if (account.getIn(['relationship', 'following'])) {
|
if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
|
||||||
dispatch(openModal({
|
dispatch(openModal({
|
||||||
modalType: 'CONFIRM',
|
modalType: 'CONFIRM',
|
||||||
modalProps: {
|
modalProps: {
|
||||||
@ -52,15 +51,6 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
|||||||
onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
|
onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
} else if (account.getIn(['relationship', 'requested'])) {
|
|
||||||
dispatch(openModal({
|
|
||||||
modalType: 'CONFIRM',
|
|
||||||
modalProps: {
|
|
||||||
message: <FormattedMessage id='confirmations.cancel_follow_request.message' defaultMessage='Are you sure you want to withdraw your request to follow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
|
|
||||||
confirm: intl.formatMessage(messages.cancelFollowRequestConfirm),
|
|
||||||
onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
} else {
|
} else {
|
||||||
dispatch(followAccount(account.get('id')));
|
dispatch(followAccount(account.get('id')));
|
||||||
}
|
}
|
||||||
|
@ -110,18 +110,6 @@ class LanguageDropdownMenu extends PureComponent {
|
|||||||
}).map(result => result.obj);
|
}).map(result => result.obj);
|
||||||
}
|
}
|
||||||
|
|
||||||
frequentlyUsed () {
|
|
||||||
const { languages, value } = this.props;
|
|
||||||
const current = languages.find(lang => lang[0] === value);
|
|
||||||
const results = [];
|
|
||||||
|
|
||||||
if (current) {
|
|
||||||
results.push(current);
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleClick = e => {
|
handleClick = e => {
|
||||||
const value = e.currentTarget.getAttribute('data-index');
|
const value = e.currentTarget.getAttribute('data-index');
|
||||||
|
|
||||||
|
@ -185,7 +185,7 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
|
|||||||
menu.push({ text: intl.formatMessage(messages.delete), action: handleDelete });
|
menu.push({ text: intl.formatMessage(messages.delete), action: handleDelete });
|
||||||
|
|
||||||
const names = accounts.map(a => (
|
const names = accounts.map(a => (
|
||||||
<Permalink to={`/@${a.get('acct')}`} href={a.get('url')} key={a.get('id')} title={a.get('acct')}>
|
<Permalink to={`/@${a.get('acct')}`} href={a.get('url')} key={a.get('id')} data-hover-card-account={a.get('id')}>
|
||||||
<bdi>
|
<bdi>
|
||||||
<strong
|
<strong
|
||||||
className='display-name__html'
|
className='display-name__html'
|
||||||
|
@ -1,247 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
|
|
||||||
|
|
||||||
import classNames from 'classnames';
|
|
||||||
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import {
|
|
||||||
followAccount,
|
|
||||||
unfollowAccount,
|
|
||||||
unblockAccount,
|
|
||||||
unmuteAccount,
|
|
||||||
} from 'flavours/glitch/actions/accounts';
|
|
||||||
import { openModal } from 'flavours/glitch/actions/modal';
|
|
||||||
import { Avatar } from 'flavours/glitch/components/avatar';
|
|
||||||
import { Button } from 'flavours/glitch/components/button';
|
|
||||||
import { DisplayName } from 'flavours/glitch/components/display_name';
|
|
||||||
import { IconButton } from 'flavours/glitch/components/icon_button';
|
|
||||||
import { Permalink } from 'flavours/glitch/components/permalink';
|
|
||||||
import { ShortNumber } from 'flavours/glitch/components/short_number';
|
|
||||||
import { autoPlayGif, me } from 'flavours/glitch/initial_state';
|
|
||||||
import { makeGetAccount } from 'flavours/glitch/selectors';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
|
||||||
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
|
||||||
cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Withdraw follow request' },
|
|
||||||
cancelFollowRequestConfirm: { id: 'confirmations.cancel_follow_request.confirm', defaultMessage: 'Withdraw request' },
|
|
||||||
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' },
|
|
||||||
unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' },
|
|
||||||
unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' },
|
|
||||||
unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
|
|
||||||
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
|
|
||||||
dismissSuggestion: { id: 'suggestions.dismiss', defaultMessage: 'Dismiss suggestion' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const makeMapStateToProps = () => {
|
|
||||||
const getAccount = makeGetAccount();
|
|
||||||
|
|
||||||
const mapStateToProps = (state, { id }) => ({
|
|
||||||
account: getAccount(state, id),
|
|
||||||
});
|
|
||||||
|
|
||||||
return mapStateToProps;
|
|
||||||
};
|
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch, { intl }) => ({
|
|
||||||
onFollow(account) {
|
|
||||||
if (account.getIn(['relationship', 'following'])) {
|
|
||||||
dispatch(
|
|
||||||
openModal({
|
|
||||||
modalType: 'CONFIRM',
|
|
||||||
modalProps: {
|
|
||||||
message: (
|
|
||||||
<FormattedMessage
|
|
||||||
id='confirmations.unfollow.message'
|
|
||||||
defaultMessage='Are you sure you want to unfollow {name}?'
|
|
||||||
values={{ name: <strong>@{account.get('acct')}</strong> }}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
confirm: intl.formatMessage(messages.unfollowConfirm),
|
|
||||||
onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
|
|
||||||
} }),
|
|
||||||
);
|
|
||||||
} else if (account.getIn(['relationship', 'requested'])) {
|
|
||||||
dispatch(openModal({
|
|
||||||
modalType: 'CONFIRM',
|
|
||||||
modalProps: {
|
|
||||||
message: <FormattedMessage id='confirmations.cancel_follow_request.message' defaultMessage='Are you sure you want to withdraw your request to follow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
|
|
||||||
confirm: intl.formatMessage(messages.cancelFollowRequestConfirm),
|
|
||||||
onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
dispatch(followAccount(account.get('id')));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
onBlock(account) {
|
|
||||||
if (account.getIn(['relationship', 'blocking'])) {
|
|
||||||
dispatch(unblockAccount(account.get('id')));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
onMute(account) {
|
|
||||||
if (account.getIn(['relationship', 'muting'])) {
|
|
||||||
dispatch(unmuteAccount(account.get('id')));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
class AccountCard extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
account: ImmutablePropTypes.record.isRequired,
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
onFollow: PropTypes.func.isRequired,
|
|
||||||
onBlock: PropTypes.func.isRequired,
|
|
||||||
onMute: PropTypes.func.isRequired,
|
|
||||||
onDismiss: 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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleFollow = () => {
|
|
||||||
this.props.onFollow(this.props.account);
|
|
||||||
};
|
|
||||||
|
|
||||||
handleBlock = () => {
|
|
||||||
this.props.onBlock(this.props.account);
|
|
||||||
};
|
|
||||||
|
|
||||||
handleMute = () => {
|
|
||||||
this.props.onMute(this.props.account);
|
|
||||||
};
|
|
||||||
|
|
||||||
handleEditProfile = () => {
|
|
||||||
window.open('/settings/profile', '_blank');
|
|
||||||
};
|
|
||||||
|
|
||||||
handleDismiss = (e) => {
|
|
||||||
const { account, onDismiss } = this.props;
|
|
||||||
onDismiss(account.get('id'));
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { account, intl } = this.props;
|
|
||||||
|
|
||||||
let actionBtn;
|
|
||||||
|
|
||||||
if (me !== account.get('id')) {
|
|
||||||
if (!account.get('relationship')) { // Wait until the relationship is loaded
|
|
||||||
actionBtn = '';
|
|
||||||
} else if (account.getIn(['relationship', 'requested'])) {
|
|
||||||
actionBtn = <Button text={intl.formatMessage(messages.cancel_follow_request)} title={intl.formatMessage(messages.requested)} onClick={this.handleFollow} />;
|
|
||||||
} else if (account.getIn(['relationship', 'muting'])) {
|
|
||||||
actionBtn = <Button text={intl.formatMessage(messages.unmute)} onClick={this.handleMute} />;
|
|
||||||
} else if (!account.getIn(['relationship', 'blocking'])) {
|
|
||||||
actionBtn = <Button disabled={account.getIn(['relationship', 'blocked_by'])} className={classNames({ 'button--destructive': account.getIn(['relationship', 'following']) })} text={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.handleFollow} />;
|
|
||||||
} else if (account.getIn(['relationship', 'blocking'])) {
|
|
||||||
actionBtn = <Button text={intl.formatMessage(messages.unblock)} onClick={this.handleBlock} />;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
actionBtn = <Button text={intl.formatMessage(messages.edit_profile)} onClick={this.handleEditProfile} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='account-card'>
|
|
||||||
<Permalink href={account.get('url')} to={`/@${account.get('acct')}`} className='account-card__permalink'>
|
|
||||||
<div className='account-card__header'>
|
|
||||||
{this.props.onDismiss && <IconButton className='media-modal__close' title={intl.formatMessage(messages.dismissSuggestion)} icon='times' onClick={this.handleDismiss} size={20} />}
|
|
||||||
|
|
||||||
<img
|
|
||||||
src={
|
|
||||||
autoPlayGif ? account.get('header') : account.get('header_static')
|
|
||||||
}
|
|
||||||
alt=''
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='account-card__title'>
|
|
||||||
<div className='account-card__title__avatar'><Avatar account={account} size={56} /></div>
|
|
||||||
<DisplayName account={account} />
|
|
||||||
</div>
|
|
||||||
</Permalink>
|
|
||||||
|
|
||||||
{account.get('note').length > 0 && (
|
|
||||||
<div
|
|
||||||
className='account-card__bio translate'
|
|
||||||
onMouseEnter={this.handleMouseEnter}
|
|
||||||
onMouseLeave={this.handleMouseLeave}
|
|
||||||
dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className='account-card__actions'>
|
|
||||||
<div className='account-card__counters'>
|
|
||||||
<div className='account-card__counters__item'>
|
|
||||||
<ShortNumber value={account.get('statuses_count')} />
|
|
||||||
<small>
|
|
||||||
<FormattedMessage id='account.posts' defaultMessage='Posts' />
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='account-card__counters__item'>
|
|
||||||
{account.get('followers_count') < 0 ? '-' : <ShortNumber value={account.get('followers_count')} />}{' '}
|
|
||||||
<small>
|
|
||||||
<FormattedMessage
|
|
||||||
id='account.followers'
|
|
||||||
defaultMessage='Followers'
|
|
||||||
/>
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='account-card__counters__item'>
|
|
||||||
<ShortNumber value={account.get('following_count')} />{' '}
|
|
||||||
<small>
|
|
||||||
<FormattedMessage
|
|
||||||
id='account.following'
|
|
||||||
defaultMessage='Following'
|
|
||||||
/>
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='account-card__actions__button'>
|
|
||||||
{actionBtn}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(AccountCard));
|
|
@ -0,0 +1,273 @@
|
|||||||
|
import type { MouseEventHandler } from 'react';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import {
|
||||||
|
followAccount,
|
||||||
|
unfollowAccount,
|
||||||
|
unblockAccount,
|
||||||
|
unmuteAccount,
|
||||||
|
} from 'flavours/glitch/actions/accounts';
|
||||||
|
import { openModal } from 'flavours/glitch/actions/modal';
|
||||||
|
import { Avatar } from 'flavours/glitch/components/avatar';
|
||||||
|
import { Button } from 'flavours/glitch/components/button';
|
||||||
|
import { DisplayName } from 'flavours/glitch/components/display_name';
|
||||||
|
import { Permalink } from 'flavours/glitch/components/permalink';
|
||||||
|
import { ShortNumber } from 'flavours/glitch/components/short_number';
|
||||||
|
import { autoPlayGif, me } from 'flavours/glitch/initial_state';
|
||||||
|
import type { Account } from 'flavours/glitch/models/account';
|
||||||
|
import { makeGetAccount } from 'flavours/glitch/selectors';
|
||||||
|
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
||||||
|
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
||||||
|
cancel_follow_request: {
|
||||||
|
id: 'account.cancel_follow_request',
|
||||||
|
defaultMessage: 'Withdraw follow request',
|
||||||
|
},
|
||||||
|
cancelFollowRequestConfirm: {
|
||||||
|
id: 'confirmations.cancel_follow_request.confirm',
|
||||||
|
defaultMessage: 'Withdraw request',
|
||||||
|
},
|
||||||
|
requested: {
|
||||||
|
id: 'account.requested',
|
||||||
|
defaultMessage: 'Awaiting approval. Click to cancel follow request',
|
||||||
|
},
|
||||||
|
unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' },
|
||||||
|
unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' },
|
||||||
|
unfollowConfirm: {
|
||||||
|
id: 'confirmations.unfollow.confirm',
|
||||||
|
defaultMessage: 'Unfollow',
|
||||||
|
},
|
||||||
|
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const getAccount = makeGetAccount();
|
||||||
|
|
||||||
|
export const AccountCard: React.FC<{ accountId: string }> = ({ accountId }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const account = useAppSelector((s) => getAccount(s, accountId));
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const handleMouseEnter = useCallback<MouseEventHandler>(
|
||||||
|
({ currentTarget }) => {
|
||||||
|
if (autoPlayGif) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const emojis =
|
||||||
|
currentTarget.querySelectorAll<HTMLImageElement>('.custom-emoji');
|
||||||
|
|
||||||
|
emojis.forEach((emoji) => {
|
||||||
|
const original = emoji.getAttribute('data-original');
|
||||||
|
if (original) emoji.src = original;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMouseLeave = useCallback<MouseEventHandler>(
|
||||||
|
({ currentTarget }) => {
|
||||||
|
if (autoPlayGif) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emojis =
|
||||||
|
currentTarget.querySelectorAll<HTMLImageElement>('.custom-emoji');
|
||||||
|
|
||||||
|
emojis.forEach((emoji) => {
|
||||||
|
const staticUrl = emoji.getAttribute('data-static');
|
||||||
|
if (staticUrl) emoji.src = staticUrl;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFollow = useCallback(() => {
|
||||||
|
if (!account) return;
|
||||||
|
|
||||||
|
if (account.getIn(['relationship', 'following'])) {
|
||||||
|
dispatch(
|
||||||
|
openModal({
|
||||||
|
modalType: 'CONFIRM',
|
||||||
|
modalProps: {
|
||||||
|
message: (
|
||||||
|
<FormattedMessage
|
||||||
|
id='confirmations.unfollow.message'
|
||||||
|
defaultMessage='Are you sure you want to unfollow {name}?'
|
||||||
|
values={{ name: <strong>@{account.get('acct')}</strong> }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
confirm: intl.formatMessage(messages.unfollowConfirm),
|
||||||
|
onConfirm: () => {
|
||||||
|
dispatch(unfollowAccount(account.get('id')));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else if (account.getIn(['relationship', 'requested'])) {
|
||||||
|
dispatch(
|
||||||
|
openModal({
|
||||||
|
modalType: 'CONFIRM',
|
||||||
|
modalProps: {
|
||||||
|
message: (
|
||||||
|
<FormattedMessage
|
||||||
|
id='confirmations.cancel_follow_request.message'
|
||||||
|
defaultMessage='Are you sure you want to withdraw your request to follow {name}?'
|
||||||
|
values={{ name: <strong>@{account.get('acct')}</strong> }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
confirm: intl.formatMessage(messages.cancelFollowRequestConfirm),
|
||||||
|
onConfirm: () => {
|
||||||
|
dispatch(unfollowAccount(account.get('id')));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
dispatch(followAccount(account.get('id')));
|
||||||
|
}
|
||||||
|
}, [account, dispatch, intl]);
|
||||||
|
|
||||||
|
const handleBlock = useCallback(() => {
|
||||||
|
if (account?.relationship?.blocking) {
|
||||||
|
dispatch(unblockAccount(account.get('id')));
|
||||||
|
}
|
||||||
|
}, [account, dispatch]);
|
||||||
|
|
||||||
|
const handleMute = useCallback(() => {
|
||||||
|
if (account?.relationship?.muting) {
|
||||||
|
dispatch(unmuteAccount(account.get('id')));
|
||||||
|
}
|
||||||
|
}, [account, dispatch]);
|
||||||
|
|
||||||
|
const handleEditProfile = useCallback(() => {
|
||||||
|
window.open('/settings/profile', '_blank');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!account) return null;
|
||||||
|
|
||||||
|
let actionBtn;
|
||||||
|
|
||||||
|
if (me !== account.get('id')) {
|
||||||
|
if (!account.get('relationship')) {
|
||||||
|
// Wait until the relationship is loaded
|
||||||
|
actionBtn = '';
|
||||||
|
} else if (account.getIn(['relationship', 'requested'])) {
|
||||||
|
actionBtn = (
|
||||||
|
<Button
|
||||||
|
text={intl.formatMessage(messages.cancel_follow_request)}
|
||||||
|
title={intl.formatMessage(messages.requested)}
|
||||||
|
onClick={handleFollow}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (account.getIn(['relationship', 'muting'])) {
|
||||||
|
actionBtn = (
|
||||||
|
<Button
|
||||||
|
text={intl.formatMessage(messages.unmute)}
|
||||||
|
onClick={handleMute}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (!account.getIn(['relationship', 'blocking'])) {
|
||||||
|
actionBtn = (
|
||||||
|
<Button
|
||||||
|
disabled={account.relationship?.blocked_by}
|
||||||
|
className={classNames({
|
||||||
|
'button--destructive': account.getIn(['relationship', 'following']),
|
||||||
|
})}
|
||||||
|
text={intl.formatMessage(
|
||||||
|
account.getIn(['relationship', 'following'])
|
||||||
|
? messages.unfollow
|
||||||
|
: messages.follow,
|
||||||
|
)}
|
||||||
|
onClick={handleFollow}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (account.getIn(['relationship', 'blocking'])) {
|
||||||
|
actionBtn = (
|
||||||
|
<Button
|
||||||
|
text={intl.formatMessage(messages.unblock)}
|
||||||
|
onClick={handleBlock}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
actionBtn = (
|
||||||
|
<Button
|
||||||
|
text={intl.formatMessage(messages.edit_profile)}
|
||||||
|
onClick={handleEditProfile}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='account-card'>
|
||||||
|
<Permalink
|
||||||
|
href={account.get('url')}
|
||||||
|
to={`/@${account.get('acct')}`}
|
||||||
|
className='account-card__permalink'
|
||||||
|
>
|
||||||
|
<div className='account-card__header'>
|
||||||
|
<img
|
||||||
|
src={
|
||||||
|
autoPlayGif ? account.get('header') : account.get('header_static')
|
||||||
|
}
|
||||||
|
alt=''
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='account-card__title'>
|
||||||
|
<div className='account-card__title__avatar'>
|
||||||
|
<Avatar account={account as Account} size={56} />
|
||||||
|
</div>
|
||||||
|
<DisplayName account={account as Account} />
|
||||||
|
</div>
|
||||||
|
</Permalink>
|
||||||
|
|
||||||
|
{account.get('note').length > 0 && (
|
||||||
|
<div
|
||||||
|
className='account-card__bio translate'
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='account-card__actions'>
|
||||||
|
<div className='account-card__counters'>
|
||||||
|
<div className='account-card__counters__item'>
|
||||||
|
<ShortNumber value={account.get('statuses_count')} />
|
||||||
|
<small>
|
||||||
|
<FormattedMessage id='account.posts' defaultMessage='Posts' />
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='account-card__counters__item'>
|
||||||
|
<ShortNumber value={account.get('followers_count')} />{' '}
|
||||||
|
<small>
|
||||||
|
<FormattedMessage
|
||||||
|
id='account.followers'
|
||||||
|
defaultMessage='Followers'
|
||||||
|
/>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='account-card__counters__item'>
|
||||||
|
<ShortNumber value={account.get('following_count')} />{' '}
|
||||||
|
<small>
|
||||||
|
<FormattedMessage
|
||||||
|
id='account.following'
|
||||||
|
defaultMessage='Following'
|
||||||
|
/>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='account-card__actions__button'>{actionBtn}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -1,181 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import { PureComponent } from 'react';
|
|
||||||
|
|
||||||
import { defineMessages, injectIntl } from 'react-intl';
|
|
||||||
|
|
||||||
import { Helmet } from 'react-helmet';
|
|
||||||
|
|
||||||
import { List as ImmutableList } from 'immutable';
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
|
|
||||||
import { addColumn, removeColumn, moveColumn, changeColumnParams } from 'flavours/glitch/actions/columns';
|
|
||||||
import { fetchDirectory, expandDirectory } from 'flavours/glitch/actions/directory';
|
|
||||||
import Column from 'flavours/glitch/components/column';
|
|
||||||
import ColumnHeader from 'flavours/glitch/components/column_header';
|
|
||||||
import { LoadMore } from 'flavours/glitch/components/load_more';
|
|
||||||
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
|
||||||
import { RadioButton } from 'flavours/glitch/components/radio_button';
|
|
||||||
import ScrollContainer from 'flavours/glitch/containers/scroll_container';
|
|
||||||
|
|
||||||
import AccountCard from './components/account_card';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
title: { id: 'column.directory', defaultMessage: 'Browse profiles' },
|
|
||||||
recentlyActive: { id: 'directory.recently_active', defaultMessage: 'Recently active' },
|
|
||||||
newArrivals: { id: 'directory.new_arrivals', defaultMessage: 'New arrivals' },
|
|
||||||
local: { id: 'directory.local', defaultMessage: 'From {domain} only' },
|
|
||||||
federated: { id: 'directory.federated', defaultMessage: 'From known fediverse' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
|
||||||
accountIds: state.getIn(['user_lists', 'directory', 'items'], ImmutableList()),
|
|
||||||
isLoading: state.getIn(['user_lists', 'directory', 'isLoading'], true),
|
|
||||||
domain: state.getIn(['meta', 'domain']),
|
|
||||||
});
|
|
||||||
|
|
||||||
class Directory extends PureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
isLoading: PropTypes.bool,
|
|
||||||
accountIds: ImmutablePropTypes.list.isRequired,
|
|
||||||
dispatch: PropTypes.func.isRequired,
|
|
||||||
columnId: PropTypes.string,
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
multiColumn: PropTypes.bool,
|
|
||||||
domain: PropTypes.string.isRequired,
|
|
||||||
params: PropTypes.shape({
|
|
||||||
order: PropTypes.string,
|
|
||||||
local: PropTypes.bool,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
state = {
|
|
||||||
order: null,
|
|
||||||
local: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
handlePin = () => {
|
|
||||||
const { columnId, dispatch } = this.props;
|
|
||||||
|
|
||||||
if (columnId) {
|
|
||||||
dispatch(removeColumn(columnId));
|
|
||||||
} else {
|
|
||||||
dispatch(addColumn('DIRECTORY', this.getParams(this.props, this.state)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
getParams = (props, state) => ({
|
|
||||||
order: state.order === null ? (props.params.order || 'active') : state.order,
|
|
||||||
local: state.local === null ? (props.params.local || false) : state.local,
|
|
||||||
});
|
|
||||||
|
|
||||||
handleMove = dir => {
|
|
||||||
const { columnId, dispatch } = this.props;
|
|
||||||
dispatch(moveColumn(columnId, dir));
|
|
||||||
};
|
|
||||||
|
|
||||||
handleHeaderClick = () => {
|
|
||||||
this.column.scrollTop();
|
|
||||||
};
|
|
||||||
|
|
||||||
componentDidMount () {
|
|
||||||
const { dispatch } = this.props;
|
|
||||||
dispatch(fetchDirectory(this.getParams(this.props, this.state)));
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate (prevProps, prevState) {
|
|
||||||
const { dispatch } = this.props;
|
|
||||||
const paramsOld = this.getParams(prevProps, prevState);
|
|
||||||
const paramsNew = this.getParams(this.props, this.state);
|
|
||||||
|
|
||||||
if (paramsOld.order !== paramsNew.order || paramsOld.local !== paramsNew.local) {
|
|
||||||
dispatch(fetchDirectory(paramsNew));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setRef = c => {
|
|
||||||
this.column = c;
|
|
||||||
};
|
|
||||||
|
|
||||||
handleChangeOrder = e => {
|
|
||||||
const { dispatch, columnId } = this.props;
|
|
||||||
|
|
||||||
if (columnId) {
|
|
||||||
dispatch(changeColumnParams(columnId, ['order'], e.target.value));
|
|
||||||
} else {
|
|
||||||
this.setState({ order: e.target.value });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleChangeLocal = e => {
|
|
||||||
const { dispatch, columnId } = this.props;
|
|
||||||
|
|
||||||
if (columnId) {
|
|
||||||
dispatch(changeColumnParams(columnId, ['local'], e.target.value === '1'));
|
|
||||||
} else {
|
|
||||||
this.setState({ local: e.target.value === '1' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleLoadMore = () => {
|
|
||||||
const { dispatch } = this.props;
|
|
||||||
dispatch(expandDirectory(this.getParams(this.props, this.state)));
|
|
||||||
};
|
|
||||||
|
|
||||||
render () {
|
|
||||||
const { isLoading, accountIds, intl, columnId, multiColumn, domain } = this.props;
|
|
||||||
const { order, local } = this.getParams(this.props, this.state);
|
|
||||||
const pinned = !!columnId;
|
|
||||||
|
|
||||||
const scrollableArea = (
|
|
||||||
<div className='scrollable'>
|
|
||||||
<div className='filter-form'>
|
|
||||||
<div className='filter-form__column' role='group'>
|
|
||||||
<RadioButton name='order' value='active' label={intl.formatMessage(messages.recentlyActive)} checked={order === 'active'} onChange={this.handleChangeOrder} />
|
|
||||||
<RadioButton name='order' value='new' label={intl.formatMessage(messages.newArrivals)} checked={order === 'new'} onChange={this.handleChangeOrder} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='filter-form__column' role='group'>
|
|
||||||
<RadioButton name='local' value='1' label={intl.formatMessage(messages.local, { domain })} checked={local} onChange={this.handleChangeLocal} />
|
|
||||||
<RadioButton name='local' value='0' label={intl.formatMessage(messages.federated)} checked={!local} onChange={this.handleChangeLocal} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='directory__list'>
|
|
||||||
{isLoading ? <LoadingIndicator /> : accountIds.map(accountId => (
|
|
||||||
<AccountCard id={accountId} key={accountId} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<LoadMore onClick={this.handleLoadMore} visible={!isLoading} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
|
|
||||||
<ColumnHeader
|
|
||||||
icon='address-book-o'
|
|
||||||
iconComponent={PeopleIcon}
|
|
||||||
title={intl.formatMessage(messages.title)}
|
|
||||||
onPin={this.handlePin}
|
|
||||||
onMove={this.handleMove}
|
|
||||||
onClick={this.handleHeaderClick}
|
|
||||||
pinned={pinned}
|
|
||||||
multiColumn={multiColumn}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{multiColumn && !pinned ? <ScrollContainer scrollKey='directory'>{scrollableArea}</ScrollContainer> : scrollableArea}
|
|
||||||
|
|
||||||
<Helmet>
|
|
||||||
<title>{intl.formatMessage(messages.title)}</title>
|
|
||||||
<meta name='robots' content='noindex' />
|
|
||||||
</Helmet>
|
|
||||||
</Column>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default connect(mapStateToProps)(injectIntl(Directory));
|
|
219
app/javascript/flavours/glitch/features/directory/index.tsx
Normal file
219
app/javascript/flavours/glitch/features/directory/index.tsx
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
import type { ChangeEventHandler } from 'react';
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import { Helmet } from 'react-helmet';
|
||||||
|
|
||||||
|
import { List as ImmutableList } from 'immutable';
|
||||||
|
|
||||||
|
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
|
||||||
|
import {
|
||||||
|
addColumn,
|
||||||
|
removeColumn,
|
||||||
|
moveColumn,
|
||||||
|
changeColumnParams,
|
||||||
|
} from 'flavours/glitch/actions/columns';
|
||||||
|
import {
|
||||||
|
fetchDirectory,
|
||||||
|
expandDirectory,
|
||||||
|
} from 'flavours/glitch/actions/directory';
|
||||||
|
import Column from 'flavours/glitch/components/column';
|
||||||
|
import { ColumnHeader } from 'flavours/glitch/components/column_header';
|
||||||
|
import { LoadMore } from 'flavours/glitch/components/load_more';
|
||||||
|
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
||||||
|
import { RadioButton } from 'flavours/glitch/components/radio_button';
|
||||||
|
import ScrollContainer from 'flavours/glitch/containers/scroll_container';
|
||||||
|
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
||||||
|
|
||||||
|
import { AccountCard } from './components/account_card';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
title: { id: 'column.directory', defaultMessage: 'Browse profiles' },
|
||||||
|
recentlyActive: {
|
||||||
|
id: 'directory.recently_active',
|
||||||
|
defaultMessage: 'Recently active',
|
||||||
|
},
|
||||||
|
newArrivals: { id: 'directory.new_arrivals', defaultMessage: 'New arrivals' },
|
||||||
|
local: { id: 'directory.local', defaultMessage: 'From {domain} only' },
|
||||||
|
federated: {
|
||||||
|
id: 'directory.federated',
|
||||||
|
defaultMessage: 'From known fediverse',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Directory: React.FC<{
|
||||||
|
columnId?: string;
|
||||||
|
multiColumn?: boolean;
|
||||||
|
params?: { order: string; local?: boolean };
|
||||||
|
}> = ({ columnId, multiColumn, params }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const [state, setState] = useState<{
|
||||||
|
order: string | null;
|
||||||
|
local: boolean | null;
|
||||||
|
}>({
|
||||||
|
order: null,
|
||||||
|
local: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const column = useRef<Column>(null);
|
||||||
|
|
||||||
|
const order = state.order ?? params?.order ?? 'active';
|
||||||
|
const local = state.local ?? params?.local ?? false;
|
||||||
|
|
||||||
|
const handlePin = useCallback(() => {
|
||||||
|
if (columnId) {
|
||||||
|
dispatch(removeColumn(columnId));
|
||||||
|
} else {
|
||||||
|
dispatch(addColumn('DIRECTORY', { order, local }));
|
||||||
|
}
|
||||||
|
}, [dispatch, columnId, order, local]);
|
||||||
|
|
||||||
|
const domain = useAppSelector((s) => s.meta.get('domain') as string);
|
||||||
|
const accountIds = useAppSelector(
|
||||||
|
(state) =>
|
||||||
|
state.user_lists.getIn(
|
||||||
|
['directory', 'items'],
|
||||||
|
ImmutableList(),
|
||||||
|
) as ImmutableList<string>,
|
||||||
|
);
|
||||||
|
const isLoading = useAppSelector(
|
||||||
|
(state) =>
|
||||||
|
state.user_lists.getIn(['directory', 'isLoading'], true) as boolean,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void dispatch(fetchDirectory({ order, local }));
|
||||||
|
}, [dispatch, order, local]);
|
||||||
|
|
||||||
|
const handleMove = useCallback(
|
||||||
|
(dir: number) => {
|
||||||
|
dispatch(moveColumn(columnId, dir));
|
||||||
|
},
|
||||||
|
[dispatch, columnId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleHeaderClick = useCallback(() => {
|
||||||
|
column.current?.scrollTop();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChangeOrder = useCallback<ChangeEventHandler<HTMLInputElement>>(
|
||||||
|
(e) => {
|
||||||
|
if (columnId) {
|
||||||
|
dispatch(changeColumnParams(columnId, ['order'], e.target.value));
|
||||||
|
} else {
|
||||||
|
setState((s) => ({ order: e.target.value, local: s.local }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[dispatch, columnId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChangeLocal = useCallback<ChangeEventHandler<HTMLInputElement>>(
|
||||||
|
(e) => {
|
||||||
|
if (columnId) {
|
||||||
|
dispatch(
|
||||||
|
changeColumnParams(columnId, ['local'], e.target.value === '1'),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setState((s) => ({ local: e.target.value === '1', order: s.order }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[dispatch, columnId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleLoadMore = useCallback(() => {
|
||||||
|
void dispatch(expandDirectory({ order, local }));
|
||||||
|
}, [dispatch, order, local]);
|
||||||
|
|
||||||
|
const pinned = !!columnId;
|
||||||
|
|
||||||
|
const scrollableArea = (
|
||||||
|
<div className='scrollable'>
|
||||||
|
<div className='filter-form'>
|
||||||
|
<div className='filter-form__column' role='group'>
|
||||||
|
<RadioButton
|
||||||
|
name='order'
|
||||||
|
value='active'
|
||||||
|
label={intl.formatMessage(messages.recentlyActive)}
|
||||||
|
checked={order === 'active'}
|
||||||
|
onChange={handleChangeOrder}
|
||||||
|
/>
|
||||||
|
<RadioButton
|
||||||
|
name='order'
|
||||||
|
value='new'
|
||||||
|
label={intl.formatMessage(messages.newArrivals)}
|
||||||
|
checked={order === 'new'}
|
||||||
|
onChange={handleChangeOrder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='filter-form__column' role='group'>
|
||||||
|
<RadioButton
|
||||||
|
name='local'
|
||||||
|
value='1'
|
||||||
|
label={intl.formatMessage(messages.local, { domain })}
|
||||||
|
checked={local}
|
||||||
|
onChange={handleChangeLocal}
|
||||||
|
/>
|
||||||
|
<RadioButton
|
||||||
|
name='local'
|
||||||
|
value='0'
|
||||||
|
label={intl.formatMessage(messages.federated)}
|
||||||
|
checked={!local}
|
||||||
|
onChange={handleChangeLocal}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='directory__list'>
|
||||||
|
{isLoading ? (
|
||||||
|
<LoadingIndicator />
|
||||||
|
) : (
|
||||||
|
accountIds.map((accountId) => (
|
||||||
|
<AccountCard accountId={accountId} key={accountId} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LoadMore onClick={handleLoadMore} visible={!isLoading} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column
|
||||||
|
bindToDocument={!multiColumn}
|
||||||
|
ref={column}
|
||||||
|
label={intl.formatMessage(messages.title)}
|
||||||
|
>
|
||||||
|
<ColumnHeader
|
||||||
|
icon='address-book-o'
|
||||||
|
iconComponent={PeopleIcon}
|
||||||
|
title={intl.formatMessage(messages.title)}
|
||||||
|
onPin={handlePin}
|
||||||
|
onMove={handleMove}
|
||||||
|
onClick={handleHeaderClick}
|
||||||
|
pinned={pinned}
|
||||||
|
multiColumn={multiColumn}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{multiColumn && !pinned ? (
|
||||||
|
// @ts-expect-error ScrollContainer is not properly typed yet
|
||||||
|
<ScrollContainer scrollKey='directory'>
|
||||||
|
{scrollableArea}
|
||||||
|
</ScrollContainer>
|
||||||
|
) : (
|
||||||
|
scrollableArea
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Helmet>
|
||||||
|
<title>{intl.formatMessage(messages.title)}</title>
|
||||||
|
<meta name='robots' content='noindex' />
|
||||||
|
</Helmet>
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/no-default-export -- Needed because this is called as an async components
|
||||||
|
export default Directory;
|
@ -0,0 +1,24 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
import { Avatar } from 'flavours/glitch/components/avatar';
|
||||||
|
import { Permalink } from 'flavours/glitch/components/permalink';
|
||||||
|
import { useAppSelector } from 'flavours/glitch/store';
|
||||||
|
|
||||||
|
export const AuthorLink = ({ accountId }) => {
|
||||||
|
const account = useAppSelector(state => state.getIn(['accounts', accountId]));
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Permalink href={account.get('url')} to={`/@${account.get('acct')}`} className='story__details__shared__author-link' data-hover-card-account={accountId}>
|
||||||
|
<Avatar account={account} size={16} />
|
||||||
|
<bdi dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} />
|
||||||
|
</Permalink>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
AuthorLink.propTypes = {
|
||||||
|
accountId: PropTypes.string.isRequired,
|
||||||
|
};
|
@ -8,34 +8,21 @@ import { Link } from 'react-router-dom';
|
|||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
|
||||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||||
import { followAccount, unfollowAccount } from 'flavours/glitch/actions/accounts';
|
|
||||||
import { dismissSuggestion } from 'flavours/glitch/actions/suggestions';
|
import { dismissSuggestion } from 'flavours/glitch/actions/suggestions';
|
||||||
import { Avatar } from 'flavours/glitch/components/avatar';
|
import { Avatar } from 'flavours/glitch/components/avatar';
|
||||||
import { Button } from 'flavours/glitch/components/button';
|
|
||||||
import { DisplayName } from 'flavours/glitch/components/display_name';
|
import { DisplayName } from 'flavours/glitch/components/display_name';
|
||||||
|
import { FollowButton } from 'flavours/glitch/components/follow_button';
|
||||||
import { IconButton } from 'flavours/glitch/components/icon_button';
|
import { IconButton } from 'flavours/glitch/components/icon_button';
|
||||||
import { domain } from 'flavours/glitch/initial_state';
|
import { domain } from 'flavours/glitch/initial_state';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
|
||||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
|
||||||
dismiss: { id: 'follow_suggestions.dismiss', defaultMessage: "Don't show again" },
|
dismiss: { id: 'follow_suggestions.dismiss', defaultMessage: "Don't show again" },
|
||||||
});
|
});
|
||||||
|
|
||||||
export const Card = ({ id, source }) => {
|
export const Card = ({ id, source }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const account = useSelector(state => state.getIn(['accounts', id]));
|
const account = useSelector(state => state.getIn(['accounts', id]));
|
||||||
const relationship = useSelector(state => state.getIn(['relationships', id]));
|
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const following = relationship?.get('following') ?? relationship?.get('requested');
|
|
||||||
|
|
||||||
const handleFollow = useCallback(() => {
|
|
||||||
if (following) {
|
|
||||||
dispatch(unfollowAccount(id));
|
|
||||||
} else {
|
|
||||||
dispatch(followAccount(id));
|
|
||||||
}
|
|
||||||
}, [id, following, dispatch]);
|
|
||||||
|
|
||||||
const handleDismiss = useCallback(() => {
|
const handleDismiss = useCallback(() => {
|
||||||
dispatch(dismissSuggestion(id));
|
dispatch(dismissSuggestion(id));
|
||||||
@ -74,7 +61,7 @@ export const Card = ({ id, source }) => {
|
|||||||
<div className='explore__suggestions__card__body__main__name-button'>
|
<div className='explore__suggestions__card__body__main__name-button'>
|
||||||
<Link className='explore__suggestions__card__body__main__name-button__name' to={`/@${account.get('acct')}`}><DisplayName account={account} /></Link>
|
<Link className='explore__suggestions__card__body__main__name-button__name' to={`/@${account.get('acct')}`}><DisplayName account={account} /></Link>
|
||||||
<IconButton iconComponent={CloseIcon} onClick={handleDismiss} title={intl.formatMessage(messages.dismiss)} />
|
<IconButton iconComponent={CloseIcon} onClick={handleDismiss} title={intl.formatMessage(messages.dismiss)} />
|
||||||
<Button text={intl.formatMessage(following ? messages.unfollow : messages.follow)} secondary={following} onClick={handleFollow} />
|
<FollowButton accountId={account.get('id')} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,61 +1,91 @@
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { PureComponent } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import { Blurhash } from 'flavours/glitch/components/blurhash';
|
import { Blurhash } from 'flavours/glitch/components/blurhash';
|
||||||
import { accountsCountRenderer } from 'flavours/glitch/components/hashtag';
|
|
||||||
import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp';
|
import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp';
|
||||||
import { ShortNumber } from 'flavours/glitch/components/short_number';
|
import { ShortNumber } from 'flavours/glitch/components/short_number';
|
||||||
import { Skeleton } from 'flavours/glitch/components/skeleton';
|
import { Skeleton } from 'flavours/glitch/components/skeleton';
|
||||||
|
|
||||||
export default class Story extends PureComponent {
|
import { AuthorLink } from './author_link';
|
||||||
|
|
||||||
static propTypes = {
|
const sharesCountRenderer = (displayNumber, pluralReady) => (
|
||||||
url: PropTypes.string,
|
<FormattedMessage
|
||||||
title: PropTypes.string,
|
id='link_preview.shares'
|
||||||
lang: PropTypes.string,
|
defaultMessage='{count, plural, one {{counter} post} other {{counter} posts}}'
|
||||||
publisher: PropTypes.string,
|
values={{
|
||||||
publishedAt: PropTypes.string,
|
count: pluralReady,
|
||||||
author: PropTypes.string,
|
counter: <strong>{displayNumber}</strong>,
|
||||||
sharedTimes: PropTypes.number,
|
}}
|
||||||
thumbnail: PropTypes.string,
|
/>
|
||||||
thumbnailDescription: PropTypes.string,
|
);
|
||||||
blurhash: PropTypes.string,
|
|
||||||
expanded: PropTypes.bool,
|
|
||||||
};
|
|
||||||
|
|
||||||
state = {
|
export const Story = ({
|
||||||
thumbnailLoaded: false,
|
url,
|
||||||
};
|
title,
|
||||||
|
lang,
|
||||||
|
publisher,
|
||||||
|
publishedAt,
|
||||||
|
author,
|
||||||
|
authorAccount,
|
||||||
|
sharedTimes,
|
||||||
|
thumbnail,
|
||||||
|
thumbnailDescription,
|
||||||
|
blurhash,
|
||||||
|
expanded
|
||||||
|
}) => {
|
||||||
|
const [thumbnailLoaded, setThumbnailLoaded] = useState(false);
|
||||||
|
|
||||||
handleImageLoad = () => this.setState({ thumbnailLoaded: true });
|
const handleImageLoad = useCallback(() => {
|
||||||
|
setThumbnailLoaded(true);
|
||||||
|
}, [setThumbnailLoaded]);
|
||||||
|
|
||||||
render () {
|
return (
|
||||||
const { expanded, url, title, lang, publisher, author, publishedAt, sharedTimes, thumbnail, thumbnailDescription, blurhash } = this.props;
|
<div className={classNames('story', { expanded })}>
|
||||||
|
<div className='story__details'>
|
||||||
const { thumbnailLoaded } = this.state;
|
<div className='story__details__publisher'>
|
||||||
|
{publisher ? <span lang={lang}>{publisher}</span> : <Skeleton width={50} />}{publishedAt && <> · <RelativeTimestamp timestamp={publishedAt} /></>}
|
||||||
return (
|
|
||||||
<a className={classNames('story', { expanded })} href={url} target='blank' rel='noopener'>
|
|
||||||
<div className='story__details'>
|
|
||||||
<div className='story__details__publisher'>{publisher ? <span lang={lang}>{publisher}</span> : <Skeleton width={50} />}{publishedAt && <> · <RelativeTimestamp timestamp={publishedAt} /></>}</div>
|
|
||||||
<div className='story__details__title' lang={lang}>{title ? title : <Skeleton />}</div>
|
|
||||||
<div className='story__details__shared'>{author && <><FormattedMessage id='link_preview.author' defaultMessage='By {name}' values={{ name: <strong>{author}</strong> }} /> · </>}{typeof sharedTimes === 'number' ? <ShortNumber value={sharedTimes} renderer={accountsCountRenderer} /> : <Skeleton width={100} />}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='story__thumbnail'>
|
<a className='story__details__title' lang={lang} href={url} target='blank' rel='noopener'>
|
||||||
{thumbnail ? (
|
{title ? title : <Skeleton />}
|
||||||
<>
|
</a>
|
||||||
<div className={classNames('story__thumbnail__preview', { 'story__thumbnail__preview--hidden': thumbnailLoaded })}><Blurhash hash={blurhash} /></div>
|
|
||||||
<img src={thumbnail} onLoad={this.handleImageLoad} alt={thumbnailDescription} title={thumbnailDescription} lang={lang} />
|
<div className='story__details__shared'>
|
||||||
</>
|
{author ? <FormattedMessage id='link_preview.author' className='story__details__shared__author' defaultMessage='By {name}' values={{ name: authorAccount ? <AuthorLink accountId={authorAccount} /> : <strong>{author}</strong> }} /> : <span />}
|
||||||
) : <Skeleton />}
|
{typeof sharedTimes === 'number' ? <Link className='story__details__shared__pill' to={`/links/${encodeURIComponent(url)}`}><ShortNumber value={sharedTimes} renderer={sharesCountRenderer} /></Link> : <Skeleton width='10ch' />}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a className='story__thumbnail' href={url} target='blank' rel='noopener'>
|
||||||
|
{thumbnail ? (
|
||||||
|
<>
|
||||||
|
<div className={classNames('story__thumbnail__preview', { 'story__thumbnail__preview--hidden': thumbnailLoaded })}><Blurhash hash={blurhash} /></div>
|
||||||
|
<img src={thumbnail} onLoad={handleImageLoad} alt={thumbnailDescription} title={thumbnailDescription} lang={lang} />
|
||||||
|
</>
|
||||||
|
) : <Skeleton />}
|
||||||
</a>
|
</a>
|
||||||
);
|
</div>
|
||||||
}
|
);
|
||||||
|
};
|
||||||
|
|
||||||
}
|
Story.propTypes = {
|
||||||
|
url: PropTypes.string,
|
||||||
|
title: PropTypes.string,
|
||||||
|
lang: PropTypes.string,
|
||||||
|
publisher: PropTypes.string,
|
||||||
|
publishedAt: PropTypes.string,
|
||||||
|
author: PropTypes.string,
|
||||||
|
authorAccount: PropTypes.string,
|
||||||
|
sharedTimes: PropTypes.number,
|
||||||
|
thumbnail: PropTypes.string,
|
||||||
|
thumbnailDescription: PropTypes.string,
|
||||||
|
blurhash: PropTypes.string,
|
||||||
|
expanded: PropTypes.bool,
|
||||||
|
};
|
||||||
|
@ -13,7 +13,7 @@ import { DismissableBanner } from 'flavours/glitch/components/dismissable_banner
|
|||||||
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
||||||
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
|
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
|
||||||
|
|
||||||
import Story from './components/story';
|
import { Story } from './components/story';
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
links: state.getIn(['trends', 'links', 'items']),
|
links: state.getIn(['trends', 'links', 'items']),
|
||||||
@ -75,6 +75,7 @@ class Links extends PureComponent {
|
|||||||
publisher={link.get('provider_name')}
|
publisher={link.get('provider_name')}
|
||||||
publishedAt={link.get('published_at')}
|
publishedAt={link.get('published_at')}
|
||||||
author={link.get('author_name')}
|
author={link.get('author_name')}
|
||||||
|
authorAccount={link.getIn(['authors', 0, 'account', 'id'])}
|
||||||
sharedTimes={link.getIn(['history', 0, 'accounts']) * 1 + link.getIn(['history', 1, 'accounts']) * 1}
|
sharedTimes={link.getIn(['history', 0, 'accounts']) * 1 + link.getIn(['history', 1, 'accounts']) * 1}
|
||||||
thumbnail={link.get('image')}
|
thumbnail={link.get('image')}
|
||||||
thumbnailDescription={link.get('image_description')}
|
thumbnailDescription={link.get('image_description')}
|
||||||
|
@ -12,12 +12,11 @@ import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
|
|||||||
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
||||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||||
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
|
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
|
||||||
import { followAccount, unfollowAccount } from 'flavours/glitch/actions/accounts';
|
|
||||||
import { changeSetting } from 'flavours/glitch/actions/settings';
|
import { changeSetting } from 'flavours/glitch/actions/settings';
|
||||||
import { fetchSuggestions, dismissSuggestion } from 'flavours/glitch/actions/suggestions';
|
import { fetchSuggestions, dismissSuggestion } from 'flavours/glitch/actions/suggestions';
|
||||||
import { Avatar } from 'flavours/glitch/components/avatar';
|
import { Avatar } from 'flavours/glitch/components/avatar';
|
||||||
import { Button } from 'flavours/glitch/components/button';
|
|
||||||
import { DisplayName } from 'flavours/glitch/components/display_name';
|
import { DisplayName } from 'flavours/glitch/components/display_name';
|
||||||
|
import { FollowButton } from 'flavours/glitch/components/follow_button';
|
||||||
import { Icon } from 'flavours/glitch/components/icon';
|
import { Icon } from 'flavours/glitch/components/icon';
|
||||||
import { IconButton } from 'flavours/glitch/components/icon_button';
|
import { IconButton } from 'flavours/glitch/components/icon_button';
|
||||||
import { VerifiedBadge } from 'flavours/glitch/components/verified_badge';
|
import { VerifiedBadge } from 'flavours/glitch/components/verified_badge';
|
||||||
@ -79,18 +78,8 @@ Source.propTypes = {
|
|||||||
const Card = ({ id, sources }) => {
|
const Card = ({ id, sources }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const account = useSelector(state => state.getIn(['accounts', id]));
|
const account = useSelector(state => state.getIn(['accounts', id]));
|
||||||
const relationship = useSelector(state => state.getIn(['relationships', id]));
|
|
||||||
const firstVerifiedField = account.get('fields').find(item => !!item.get('verified_at'));
|
const firstVerifiedField = account.get('fields').find(item => !!item.get('verified_at'));
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const following = relationship?.get('following') ?? relationship?.get('requested');
|
|
||||||
|
|
||||||
const handleFollow = useCallback(() => {
|
|
||||||
if (following) {
|
|
||||||
dispatch(unfollowAccount(id));
|
|
||||||
} else {
|
|
||||||
dispatch(followAccount(id));
|
|
||||||
}
|
|
||||||
}, [id, following, dispatch]);
|
|
||||||
|
|
||||||
const handleDismiss = useCallback(() => {
|
const handleDismiss = useCallback(() => {
|
||||||
dispatch(dismissSuggestion(id));
|
dispatch(dismissSuggestion(id));
|
||||||
@ -109,7 +98,7 @@ const Card = ({ id, sources }) => {
|
|||||||
{firstVerifiedField ? <VerifiedBadge link={firstVerifiedField.get('value')} /> : <Source id={sources.get(0)} />}
|
{firstVerifiedField ? <VerifiedBadge link={firstVerifiedField.get('value')} /> : <Source id={sources.get(0)} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button text={intl.formatMessage(following ? messages.unfollow : messages.follow)} secondary={following} onClick={handleFollow} />
|
<FollowButton accountId={id} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -0,0 +1,77 @@
|
|||||||
|
import { useRef, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
import { Helmet } from 'react-helmet';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import ExploreIcon from '@/material-icons/400-24px/explore.svg?react';
|
||||||
|
import { expandLinkTimeline } from 'flavours/glitch/actions/timelines';
|
||||||
|
import Column from 'flavours/glitch/components/column';
|
||||||
|
import { ColumnHeader } from 'flavours/glitch/components/column_header';
|
||||||
|
import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
|
||||||
|
import type { Card } from 'flavours/glitch/models/status';
|
||||||
|
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
||||||
|
|
||||||
|
export const LinkTimeline: React.FC<{
|
||||||
|
multiColumn: boolean;
|
||||||
|
}> = ({ multiColumn }) => {
|
||||||
|
const { url } = useParams<{ url: string }>();
|
||||||
|
const decodedUrl = url ? decodeURIComponent(url) : undefined;
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const columnRef = useRef<Column>(null);
|
||||||
|
const firstStatusId = useAppSelector((state) =>
|
||||||
|
decodedUrl
|
||||||
|
? // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
||||||
|
(state.timelines.getIn([`link:${decodedUrl}`, 'items', 0]) as string)
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
const story = useAppSelector((state) =>
|
||||||
|
firstStatusId
|
||||||
|
? (state.statuses.getIn([firstStatusId, 'card']) as Card)
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleHeaderClick = useCallback(() => {
|
||||||
|
columnRef.current?.scrollTop();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLoadMore = useCallback(
|
||||||
|
(maxId: string) => {
|
||||||
|
dispatch(expandLinkTimeline(decodedUrl, { maxId }));
|
||||||
|
},
|
||||||
|
[dispatch, decodedUrl],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(expandLinkTimeline(decodedUrl));
|
||||||
|
}, [dispatch, decodedUrl]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column bindToDocument={!multiColumn} ref={columnRef} label={story?.title}>
|
||||||
|
<ColumnHeader
|
||||||
|
icon='explore'
|
||||||
|
iconComponent={ExploreIcon}
|
||||||
|
title={story?.title}
|
||||||
|
onClick={handleHeaderClick}
|
||||||
|
multiColumn={multiColumn}
|
||||||
|
showBackButton
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StatusListContainer
|
||||||
|
timelineId={`link:${decodedUrl}`}
|
||||||
|
onLoadMore={handleLoadMore}
|
||||||
|
trackScroll
|
||||||
|
scrollKey={`link_timeline-${decodedUrl}`}
|
||||||
|
bindToDocument={!multiColumn}
|
||||||
|
regex={undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Helmet>
|
||||||
|
<title>{story?.title}</title>
|
||||||
|
<meta name='robots' content='noindex' />
|
||||||
|
</Helmet>
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/no-default-export
|
||||||
|
export default LinkTimeline;
|
@ -25,7 +25,7 @@ class ColumnSettings extends PureComponent {
|
|||||||
alertsEnabled: PropTypes.bool,
|
alertsEnabled: PropTypes.bool,
|
||||||
browserSupport: PropTypes.bool,
|
browserSupport: PropTypes.bool,
|
||||||
browserPermission: PropTypes.string,
|
browserPermission: PropTypes.string,
|
||||||
notificationPolicy: ImmutablePropTypes.map,
|
notificationPolicy: PropTypes.object.isRequired,
|
||||||
onChangePolicy: PropTypes.func.isRequired,
|
onChangePolicy: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -84,22 +84,22 @@ class ColumnSettings extends PureComponent {
|
|||||||
<h3><FormattedMessage id='notifications.policy.title' defaultMessage='Filter out notifications from…' /></h3>
|
<h3><FormattedMessage id='notifications.policy.title' defaultMessage='Filter out notifications from…' /></h3>
|
||||||
|
|
||||||
<div className='column-settings__row'>
|
<div className='column-settings__row'>
|
||||||
<CheckboxWithLabel checked={notificationPolicy.get('filter_not_following')} onChange={this.handleFilterNotFollowing}>
|
<CheckboxWithLabel checked={notificationPolicy.filter_not_following} onChange={this.handleFilterNotFollowing}>
|
||||||
<strong><FormattedMessage id='notifications.policy.filter_not_following_title' defaultMessage="People you don't follow" /></strong>
|
<strong><FormattedMessage id='notifications.policy.filter_not_following_title' defaultMessage="People you don't follow" /></strong>
|
||||||
<span className='hint'><FormattedMessage id='notifications.policy.filter_not_following_hint' defaultMessage='Until you manually approve them' /></span>
|
<span className='hint'><FormattedMessage id='notifications.policy.filter_not_following_hint' defaultMessage='Until you manually approve them' /></span>
|
||||||
</CheckboxWithLabel>
|
</CheckboxWithLabel>
|
||||||
|
|
||||||
<CheckboxWithLabel checked={notificationPolicy.get('filter_not_followers')} onChange={this.handleFilterNotFollowers}>
|
<CheckboxWithLabel checked={notificationPolicy.filter_not_followers} onChange={this.handleFilterNotFollowers}>
|
||||||
<strong><FormattedMessage id='notifications.policy.filter_not_followers_title' defaultMessage='People not following you' /></strong>
|
<strong><FormattedMessage id='notifications.policy.filter_not_followers_title' defaultMessage='People not following you' /></strong>
|
||||||
<span className='hint'><FormattedMessage id='notifications.policy.filter_not_followers_hint' defaultMessage='Including people who have been following you fewer than {days, plural, one {one day} other {# days}}' values={{ days: 3 }} /></span>
|
<span className='hint'><FormattedMessage id='notifications.policy.filter_not_followers_hint' defaultMessage='Including people who have been following you fewer than {days, plural, one {one day} other {# days}}' values={{ days: 3 }} /></span>
|
||||||
</CheckboxWithLabel>
|
</CheckboxWithLabel>
|
||||||
|
|
||||||
<CheckboxWithLabel checked={notificationPolicy.get('filter_new_accounts')} onChange={this.handleFilterNewAccounts}>
|
<CheckboxWithLabel checked={notificationPolicy.filter_new_accounts} onChange={this.handleFilterNewAccounts}>
|
||||||
<strong><FormattedMessage id='notifications.policy.filter_new_accounts_title' defaultMessage='New accounts' /></strong>
|
<strong><FormattedMessage id='notifications.policy.filter_new_accounts_title' defaultMessage='New accounts' /></strong>
|
||||||
<span className='hint'><FormattedMessage id='notifications.policy.filter_new_accounts.hint' defaultMessage='Created within the past {days, plural, one {one day} other {# days}}' values={{ days: 30 }} /></span>
|
<span className='hint'><FormattedMessage id='notifications.policy.filter_new_accounts.hint' defaultMessage='Created within the past {days, plural, one {one day} other {# days}}' values={{ days: 30 }} /></span>
|
||||||
</CheckboxWithLabel>
|
</CheckboxWithLabel>
|
||||||
|
|
||||||
<CheckboxWithLabel checked={notificationPolicy.get('filter_private_mentions')} onChange={this.handleFilterPrivateMentions}>
|
<CheckboxWithLabel checked={notificationPolicy.filter_private_mentions} onChange={this.handleFilterPrivateMentions}>
|
||||||
<strong><FormattedMessage id='notifications.policy.filter_private_mentions_title' defaultMessage='Unsolicited private mentions' /></strong>
|
<strong><FormattedMessage id='notifications.policy.filter_private_mentions_title' defaultMessage='Unsolicited private mentions' /></strong>
|
||||||
<span className='hint'><FormattedMessage id='notifications.policy.filter_private_mentions_hint' defaultMessage="Filtered unless it's in reply to your own mention or if you follow the sender" /></span>
|
<span className='hint'><FormattedMessage id='notifications.policy.filter_private_mentions_hint' defaultMessage="Filtered unless it's in reply to your own mention or if you follow the sender" /></span>
|
||||||
</CheckboxWithLabel>
|
</CheckboxWithLabel>
|
||||||
|
@ -1,49 +0,0 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
|
||||||
|
|
||||||
import InventoryIcon from '@/material-icons/400-24px/inventory_2.svg?react';
|
|
||||||
import { fetchNotificationPolicy } from 'flavours/glitch/actions/notifications';
|
|
||||||
import { Icon } from 'flavours/glitch/components/icon';
|
|
||||||
import { toCappedNumber } from 'flavours/glitch/utils/numbers';
|
|
||||||
|
|
||||||
export const FilteredNotificationsBanner = () => {
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
const policy = useSelector(state => state.get('notificationPolicy'));
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
dispatch(fetchNotificationPolicy());
|
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
dispatch(fetchNotificationPolicy());
|
|
||||||
}, 120000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(interval);
|
|
||||||
};
|
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
if (policy === null || policy.getIn(['summary', 'pending_notifications_count']) === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link className='filtered-notifications-banner' to='/notifications/requests'>
|
|
||||||
<Icon icon={InventoryIcon} />
|
|
||||||
|
|
||||||
<div className='filtered-notifications-banner__text'>
|
|
||||||
<strong><FormattedMessage id='filtered_notifications_banner.title' defaultMessage='Filtered notifications' /></strong>
|
|
||||||
<span><FormattedMessage id='filtered_notifications_banner.pending_requests' defaultMessage='Notifications from {count, plural, =0 {no one} one {one person} other {# people}} you may know' values={{ count: policy.getIn(['summary', 'pending_requests_count']) }} /></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='filtered-notifications-banner__badge'>
|
|
||||||
<div className='filtered-notifications-banner__badge__badge'>{toCappedNumber(policy.getIn(['summary', 'pending_notifications_count']))}</div>
|
|
||||||
<FormattedMessage id='filtered_notifications_banner.mentions' defaultMessage='{count, plural, one {mention} other {mentions}}' values={{ count: policy.getIn(['summary', 'pending_notifications_count']) }} />
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
};
|
|
@ -0,0 +1,68 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import InventoryIcon from '@/material-icons/400-24px/inventory_2.svg?react';
|
||||||
|
import { fetchNotificationPolicy } from 'flavours/glitch/actions/notification_policies';
|
||||||
|
import { Icon } from 'flavours/glitch/components/icon';
|
||||||
|
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
|
||||||
|
import { toCappedNumber } from 'flavours/glitch/utils/numbers';
|
||||||
|
|
||||||
|
export const FilteredNotificationsBanner: React.FC = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const policy = useAppSelector((state) => state.notificationPolicy);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void dispatch(fetchNotificationPolicy());
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
void dispatch(fetchNotificationPolicy());
|
||||||
|
}, 120000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
if (policy === null || policy.summary.pending_notifications_count === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
className='filtered-notifications-banner'
|
||||||
|
to='/notifications/requests'
|
||||||
|
>
|
||||||
|
<Icon icon={InventoryIcon} id='filtered-notifications' />
|
||||||
|
|
||||||
|
<div className='filtered-notifications-banner__text'>
|
||||||
|
<strong>
|
||||||
|
<FormattedMessage
|
||||||
|
id='filtered_notifications_banner.title'
|
||||||
|
defaultMessage='Filtered notifications'
|
||||||
|
/>
|
||||||
|
</strong>
|
||||||
|
<span>
|
||||||
|
<FormattedMessage
|
||||||
|
id='filtered_notifications_banner.pending_requests'
|
||||||
|
defaultMessage='Notifications from {count, plural, =0 {no one} one {one person} other {# people}} you may know'
|
||||||
|
values={{ count: policy.summary.pending_requests_count }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='filtered-notifications-banner__badge'>
|
||||||
|
<div className='filtered-notifications-banner__badge__badge'>
|
||||||
|
{toCappedNumber(policy.summary.pending_notifications_count)}
|
||||||
|
</div>
|
||||||
|
<FormattedMessage
|
||||||
|
id='filtered_notifications_banner.mentions'
|
||||||
|
defaultMessage='{count, plural, one {mention} other {mentions}}'
|
||||||
|
values={{ count: policy.summary.pending_notifications_count }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
@ -414,6 +414,7 @@ class Notification extends ImmutablePureComponent {
|
|||||||
title={targetAccount.get('acct')}
|
title={targetAccount.get('acct')}
|
||||||
to={`/@${targetAccount.get('acct')}`}
|
to={`/@${targetAccount.get('acct')}`}
|
||||||
dangerouslySetInnerHTML={targetDisplayNameHtml}
|
dangerouslySetInnerHTML={targetDisplayNameHtml}
|
||||||
|
data-hover-card-account={targetAccount.get('id')}
|
||||||
/>
|
/>
|
||||||
</bdi>
|
</bdi>
|
||||||
);
|
);
|
||||||
@ -448,6 +449,7 @@ class Notification extends ImmutablePureComponent {
|
|||||||
title={account.get('acct')}
|
title={account.get('acct')}
|
||||||
to={`/@${account.get('acct')}`}
|
to={`/@${account.get('acct')}`}
|
||||||
dangerouslySetInnerHTML={displayNameHtml}
|
dangerouslySetInnerHTML={displayNameHtml}
|
||||||
|
data-hover-card-account={account.get('id')}
|
||||||
/>
|
/>
|
||||||
</bdi>
|
</bdi>
|
||||||
);
|
);
|
||||||
|
@ -4,7 +4,8 @@ import { connect } from 'react-redux';
|
|||||||
|
|
||||||
import { showAlert } from '../../../actions/alerts';
|
import { showAlert } from '../../../actions/alerts';
|
||||||
import { openModal } from '../../../actions/modal';
|
import { openModal } from '../../../actions/modal';
|
||||||
import { setFilter, clearNotifications, requestBrowserPermission, updateNotificationsPolicy } from '../../../actions/notifications';
|
import { updateNotificationsPolicy } from '../../../actions/notification_policies';
|
||||||
|
import { setFilter, clearNotifications, requestBrowserPermission } from '../../../actions/notifications';
|
||||||
import { changeAlerts as changePushNotifications } from '../../../actions/push_notifications';
|
import { changeAlerts as changePushNotifications } from '../../../actions/push_notifications';
|
||||||
import { changeSetting } from '../../../actions/settings';
|
import { changeSetting } from '../../../actions/settings';
|
||||||
import ColumnSettings from '../components/column_settings';
|
import ColumnSettings from '../components/column_settings';
|
||||||
@ -15,13 +16,16 @@ const messages = defineMessages({
|
|||||||
permissionDenied: { id: 'notifications.permission_denied_alert', defaultMessage: 'Desktop notifications can\'t be enabled, as browser permission has been denied before' },
|
permissionDenied: { id: 'notifications.permission_denied_alert', defaultMessage: 'Desktop notifications can\'t be enabled, as browser permission has been denied before' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('flavours/glitch/store').RootState} state
|
||||||
|
*/
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
settings: state.getIn(['settings', 'notifications']),
|
settings: state.getIn(['settings', 'notifications']),
|
||||||
pushSettings: state.get('push_notifications'),
|
pushSettings: state.get('push_notifications'),
|
||||||
alertsEnabled: state.getIn(['settings', 'notifications', 'alerts']).includes(true),
|
alertsEnabled: state.getIn(['settings', 'notifications', 'alerts']).includes(true),
|
||||||
browserSupport: state.getIn(['notifications', 'browserSupport']),
|
browserSupport: state.getIn(['notifications', 'browserSupport']),
|
||||||
browserPermission: state.getIn(['notifications', 'browserPermission']),
|
browserPermission: state.getIn(['notifications', 'browserPermission']),
|
||||||
notificationPolicy: state.get('notificationPolicy'),
|
notificationPolicy: state.notificationPolicy,
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch, { intl }) => ({
|
const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||||
|
@ -11,10 +11,9 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
|||||||
import DescriptionIcon from '@/material-icons/400-24px/description-fill.svg?react';
|
import DescriptionIcon from '@/material-icons/400-24px/description-fill.svg?react';
|
||||||
import OpenInNewIcon from '@/material-icons/400-24px/open_in_new.svg?react';
|
import OpenInNewIcon from '@/material-icons/400-24px/open_in_new.svg?react';
|
||||||
import PlayArrowIcon from '@/material-icons/400-24px/play_arrow-fill.svg?react';
|
import PlayArrowIcon from '@/material-icons/400-24px/play_arrow-fill.svg?react';
|
||||||
import { Avatar } from 'flavours/glitch/components/avatar';
|
|
||||||
import { Blurhash } from 'flavours/glitch/components/blurhash';
|
import { Blurhash } from 'flavours/glitch/components/blurhash';
|
||||||
import { Icon } from 'flavours/glitch/components/icon';
|
import { Icon } from 'flavours/glitch/components/icon';
|
||||||
import { Permalink } from 'flavours/glitch/components/permalink';
|
import { MoreFromAuthor } from 'flavours/glitch/components/more_from_author';
|
||||||
import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp';
|
import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp';
|
||||||
import { useBlurhash } from 'flavours/glitch/initial_state';
|
import { useBlurhash } from 'flavours/glitch/initial_state';
|
||||||
import { decode as decodeIDNA } from 'flavours/glitch/utils/idna';
|
import { decode as decodeIDNA } from 'flavours/glitch/utils/idna';
|
||||||
@ -48,20 +47,6 @@ const addAutoPlay = html => {
|
|||||||
return html;
|
return html;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MoreFromAuthor = ({ author }) => (
|
|
||||||
<div className='more-from-author'>
|
|
||||||
<svg viewBox='0 0 79 79' className='logo logo--icon' role='img'>
|
|
||||||
<use xlinkHref='#logo-symbol-icon' />
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<FormattedMessage id='link_preview.more_from_author' defaultMessage='More from {name}' values={{ name: <Permalink href={author.get('url')} to={`/@${author.get('acct')}`}><Avatar account={author} size={16} /> {author.get('display_name')}</Permalink> }} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
MoreFromAuthor.propTypes = {
|
|
||||||
author: ImmutablePropTypes.map,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default class Card extends PureComponent {
|
export default class Card extends PureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
@ -142,7 +127,7 @@ export default class Card extends PureComponent {
|
|||||||
const interactive = card.get('type') === 'video';
|
const interactive = card.get('type') === 'video';
|
||||||
const language = card.get('language') || '';
|
const language = card.get('language') || '';
|
||||||
const largeImage = (card.get('image')?.length > 0 && card.get('width') > card.get('height')) || interactive;
|
const largeImage = (card.get('image')?.length > 0 && card.get('width') > card.get('height')) || interactive;
|
||||||
const showAuthor = !!card.get('author_account');
|
const showAuthor = !!card.getIn(['authors', 0, 'accountId']);
|
||||||
|
|
||||||
const description = (
|
const description = (
|
||||||
<div className='status-card__content'>
|
<div className='status-card__content'>
|
||||||
@ -248,7 +233,7 @@ export default class Card extends PureComponent {
|
|||||||
{description}
|
{description}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{showAuthor && <MoreFromAuthor author={card.get('author_account')} />}
|
{showAuthor && <MoreFromAuthor accountId={card.getIn(['authors', 0, 'accountId'])} />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -289,7 +289,7 @@ class DetailedStatus extends ImmutablePureComponent {
|
|||||||
return (
|
return (
|
||||||
<div style={outerStyle}>
|
<div style={outerStyle}>
|
||||||
<div ref={this.setRef} className={classNames('detailed-status', `detailed-status-${status.get('visibility')}`, { compact })} data-status-by={status.getIn(['account', 'acct'])}>
|
<div ref={this.setRef} className={classNames('detailed-status', `detailed-status-${status.get('visibility')}`, { compact })} data-status-by={status.getIn(['account', 'acct'])}>
|
||||||
<a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name'>
|
<a href={status.getIn(['account', 'url'])} data-hover-card-account={status.getIn(['account', 'id'])} onClick={this.handleAccountClick} className='detailed-status__display-name'>
|
||||||
<div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={48} /></div>
|
<div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={48} /></div>
|
||||||
<DisplayName account={status.get('account')} localDomain={this.props.domain} />
|
<DisplayName account={status.get('account')} localDomain={this.props.domain} />
|
||||||
</a>
|
</a>
|
||||||
|
@ -1,11 +1,8 @@
|
|||||||
import Column from '../../../components/column';
|
import Column from 'flavours/glitch/components/column';
|
||||||
import ColumnHeader from '../../../components/column_header';
|
import { ColumnHeader } from 'flavours/glitch/components/column_header';
|
||||||
|
import type { Props as ColumnHeaderProps } from 'flavours/glitch/components/column_header';
|
||||||
|
|
||||||
interface Props {
|
export const ColumnLoading: React.FC<ColumnHeaderProps> = (otherProps) => (
|
||||||
multiColumn?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ColumnLoading: React.FC<Props> = (otherProps) => (
|
|
||||||
<Column>
|
<Column>
|
||||||
<ColumnHeader {...otherProps} />
|
<ColumnHeader {...otherProps} />
|
||||||
<div className='scrollable' />
|
<div className='scrollable' />
|
||||||
|
@ -22,7 +22,8 @@ const SignInBanner = () => {
|
|||||||
if (sso_redirect) {
|
if (sso_redirect) {
|
||||||
return (
|
return (
|
||||||
<div className='sign-in-banner'>
|
<div className='sign-in-banner'>
|
||||||
<p><FormattedMessage id='sign_in_banner.text' defaultMessage='Login to follow profiles or hashtags, favorite, share and reply to posts. You can also interact from your account on a different server.' /></p>
|
<p><strong><FormattedMessage id='sign_in_banner.mastodon_is' defaultMessage="Mastodon is the best way to keep up with what's happening." /></strong></p>
|
||||||
|
<p><FormattedMessage id='sign_in_banner.follow_anyone' defaultMessage='Follow anyone across the fediverse and see it all in chronological order. No algorithms, ads, or clickbait in sight.' /></p>
|
||||||
<a href={sso_redirect} data-method='post' className='button button--block button-tertiary'><FormattedMessage id='sign_in_banner.sso_redirect' defaultMessage='Login or Register' /></a>
|
<a href={sso_redirect} data-method='post' className='button button--block button-tertiary'><FormattedMessage id='sign_in_banner.sso_redirect' defaultMessage='Login or Register' /></a>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -44,7 +45,8 @@ const SignInBanner = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='sign-in-banner'>
|
<div className='sign-in-banner'>
|
||||||
<p><FormattedMessage id='sign_in_banner.text' defaultMessage='Login to follow profiles or hashtags, favorite, share and reply to posts. You can also interact from your account on a different server.' /></p>
|
<p><strong><FormattedMessage id='sign_in_banner.mastodon_is' defaultMessage="Mastodon is the best way to keep up with what's happening." /></strong></p>
|
||||||
|
<p><FormattedMessage id='sign_in_banner.follow_anyone' defaultMessage='Follow anyone across the fediverse and see it all in chronological order. No algorithms, ads, or clickbait in sight.' /></p>
|
||||||
{signupButton}
|
{signupButton}
|
||||||
<a href='/auth/sign_in' className='button button--block button-tertiary'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Login' /></a>
|
<a href='/auth/sign_in' className='button button--block button-tertiary'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Login' /></a>
|
||||||
</div>
|
</div>
|
||||||
|
@ -15,6 +15,7 @@ import { HotKeys } from 'react-hotkeys';
|
|||||||
import { changeLayout } from 'flavours/glitch/actions/app';
|
import { changeLayout } from 'flavours/glitch/actions/app';
|
||||||
import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'flavours/glitch/actions/markers';
|
import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'flavours/glitch/actions/markers';
|
||||||
import { INTRODUCTION_VERSION } from 'flavours/glitch/actions/onboarding';
|
import { INTRODUCTION_VERSION } from 'flavours/glitch/actions/onboarding';
|
||||||
|
import { HoverCardController } from 'flavours/glitch/components/hover_card_controller';
|
||||||
import { Permalink } from 'flavours/glitch/components/permalink';
|
import { Permalink } from 'flavours/glitch/components/permalink';
|
||||||
import { PictureInPicture } from 'flavours/glitch/features/picture_in_picture';
|
import { PictureInPicture } from 'flavours/glitch/features/picture_in_picture';
|
||||||
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
|
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
|
||||||
@ -57,6 +58,7 @@ import {
|
|||||||
FavouritedStatuses,
|
FavouritedStatuses,
|
||||||
BookmarkedStatuses,
|
BookmarkedStatuses,
|
||||||
FollowedTags,
|
FollowedTags,
|
||||||
|
LinkTimeline,
|
||||||
ListTimeline,
|
ListTimeline,
|
||||||
Blocks,
|
Blocks,
|
||||||
DomainBlocks,
|
DomainBlocks,
|
||||||
@ -210,6 +212,7 @@ class SwitchingColumnsArea extends PureComponent {
|
|||||||
<WrappedRoute path='/public/remote' exact component={Firehose} componentParams={{ feedType: 'public:remote' }} content={children} />
|
<WrappedRoute path='/public/remote' exact component={Firehose} componentParams={{ feedType: 'public:remote' }} content={children} />
|
||||||
<WrappedRoute path={['/conversations', '/timelines/direct']} component={DirectTimeline} content={children} />
|
<WrappedRoute path={['/conversations', '/timelines/direct']} component={DirectTimeline} content={children} />
|
||||||
<WrappedRoute path='/tags/:id' component={HashtagTimeline} content={children} />
|
<WrappedRoute path='/tags/:id' component={HashtagTimeline} content={children} />
|
||||||
|
<WrappedRoute path='/links/:url' component={LinkTimeline} content={children} />
|
||||||
<WrappedRoute path='/lists/:id' component={ListTimeline} content={children} />
|
<WrappedRoute path='/lists/:id' component={ListTimeline} content={children} />
|
||||||
<WrappedRoute path='/notifications' component={Notifications} content={children} exact />
|
<WrappedRoute path='/notifications' component={Notifications} content={children} exact />
|
||||||
<WrappedRoute path='/notifications/requests' component={NotificationRequests} content={children} exact />
|
<WrappedRoute path='/notifications/requests' component={NotificationRequests} content={children} exact />
|
||||||
@ -648,6 +651,7 @@ class UI extends PureComponent {
|
|||||||
|
|
||||||
{layout !== 'mobile' && <PictureInPicture />}
|
{layout !== 'mobile' && <PictureInPicture />}
|
||||||
<NotificationsContainer />
|
<NotificationsContainer />
|
||||||
|
<HoverCardController />
|
||||||
<LoadingBarContainer className='loading-bar' />
|
<LoadingBarContainer className='loading-bar' />
|
||||||
<ModalContainer />
|
<ModalContainer />
|
||||||
<UploadArea active={draggingOver} onClose={this.closeUploadModal} />
|
<UploadArea active={draggingOver} onClose={this.closeUploadModal} />
|
||||||
|
@ -213,3 +213,7 @@ export function NotificationRequests () {
|
|||||||
export function NotificationRequest () {
|
export function NotificationRequest () {
|
||||||
return import(/*webpackChunkName: "features/glitch/notifications/request" */'../../notifications/request');
|
return import(/*webpackChunkName: "features/glitch/notifications/request" */'../../notifications/request');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function LinkTimeline () {
|
||||||
|
return import(/*webpackChunkName: "features/glitch/link_timeline" */'../../link_timeline');
|
||||||
|
}
|
||||||
|
61
app/javascript/flavours/glitch/hooks/useLinks.ts
Normal file
61
app/javascript/flavours/glitch/hooks/useLinks.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
import { useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { openURL } from 'flavours/glitch/actions/search';
|
||||||
|
import { useAppDispatch } from 'flavours/glitch/store';
|
||||||
|
|
||||||
|
const isMentionClick = (element: HTMLAnchorElement) =>
|
||||||
|
element.classList.contains('mention');
|
||||||
|
|
||||||
|
const isHashtagClick = (element: HTMLAnchorElement) =>
|
||||||
|
element.textContent?.[0] === '#' ||
|
||||||
|
element.previousSibling?.textContent?.endsWith('#');
|
||||||
|
|
||||||
|
export const useLinks = () => {
|
||||||
|
const history = useHistory();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const handleHashtagClick = useCallback(
|
||||||
|
(element: HTMLAnchorElement) => {
|
||||||
|
const { textContent } = element;
|
||||||
|
|
||||||
|
if (!textContent) return;
|
||||||
|
|
||||||
|
history.push(`/tags/${textContent.replace(/^#/, '')}`);
|
||||||
|
},
|
||||||
|
[history],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMentionClick = useCallback(
|
||||||
|
(element: HTMLAnchorElement) => {
|
||||||
|
dispatch(
|
||||||
|
openURL(element.href, history, () => {
|
||||||
|
window.location.href = element.href;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[dispatch, history],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClick = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
const target = (e.target as HTMLElement).closest('a');
|
||||||
|
|
||||||
|
if (!target || e.button !== 0 || e.ctrlKey || e.metaKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMentionClick(target)) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleMentionClick(target);
|
||||||
|
} else if (isHashtagClick(target)) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleHashtagClick(target);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleMentionClick, handleHashtagClick],
|
||||||
|
);
|
||||||
|
|
||||||
|
return handleClick;
|
||||||
|
};
|
44
app/javascript/flavours/glitch/hooks/useTimeout.ts
Normal file
44
app/javascript/flavours/glitch/hooks/useTimeout.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { useRef, useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
|
export const useTimeout = () => {
|
||||||
|
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
const callbackRef = useRef<() => void>();
|
||||||
|
|
||||||
|
const set = useCallback((callback: () => void, delay: number) => {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
callbackRef.current = callback;
|
||||||
|
timeoutRef.current = setTimeout(callback, delay);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const delay = useCallback((delay: number) => {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!callbackRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
timeoutRef.current = setTimeout(callbackRef.current, delay);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cancel = useCallback(() => {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = undefined;
|
||||||
|
callbackRef.current = undefined;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
cancel();
|
||||||
|
},
|
||||||
|
[cancel],
|
||||||
|
);
|
||||||
|
|
||||||
|
return [set, cancel, delay] as const;
|
||||||
|
};
|
@ -0,0 +1,3 @@
|
|||||||
|
import type { NotificationPolicyJSON } from 'flavours/glitch/api_types/notification_policies';
|
||||||
|
|
||||||
|
export type NotificationPolicy = NotificationPolicyJSON; // No changes from the API type
|
@ -1,4 +1,12 @@
|
|||||||
|
import type { RecordOf } from 'immutable';
|
||||||
|
|
||||||
|
import type { ApiPreviewCardJSON } from 'flavours/glitch/api_types/statuses';
|
||||||
|
|
||||||
export type { StatusVisibility } from 'flavours/glitch/api_types/statuses';
|
export type { StatusVisibility } from 'flavours/glitch/api_types/statuses';
|
||||||
|
|
||||||
// Temporary until we type it correctly
|
// Temporary until we type it correctly
|
||||||
export type Status = Immutable.Map<string, unknown>;
|
export type Status = Immutable.Map<string, unknown>;
|
||||||
|
|
||||||
|
type CardShape = Required<ApiPreviewCardJSON>;
|
||||||
|
|
||||||
|
export type Card = RecordOf<CardShape>;
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user