mirror of
https://github.com/funamitech/mastodon
synced 2024-11-23 14:46:52 +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,
|
||||
},
|
||||
|
||||
globals: {
|
||||
ATTACHMENT_HOST: false,
|
||||
},
|
||||
|
||||
parser: '@typescript-eslint/parser',
|
||||
|
||||
plugins: [
|
||||
@ -79,7 +75,7 @@ module.exports = defineConfig({
|
||||
],
|
||||
},
|
||||
],
|
||||
'no-empty': 'off',
|
||||
'no-empty': ['error', { "allowEmptyCatch": true }],
|
||||
'no-restricted-properties': [
|
||||
'error',
|
||||
{ property: 'substring', message: 'Use .slice instead of .substring.' },
|
||||
@ -94,7 +90,6 @@ module.exports = defineConfig({
|
||||
message: "Use '·' (middle dot) instead of '•' (bullet)",
|
||||
},
|
||||
],
|
||||
'no-self-assign': 'off',
|
||||
'no-unused-expressions': 'error',
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
@ -119,12 +114,10 @@ module.exports = defineConfig({
|
||||
'react/jsx-tag-spacing': 'error',
|
||||
'react/jsx-uses-react': 'off', // not needed with new JSX transform
|
||||
'react/jsx-wrap-multilines': 'error',
|
||||
'react/no-deprecated': 'off',
|
||||
'react/react-in-jsx-scope': 'off', // not needed with new JSX transform
|
||||
'react/self-closing-comp': 'error',
|
||||
|
||||
// recommended values found in https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/v6.8.0/src/index.js#L46
|
||||
'jsx-a11y/accessible-emoji': 'warn',
|
||||
'jsx-a11y/click-events-have-key-events': 'off',
|
||||
'jsx-a11y/label-has-associated-control': 'off',
|
||||
'jsx-a11y/media-has-caption': 'off',
|
||||
@ -139,23 +132,6 @@ module.exports = defineConfig({
|
||||
// ],
|
||||
'jsx-a11y/no-interactive-element-to-noninteractive-role': 'off',
|
||||
// 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': [
|
||||
// 'error',
|
||||
// {
|
||||
@ -165,7 +141,6 @@ module.exports = defineConfig({
|
||||
// },
|
||||
// ],
|
||||
'jsx-a11y/no-noninteractive-tabindex': 'off',
|
||||
'jsx-a11y/no-onchange': 'off',
|
||||
// recommended is full 'error'
|
||||
'jsx-a11y/no-static-element-interactions': [
|
||||
'warn',
|
||||
@ -366,6 +341,9 @@ module.exports = defineConfig({
|
||||
// Disable formatting rules that have been enabled in the base config
|
||||
'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'],
|
||||
|
||||
'@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:
|
||||
- 'Gemfile*'
|
||||
- '.ruby-version'
|
||||
- '.bundler-audit.yml'
|
||||
- '.github/workflows/bundler-audit.yml'
|
||||
|
||||
pull_request:
|
||||
paths:
|
||||
- 'Gemfile*'
|
||||
- '.ruby-version'
|
||||
- '.bundler-audit.yml'
|
||||
- '.github/workflows/bundler-audit.yml'
|
||||
|
||||
schedule:
|
||||
@ -23,12 +21,17 @@ jobs:
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
BUNDLE_ONLY: development
|
||||
|
||||
steps:
|
||||
- name: Clone repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Ruby environment
|
||||
uses: ./.github/actions/setup-ruby
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
- 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:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
BUNDLE_ONLY: development
|
||||
|
||||
steps:
|
||||
- name: Clone repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Ruby environment
|
||||
uses: ./.github/actions/setup-ruby
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
- name: Run haml-lint
|
||||
run: |
|
||||
|
13
.github/workflows/lint-ruby.yml
vendored
13
.github/workflows/lint-ruby.yml
vendored
@ -27,19 +27,24 @@ jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
BUNDLE_ONLY: development
|
||||
|
||||
steps:
|
||||
- name: Clone repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Ruby environment
|
||||
uses: ./.github/actions/setup-ruby
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
- name: Set-up RuboCop Problem Matcher
|
||||
uses: r7kamura/rubocop-problem-matchers-action@v1
|
||||
|
||||
- name: Run rubocop
|
||||
run: bundle exec rubocop
|
||||
run: bin/rubocop
|
||||
|
||||
- name: Run brakeman
|
||||
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:
|
||||
push:
|
||||
branches-ignore:
|
||||
@ -17,7 +18,7 @@ jobs:
|
||||
- id: skip_check
|
||||
uses: fkirc/skip-duplicate-actions@v5
|
||||
with:
|
||||
paths: '["Gemfile*", ".ruby-version", "**/*.rb", ".github/workflows/test-migrations-one-step.yml", "lib/tasks/tests.rake"]'
|
||||
paths: '["Gemfile*", ".ruby-version", "**/*.rb", ".github/workflows/test-migrations.yml", "lib/tasks/tests.rake"]'
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
@ -40,9 +41,9 @@ jobs:
|
||||
POSTGRES_USER: postgres
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
--health-interval 10ms
|
||||
--health-timeout 3s
|
||||
--health-retries 50
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
@ -50,14 +51,13 @@ jobs:
|
||||
image: redis:7-alpine
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
--health-interval 10ms
|
||||
--health-timeout 3s
|
||||
--health-retries 50
|
||||
ports:
|
||||
- 6379:6379
|
||||
|
||||
env:
|
||||
CONTINUOUS_INTEGRATION: true
|
||||
DB_HOST: localhost
|
||||
DB_USER: postgres
|
||||
DB_PASS: postgres
|
||||
@ -65,7 +65,7 @@ jobs:
|
||||
RAILS_ENV: test
|
||||
BUNDLE_CLEAN: true
|
||||
BUNDLE_FROZEN: true
|
||||
BUNDLE_WITHOUT: 'development production'
|
||||
BUNDLE_WITHOUT: 'development:production'
|
||||
BUNDLE_JOBS: 3
|
||||
BUNDLE_RETRY: 3
|
||||
|
||||
@ -75,14 +75,19 @@ jobs:
|
||||
- name: Set up Ruby environment
|
||||
uses: ./.github/actions/setup-ruby
|
||||
|
||||
- name: Create database
|
||||
run: './bin/rails db:create'
|
||||
- name: Test "one step migration" flow
|
||||
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
|
||||
run: './bin/rails tests:migrations:prepare_database'
|
||||
|
||||
- name: Run all remaining migrations
|
||||
run: './bin/rails db:migrate'
|
||||
|
||||
- name: Check migration result
|
||||
run: './bin/rails tests:migrations:check_database'
|
||||
- name: Test "two step migration" flow
|
||||
run: |
|
||||
bin/rails db:drop
|
||||
bin/rails db:create
|
||||
SKIP_POST_DEPLOYMENT_MIGRATIONS=true bin/rails tests:migrations:prepare_database
|
||||
SKIP_POST_DEPLOYMENT_MIGRATIONS=true bin/rails db:migrate
|
||||
bin/rails db:migrate
|
||||
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:
|
||||
RAILS_ENV: ${{ matrix.mode }}
|
||||
BUNDLE_WITH: ${{ matrix.mode }}
|
||||
ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY: precompile_placeholder
|
||||
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT: precompile_placeholder
|
||||
ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY: precompile_placeholder
|
||||
OTP_SECRET: precompile_placeholder
|
||||
SECRET_KEY_BASE: precompile_placeholder
|
||||
SECRET_KEY_BASE_DUMMY: 1
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@ -77,9 +73,9 @@ jobs:
|
||||
POSTGRES_USER: postgres
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
--health-interval 10ms
|
||||
--health-timeout 3s
|
||||
--health-retries 50
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
@ -87,9 +83,9 @@ jobs:
|
||||
image: redis:7-alpine
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
--health-interval 10ms
|
||||
--health-timeout 3s
|
||||
--health-retries 50
|
||||
ports:
|
||||
- 6379:6379
|
||||
|
||||
@ -136,15 +132,17 @@ jobs:
|
||||
additional-system-dependencies: ffmpeg libpam-dev
|
||||
|
||||
- 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
|
||||
if: matrix.ruby-version == '.ruby-version'
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
files: coverage/lcov/mastodon.lcov
|
||||
files: coverage/lcov/*.lcov
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
@ -163,9 +161,9 @@ jobs:
|
||||
POSTGRES_USER: postgres
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
--health-interval 10ms
|
||||
--health-timeout 3s
|
||||
--health-retries 50
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
@ -173,9 +171,9 @@ jobs:
|
||||
image: redis:7-alpine
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
--health-interval 10ms
|
||||
--health-timeout 3s
|
||||
--health-retries 50
|
||||
ports:
|
||||
- 6379:6379
|
||||
|
||||
@ -250,9 +248,9 @@ jobs:
|
||||
POSTGRES_USER: postgres
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
--health-interval 10ms
|
||||
--health-timeout 3s
|
||||
--health-retries 50
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
@ -260,9 +258,9 @@ jobs:
|
||||
image: redis:7-alpine
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
--health-interval 10ms
|
||||
--health-timeout 3s
|
||||
--health-retries 50
|
||||
ports:
|
||||
- 6379:6379
|
||||
|
||||
@ -289,9 +287,13 @@ jobs:
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: './public'
|
||||
path: './'
|
||||
name: ${{ github.sha }}
|
||||
|
||||
- name: Expand archived asset artifacts
|
||||
run: |
|
||||
tar xvzf artifacts.tar.gz
|
||||
|
||||
- name: Set up Ruby environment
|
||||
uses: ./.github/actions/setup-ruby
|
||||
with:
|
||||
@ -335,9 +337,9 @@ jobs:
|
||||
POSTGRES_USER: postgres
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
--health-interval 10ms
|
||||
--health-timeout 3s
|
||||
--health-retries 50
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
@ -345,9 +347,9 @@ jobs:
|
||||
image: redis:7-alpine
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
--health-interval 10ms
|
||||
--health-timeout 3s
|
||||
--health-retries 50
|
||||
ports:
|
||||
- 6379:6379
|
||||
|
||||
@ -358,9 +360,9 @@ jobs:
|
||||
xpack.security.enabled: false
|
||||
options: >-
|
||||
--health-cmd "curl http://localhost:9200/_cluster/health"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
--health-interval 2s
|
||||
--health-timeout 3s
|
||||
--health-retries 50
|
||||
ports:
|
||||
- 9200:9200
|
||||
|
||||
@ -372,9 +374,9 @@ jobs:
|
||||
DISABLE_SECURITY_PLUGIN: true
|
||||
options: >-
|
||||
--health-cmd "curl http://localhost:9200/_cluster/health"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
--health-interval 2s
|
||||
--health-timeout 3s
|
||||
--health-retries 50
|
||||
ports:
|
||||
- 9200:9200
|
||||
|
||||
@ -409,7 +411,7 @@ jobs:
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: './public'
|
||||
path: './'
|
||||
name: ${{ github.sha }}
|
||||
|
||||
- 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:
|
||||
merge:
|
||||
- Exclude
|
||||
@ -12,229 +32,3 @@ require:
|
||||
- rubocop-rspec_rails
|
||||
- rubocop-performance
|
||||
- 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
|
||||
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-exclude-limit --no-offense-counts --no-auto-gen-timestamp`
|
||||
# using RuboCop version 1.63.5.
|
||||
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp`
|
||||
# using RuboCop version 1.64.1.
|
||||
# The point is for the user to remove these configuration records
|
||||
# one by one as the offenses are removed from the code base.
|
||||
# Note that changes in the inspected code, or installation of new
|
||||
@ -27,33 +27,10 @@ Metrics/CyclomaticComplexity:
|
||||
Metrics/PerceivedComplexity:
|
||||
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:
|
||||
Exclude:
|
||||
- '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).
|
||||
# Configuration parameters: AllowedVars.
|
||||
Style/FetchEnvVar:
|
||||
@ -70,7 +47,6 @@ Style/FetchEnvVar:
|
||||
- 'config/initializers/vapid.rb'
|
||||
- 'lib/mastodon/redis_config.rb'
|
||||
- 'lib/tasks/repo.rake'
|
||||
- 'spec/system/profile_spec.rb'
|
||||
|
||||
# This cop supports safe autocorrection (--autocorrect).
|
||||
# Configuration parameters: EnforcedStyle, MaxUnannotatedPlaceholdersAllowed, AllowedMethods, AllowedPatterns.
|
||||
@ -81,40 +57,10 @@ Style/FormatStringToken:
|
||||
- 'config/initializers/devise.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).
|
||||
# Configuration parameters: MinBodyLength, AllowConsecutiveConditionals.
|
||||
Style/GuardClause:
|
||||
Exclude:
|
||||
- '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'
|
||||
Enabled: false
|
||||
|
||||
# This cop supports unsafe autocorrection (--autocorrect-all).
|
||||
Style/HashTransformValues:
|
||||
@ -136,16 +82,10 @@ Style/MutableConstant:
|
||||
- 'app/services/delete_account_service.rb'
|
||||
- 'lib/mastodon/migration_warning.rb'
|
||||
|
||||
# This cop supports safe autocorrection (--autocorrect).
|
||||
Style/NilLambda:
|
||||
Exclude:
|
||||
- 'config/initializers/paperclip.rb'
|
||||
|
||||
# Configuration parameters: AllowedMethods.
|
||||
# AllowedMethods: respond_to_missing?
|
||||
Style/OptionalBooleanParameter:
|
||||
Exclude:
|
||||
- 'app/helpers/admin/account_moderation_notes_helper.rb'
|
||||
- 'app/helpers/jsonld_helper.rb'
|
||||
- 'app/lib/admin/system_check/message.rb'
|
||||
- 'app/lib/request.rb'
|
||||
@ -169,13 +109,6 @@ Style/RedundantConstantBase:
|
||||
- 'config/environments/production.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).
|
||||
# Configuration parameters: WordRegex.
|
||||
# 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.
|
||||
|
||||
## [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
|
||||
|
||||
### 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
|
||||
# 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"]
|
||||
# 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"]
|
||||
# renovate: datasource=node-version depName=node
|
||||
ARG NODE_MAJOR_VERSION="20"
|
||||
# Debian image to use for base image, change with [--build-arg DEBIAN_VERSION="bookworm"]
|
||||
ARG DEBIAN_VERSION="bookworm"
|
||||
# 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)
|
||||
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
|
||||
# Example: v4.2.0-nightly.2023.11.09+something
|
||||
# Overwrite existence of 'alpha.0' in version.rb [--build-arg MASTODON_VERSION_PRERELEASE="nightly.2023.11.09"]
|
||||
# Example: v4.3.0-nightly.2023.11.09+pr-123456
|
||||
# Overwrite existence of 'alpha.X' in version.rb [--build-arg MASTODON_VERSION_PRERELEASE="nightly.2023.11.09"]
|
||||
ARG MASTODON_VERSION_PRERELEASE=""
|
||||
# Append build metadata or fork information to version.rb [--build-arg MASTODON_VERSION_METADATA="pr-12345"]
|
||||
# Append build metadata or fork information to version.rb [--build-arg MASTODON_VERSION_METADATA="pr-123456"]
|
||||
ARG MASTODON_VERSION_METADATA=""
|
||||
|
||||
# Allow Ruby on Rails to serve static files
|
||||
@ -48,8 +48,6 @@ ENV \
|
||||
# Apply Mastodon version information
|
||||
MASTODON_VERSION_PRERELEASE="${MASTODON_VERSION_PRERELEASE}" \
|
||||
MASTODON_VERSION_METADATA="${MASTODON_VERSION_METADATA}" \
|
||||
# Enable libvips
|
||||
MASTODON_USE_LIBVIPS=true \
|
||||
# Apply Mastodon static files and YJIT options
|
||||
RAILS_SERVE_STATIC_FILES=${RAILS_SERVE_STATIC_FILES} \
|
||||
RUBY_YJIT_ENABLE=${RUBY_YJIT_ENABLE} \
|
||||
@ -67,7 +65,9 @@ ENV \
|
||||
DEBIAN_FRONTEND="noninteractive" \
|
||||
PATH="${PATH}:/opt/ruby/bin:/opt/mastodon/bin" \
|
||||
# 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
|
||||
SHELL ["/bin/bash", "-o", "pipefail", "-o", "errexit", "-c"]
|
||||
@ -100,11 +100,8 @@ RUN \
|
||||
apt-get dist-upgrade -yq; \
|
||||
# Install jemalloc, curl and other necessary components
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
ffmpeg \
|
||||
file \
|
||||
libvips42 \
|
||||
libjemalloc2 \
|
||||
patchelf \
|
||||
procps \
|
||||
@ -120,7 +117,7 @@ RUN \
|
||||
;
|
||||
|
||||
# Create temporary build layer from base image
|
||||
FROM ruby as build
|
||||
FROM ruby AS build
|
||||
|
||||
# Copy Node package configuration files into working directory
|
||||
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 \
|
||||
# Install build tools and bundler dependencies from APT
|
||||
apt-get install -y --no-install-recommends \
|
||||
g++ \
|
||||
gcc \
|
||||
autoconf \
|
||||
automake \
|
||||
build-essential \
|
||||
cmake \
|
||||
git \
|
||||
libgdbm-dev \
|
||||
libglib2.0-dev \
|
||||
libgmp-dev \
|
||||
libicu-dev \
|
||||
libidn-dev \
|
||||
libpq-dev \
|
||||
libssl-dev \
|
||||
make \
|
||||
libtool \
|
||||
meson \
|
||||
nasm \
|
||||
pkg-config \
|
||||
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 \
|
||||
@ -158,8 +184,70 @@ RUN \
|
||||
corepack enable; \
|
||||
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
|
||||
FROM build as bundler
|
||||
FROM build AS bundler
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
@ -181,7 +269,7 @@ RUN \
|
||||
bundle install -j"$(nproc)";
|
||||
|
||||
# Create temporary node specific build layer from build layer
|
||||
FROM build as yarn
|
||||
FROM build AS yarn
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
@ -198,7 +286,7 @@ RUN \
|
||||
yarn workspaces focus --production @mastodon/mastodon;
|
||||
|
||||
# Create temporary assets build layer from build layer
|
||||
FROM build as precompiler
|
||||
FROM build AS precompiler
|
||||
|
||||
# Copy Mastodon sources into precompiler layer
|
||||
COPY . /opt/mastodon/
|
||||
@ -207,22 +295,22 @@ COPY . /opt/mastodon/
|
||||
COPY --from=yarn /opt/mastodon /opt/mastodon/
|
||||
COPY --from=bundler /opt/mastodon /opt/mastodon/
|
||||
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
|
||||
|
||||
RUN \
|
||||
ldconfig; \
|
||||
# Use Ruby on Rails to create Mastodon assets
|
||||
ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=precompile_placeholder \
|
||||
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=precompile_placeholder \
|
||||
ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=precompile_placeholder \
|
||||
OTP_SECRET=precompile_placeholder \
|
||||
SECRET_KEY_BASE=precompile_placeholder \
|
||||
SECRET_KEY_BASE_DUMMY=1 \
|
||||
bundle exec rails assets:precompile; \
|
||||
# Cleanup temporary files
|
||||
rm -fr /opt/mastodon/tmp;
|
||||
|
||||
# Prep final Mastodon Ruby layer
|
||||
FROM ruby as mastodon
|
||||
FROM ruby AS mastodon
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
@ -236,12 +324,41 @@ RUN \
|
||||
--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-get install -y --no-install-recommends \
|
||||
libssl3 \
|
||||
libpq5 \
|
||||
libexpat1 \
|
||||
libglib2.0-0 \
|
||||
libicu72 \
|
||||
libidn12 \
|
||||
libpq5 \
|
||||
libreadline8 \
|
||||
libssl3 \
|
||||
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
|
||||
@ -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 bundler components to layer
|
||||
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 \
|
||||
# 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/;
|
||||
|
||||
RUN \
|
||||
|
18
Gemfile
18
Gemfile
@ -9,9 +9,6 @@ gem 'rack', '~> 2.2.7'
|
||||
gem 'rails', '~> 7.1.1'
|
||||
gem 'thor', '~> 1.2'
|
||||
|
||||
# For why irb is in the Gemfile, see: https://ruby.social/@st0012/111444685161478182
|
||||
gem 'irb', '~> 1.8'
|
||||
|
||||
gem 'dotenv'
|
||||
gem 'haml-rails', '~>2.0'
|
||||
gem 'pg', '~> 1.5'
|
||||
@ -28,7 +25,7 @@ gem 'ruby-vips', '~> 2.2', require: false
|
||||
gem 'active_model_serializers', '~> 0.10'
|
||||
gem 'addressable', '~> 2.8'
|
||||
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 'chewy', '~> 7.3'
|
||||
gem 'devise', '~> 4.9'
|
||||
@ -61,6 +58,7 @@ gem 'httplog', '~> 1.7.0'
|
||||
gem 'i18n'
|
||||
gem 'idn-ruby', require: 'idn'
|
||||
gem 'inline_svg'
|
||||
gem 'irb', '~> 1.8'
|
||||
gem 'kaminari', '~> 1.2'
|
||||
gem 'link_header', '~> 0.0'
|
||||
gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock'
|
||||
@ -71,7 +69,7 @@ gem 'oj', '~> 3.14'
|
||||
gem 'ox', '~> 2.14'
|
||||
gem 'parslet'
|
||||
gem 'premailer-rails'
|
||||
gem 'public_suffix', '~> 5.0'
|
||||
gem 'public_suffix', '~> 6.0'
|
||||
gem 'pundit', '~> 2.3'
|
||||
gem 'rack-attack', '~> 6.6'
|
||||
gem 'rack-cors', '~> 2.0', require: 'rack/cors'
|
||||
@ -102,12 +100,10 @@ gem 'json-ld'
|
||||
gem 'json-ld-preloaded', '~> 3.2'
|
||||
gem 'rdf-normalize', '~> 0.5'
|
||||
|
||||
gem 'private_address_check', '~> 0.5'
|
||||
|
||||
gem 'opentelemetry-api', '~> 1.2.5'
|
||||
|
||||
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_model_serializers', '~> 0.20.1', 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-pg', '~> 0.27.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-sidekiq', '~> 0.25.2', require: false
|
||||
gem 'opentelemetry-sdk', '~> 1.4', require: false
|
||||
end
|
||||
|
||||
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
|
||||
gem 'rspec-github', '~> 2.4', require: false
|
||||
|
||||
@ -171,6 +170,7 @@ group :development do
|
||||
gem 'rubocop-performance', require: false
|
||||
gem 'rubocop-rails', require: false
|
||||
gem 'rubocop-rspec', require: false
|
||||
gem 'rubocop-rspec_rails', require: false
|
||||
|
||||
# Annotates modules with schema
|
||||
gem 'annotate', '~> 3.2'
|
||||
|
147
Gemfile.lock
147
Gemfile.lock
@ -89,8 +89,8 @@ GEM
|
||||
minitest (>= 5.1)
|
||||
mutex_m
|
||||
tzinfo (~> 2.0)
|
||||
addressable (2.8.6)
|
||||
public_suffix (>= 2.0.2, < 6.0)
|
||||
addressable (2.8.7)
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
aes_key_wrap (1.1.0)
|
||||
android_key_attestation (0.3.0)
|
||||
annotate (3.2.0)
|
||||
@ -100,19 +100,19 @@ GEM
|
||||
attr_required (1.0.2)
|
||||
awrence (1.2.1)
|
||||
aws-eventstream (1.3.0)
|
||||
aws-partitions (1.940.0)
|
||||
aws-sdk-core (3.197.0)
|
||||
aws-partitions (1.950.0)
|
||||
aws-sdk-core (3.201.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.651.0)
|
||||
aws-sigv4 (~> 1.8)
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
aws-sdk-kms (1.83.0)
|
||||
aws-sdk-core (~> 3, >= 3.197.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
aws-sdk-s3 (1.152.0)
|
||||
aws-sdk-core (~> 3, >= 3.197.0)
|
||||
aws-sdk-kms (1.88.0)
|
||||
aws-sdk-core (~> 3, >= 3.201.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.156.0)
|
||||
aws-sdk-core (~> 3, >= 3.201.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.8)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.8.0)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
azure-storage-blob (2.0.3)
|
||||
@ -143,7 +143,7 @@ GEM
|
||||
brpoplpush-redis_script (0.1.3)
|
||||
concurrent-ruby (~> 1.0, >= 1.0.5)
|
||||
redis (>= 1.0, < 6)
|
||||
builder (3.2.4)
|
||||
builder (3.3.0)
|
||||
bundler-audit (0.9.1)
|
||||
bundler (>= 1.2.0, < 3)
|
||||
thor (~> 1.0)
|
||||
@ -159,7 +159,7 @@ GEM
|
||||
case_transform (0.2)
|
||||
activesupport
|
||||
cbor (0.5.9.8)
|
||||
charlock_holmes (0.7.7)
|
||||
charlock_holmes (0.7.8)
|
||||
chewy (7.6.0)
|
||||
activesupport (>= 5.2)
|
||||
elasticsearch (>= 7.14.0, < 8)
|
||||
@ -195,7 +195,7 @@ GEM
|
||||
railties (>= 4.1.0)
|
||||
responders
|
||||
warden (~> 1.2.3)
|
||||
devise-two-factor (5.0.0)
|
||||
devise-two-factor (5.1.0)
|
||||
activesupport (~> 7.0)
|
||||
devise (~> 4.0)
|
||||
railties (~> 7.0)
|
||||
@ -208,7 +208,7 @@ GEM
|
||||
activerecord (>= 4.2, < 8)
|
||||
docile (1.4.0)
|
||||
domain_name (0.6.20240107)
|
||||
doorkeeper (5.6.9)
|
||||
doorkeeper (5.7.1)
|
||||
railties (>= 5)
|
||||
dotenv (3.1.2)
|
||||
drb (2.2.1)
|
||||
@ -226,7 +226,7 @@ GEM
|
||||
htmlentities (~> 4.3.3)
|
||||
launchy (~> 2.1)
|
||||
mail (~> 2.7)
|
||||
erubi (1.12.0)
|
||||
erubi (1.13.0)
|
||||
et-orbi (1.2.11)
|
||||
tzinfo
|
||||
excon (0.110.0)
|
||||
@ -264,6 +264,11 @@ GEM
|
||||
ffi-compiler (1.3.2)
|
||||
ffi (>= 1.15.5)
|
||||
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)
|
||||
builder
|
||||
excon (~> 0.71)
|
||||
@ -272,7 +277,7 @@ GEM
|
||||
fog-json (1.2.0)
|
||||
fog-core
|
||||
multi_json (~> 1.10)
|
||||
fog-openstack (1.1.1)
|
||||
fog-openstack (1.1.3)
|
||||
fog-core (~> 2.1)
|
||||
fog-json (>= 1.0)
|
||||
formatador (1.1.0)
|
||||
@ -341,7 +346,7 @@ GEM
|
||||
activesupport (>= 3.0)
|
||||
nokogiri (>= 1.6)
|
||||
io-console (0.7.2)
|
||||
irb (1.13.1)
|
||||
irb (1.13.2)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jmespath (1.6.2)
|
||||
@ -398,6 +403,7 @@ GEM
|
||||
llhttp-ffi (0.5.0)
|
||||
ffi-compiler (~> 1.0)
|
||||
rake (~> 13.0)
|
||||
logger (1.6.0)
|
||||
lograge (0.14.0)
|
||||
actionpack (>= 4)
|
||||
activesupport (>= 4)
|
||||
@ -419,13 +425,13 @@ GEM
|
||||
addressable (~> 2.5)
|
||||
azure-storage-blob (~> 2.0.1)
|
||||
hashie (~> 5.0)
|
||||
memory_profiler (1.0.1)
|
||||
memory_profiler (1.0.2)
|
||||
mime-types (3.5.2)
|
||||
mime-types-data (~> 3.2015)
|
||||
mime-types-data (3.2024.0507)
|
||||
mime-types-data (3.2024.0604)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.7)
|
||||
minitest (5.23.1)
|
||||
minitest (5.24.1)
|
||||
msgpack (1.7.2)
|
||||
multi_json (1.15.0)
|
||||
multipart-post (2.4.0)
|
||||
@ -445,7 +451,7 @@ GEM
|
||||
net-smtp (0.5.0)
|
||||
net-protocol
|
||||
nio4r (2.7.3)
|
||||
nokogiri (1.16.5)
|
||||
nokogiri (1.16.6)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
nsa (0.3.0)
|
||||
@ -489,8 +495,8 @@ GEM
|
||||
opentelemetry-api (1.2.5)
|
||||
opentelemetry-common (0.20.1)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-exporter-otlp (0.27.0)
|
||||
google-protobuf (~> 3.14)
|
||||
opentelemetry-exporter-otlp (0.28.0)
|
||||
google-protobuf (>= 3.18)
|
||||
googleapis-common-protos-types (~> 1.3)
|
||||
opentelemetry-api (~> 1.1)
|
||||
opentelemetry-common (~> 0.20)
|
||||
@ -498,6 +504,10 @@ GEM
|
||||
opentelemetry-semantic_conventions
|
||||
opentelemetry-helpers-sql-obfuscation (0.1.0)
|
||||
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-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||
@ -506,7 +516,7 @@ GEM
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-active_support (~> 0.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-instrumentation-base (~> 0.22.1)
|
||||
opentelemetry-instrumentation-active_model_serializers (0.20.1)
|
||||
@ -515,7 +525,7 @@ GEM
|
||||
opentelemetry-instrumentation-active_record (0.7.2)
|
||||
opentelemetry-api (~> 1.0)
|
||||
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-instrumentation-base (~> 0.22.1)
|
||||
opentelemetry-instrumentation-base (0.22.3)
|
||||
@ -524,48 +534,42 @@ GEM
|
||||
opentelemetry-instrumentation-concurrent_ruby (0.21.3)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||
opentelemetry-instrumentation-excon (0.22.1)
|
||||
opentelemetry-instrumentation-excon (0.22.3)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-common (~> 0.20.0)
|
||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||
opentelemetry-instrumentation-faraday (0.24.2)
|
||||
opentelemetry-instrumentation-faraday (0.24.5)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-common (~> 0.20.0)
|
||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||
opentelemetry-instrumentation-http (0.23.3)
|
||||
opentelemetry-api (~> 1.0)
|
||||
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-common (~> 0.20.0)
|
||||
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-common (~> 0.20.0)
|
||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||
opentelemetry-instrumentation-pg (0.27.3)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-helpers-sql-obfuscation
|
||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||
opentelemetry-instrumentation-rack (0.24.3)
|
||||
opentelemetry-instrumentation-rack (0.24.5)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-common (~> 0.20.0)
|
||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||
opentelemetry-instrumentation-rails (0.30.1)
|
||||
opentelemetry-instrumentation-rails (0.31.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-action_mailer (~> 0.1.0)
|
||||
opentelemetry-instrumentation-action_pack (~> 0.9.0)
|
||||
opentelemetry-instrumentation-action_view (~> 0.7.0)
|
||||
opentelemetry-instrumentation-active_job (~> 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-redis (0.25.4)
|
||||
opentelemetry-instrumentation-redis (0.25.6)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-common (~> 0.20.0)
|
||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||
opentelemetry-instrumentation-sidekiq (0.25.3)
|
||||
opentelemetry-instrumentation-sidekiq (0.25.6)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-common (~> 0.20.0)
|
||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||
opentelemetry-registry (0.3.1)
|
||||
opentelemetry-api (~> 1.1)
|
||||
@ -579,7 +583,7 @@ GEM
|
||||
orm_adapter (0.5.0)
|
||||
ox (2.14.18)
|
||||
parallel (1.25.1)
|
||||
parser (3.3.2.0)
|
||||
parser (3.3.3.0)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
parslet (2.0.0)
|
||||
@ -596,7 +600,6 @@ GEM
|
||||
actionmailer (>= 3)
|
||||
net-smtp
|
||||
premailer (~> 1.7, >= 1.7.9)
|
||||
private_address_check (0.5.0)
|
||||
propshaft (0.9.0)
|
||||
actionpack (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
@ -604,7 +607,7 @@ GEM
|
||||
railties (>= 7.0.0)
|
||||
psych (5.1.2)
|
||||
stringio
|
||||
public_suffix (5.0.5)
|
||||
public_suffix (6.0.0)
|
||||
puma (6.4.2)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.3.2)
|
||||
@ -677,7 +680,7 @@ GEM
|
||||
link_header (~> 0.0, >= 0.0.8)
|
||||
rdf-normalize (0.7.0)
|
||||
rdf (~> 3.3)
|
||||
rdoc (6.6.3.1)
|
||||
rdoc (6.7.0)
|
||||
psych (>= 4.0.0)
|
||||
redcarpet (3.6.0)
|
||||
redis (4.8.1)
|
||||
@ -686,15 +689,15 @@ GEM
|
||||
redlock (1.3.2)
|
||||
redis (>= 3.0.0, < 6.0)
|
||||
regexp_parser (2.9.2)
|
||||
reline (0.5.8)
|
||||
reline (0.5.9)
|
||||
io-console (~> 0.5)
|
||||
request_store (1.6.0)
|
||||
rack (>= 1.4)
|
||||
responders (3.1.1)
|
||||
actionpack (>= 5.2)
|
||||
railties (>= 5.2)
|
||||
rexml (3.2.8)
|
||||
strscan (>= 3.0.9)
|
||||
rexml (3.3.1)
|
||||
strscan
|
||||
rotp (6.3.0)
|
||||
rouge (4.2.1)
|
||||
rpam2 (4.0.2)
|
||||
@ -702,9 +705,13 @@ GEM
|
||||
chunky_png (~> 1.0)
|
||||
rqrcode_core (~> 1.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-support (~> 3.13.0)
|
||||
rspec-expectations (3.13.0)
|
||||
rspec-expectations (3.13.1)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-github (2.4.0)
|
||||
@ -712,7 +719,7 @@ GEM
|
||||
rspec-mocks (3.13.1)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-rails (6.1.2)
|
||||
rspec-rails (6.1.3)
|
||||
actionpack (>= 6.1)
|
||||
activesupport (>= 6.1)
|
||||
railties (>= 6.1)
|
||||
@ -741,23 +748,19 @@ GEM
|
||||
parser (>= 3.3.1.0)
|
||||
rubocop-capybara (2.21.0)
|
||||
rubocop (~> 1.41)
|
||||
rubocop-factory_bot (2.25.1)
|
||||
rubocop (~> 1.41)
|
||||
rubocop-performance (1.21.0)
|
||||
rubocop-performance (1.21.1)
|
||||
rubocop (>= 1.48.1, < 2.0)
|
||||
rubocop-ast (>= 1.31.1, < 2.0)
|
||||
rubocop-rails (2.25.0)
|
||||
rubocop-rails (2.25.1)
|
||||
activesupport (>= 4.2.0)
|
||||
rack (>= 1.1)
|
||||
rubocop (>= 1.33.0, < 2.0)
|
||||
rubocop-ast (>= 1.31.1, < 2.0)
|
||||
rubocop-rspec (2.31.0)
|
||||
rubocop (~> 1.40)
|
||||
rubocop-capybara (~> 2.17)
|
||||
rubocop-factory_bot (~> 2.22)
|
||||
rubocop-rspec_rails (~> 2.28)
|
||||
rubocop-rspec_rails (2.28.3)
|
||||
rubocop (~> 1.40)
|
||||
rubocop-rspec (3.0.2)
|
||||
rubocop (~> 1.61)
|
||||
rubocop-rspec_rails (2.30.0)
|
||||
rubocop (~> 1.61)
|
||||
rubocop-rspec (~> 3, >= 3.0.1)
|
||||
ruby-prof (1.7.0)
|
||||
ruby-progressbar (1.13.0)
|
||||
ruby-saml (1.16.0)
|
||||
@ -771,14 +774,15 @@ GEM
|
||||
fugit (~> 1.1, >= 1.1.6)
|
||||
safety_net_attestation (0.4.0)
|
||||
jwt (~> 2.0)
|
||||
sanitize (6.1.0)
|
||||
sanitize (6.1.1)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
scenic (1.8.0)
|
||||
activerecord (>= 4.0.0)
|
||||
railties (>= 4.0.0)
|
||||
selenium-webdriver (4.21.1)
|
||||
selenium-webdriver (4.22.0)
|
||||
base64 (~> 0.2)
|
||||
logger (~> 1.4)
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
rubyzip (>= 1.2.2, < 3.0)
|
||||
websocket (~> 1.0)
|
||||
@ -815,7 +819,7 @@ GEM
|
||||
statsd-ruby (1.5.0)
|
||||
stoplight (4.1.0)
|
||||
redlock (~> 1.0)
|
||||
stringio (3.1.0)
|
||||
stringio (3.1.1)
|
||||
strong_migrations (1.8.0)
|
||||
activerecord (>= 5.2)
|
||||
strscan (3.1.0)
|
||||
@ -829,7 +833,7 @@ GEM
|
||||
unicode-display_width (>= 1.1.1, < 3)
|
||||
terrapin (1.0.1)
|
||||
climate_control
|
||||
test-prof (1.3.3)
|
||||
test-prof (1.3.3.1)
|
||||
thor (1.3.1)
|
||||
tilt (2.3.0)
|
||||
timeout (0.4.1)
|
||||
@ -897,7 +901,7 @@ GEM
|
||||
xorcist (1.1.3)
|
||||
xpath (3.2.0)
|
||||
nokogiri (~> 1.8)
|
||||
zeitwerk (2.6.15)
|
||||
zeitwerk (2.6.16)
|
||||
|
||||
PLATFORMS
|
||||
ruby
|
||||
@ -912,7 +916,7 @@ DEPENDENCIES
|
||||
blurhash (~> 0.1)
|
||||
bootsnap (~> 1.18.0)
|
||||
brakeman (~> 6.0)
|
||||
browser
|
||||
browser (< 6)
|
||||
bundler-audit (~> 0.9)
|
||||
capybara (~> 3.39)
|
||||
charlock_holmes (~> 0.7.7)
|
||||
@ -937,6 +941,7 @@ DEPENDENCIES
|
||||
faker (~> 3.2)
|
||||
fast_blank (~> 1.0)
|
||||
fastimage
|
||||
flatware-rspec
|
||||
fog-core (<= 2.4.0)
|
||||
fog-openstack (~> 1.0)
|
||||
fuubar (~> 2.5)
|
||||
@ -978,7 +983,7 @@ DEPENDENCIES
|
||||
omniauth-saml (~> 2.0)
|
||||
omniauth_openid_connect (~> 0.6.1)
|
||||
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_model_serializers (~> 0.20.1)
|
||||
opentelemetry-instrumentation-concurrent_ruby (~> 0.21.2)
|
||||
@ -989,7 +994,7 @@ DEPENDENCIES
|
||||
opentelemetry-instrumentation-net_http (~> 0.22.4)
|
||||
opentelemetry-instrumentation-pg (~> 0.27.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-sidekiq (~> 0.25.2)
|
||||
opentelemetry-sdk (~> 1.4)
|
||||
@ -998,9 +1003,8 @@ DEPENDENCIES
|
||||
pg (~> 1.5)
|
||||
pghero
|
||||
premailer-rails
|
||||
private_address_check (~> 0.5)
|
||||
propshaft
|
||||
public_suffix (~> 5.0)
|
||||
public_suffix (~> 6.0)
|
||||
puma (~> 6.3)
|
||||
pundit (~> 2.3)
|
||||
rack (~> 2.2.7)
|
||||
@ -1023,6 +1027,7 @@ DEPENDENCIES
|
||||
rubocop-performance
|
||||
rubocop-rails
|
||||
rubocop-rspec
|
||||
rubocop-rspec_rails
|
||||
ruby-prof
|
||||
ruby-progressbar (~> 1.13)
|
||||
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
|
||||
|
||||
- **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
|
||||
|
||||
### Requirements
|
||||
|
@ -4,6 +4,18 @@ module Admin
|
||||
class DomainBlocksController < BaseController
|
||||
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
|
||||
authorize :domain_block, :create?
|
||||
@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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
def form_domain_block_batch_params
|
||||
|
@ -13,6 +13,13 @@ class Api::V1::Admin::TagsController < Api::BaseController
|
||||
|
||||
LIMIT = 100
|
||||
|
||||
PERMITTED_PARAMS = %i(
|
||||
display_name
|
||||
listable
|
||||
trendable
|
||||
usable
|
||||
).freeze
|
||||
|
||||
def index
|
||||
authorize :tag, :index?
|
||||
render json: @tags, each_serializer: REST::Admin::TagSerializer
|
||||
@ -40,7 +47,9 @@ class Api::V1::Admin::TagsController < Api::BaseController
|
||||
end
|
||||
|
||||
def tag_params
|
||||
params.permit(:display_name, :trendable, :usable, :listable)
|
||||
params
|
||||
.slice(*PERMITTED_PARAMS)
|
||||
.permit(*PERMITTED_PARAMS)
|
||||
end
|
||||
|
||||
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! :write, :'write:statuses' }, only: [:update, :destroy]
|
||||
|
||||
before_action :require_user!
|
||||
before_action :set_statuses, only: :index
|
||||
before_action :set_status, except: :index
|
||||
|
||||
|
@ -2,6 +2,7 @@
|
||||
|
||||
class Api::V1::Statuses::TranslationsController < Api::V1::Statuses::BaseController
|
||||
before_action -> { doorkeeper_authorize! :read, :'read:statuses' }
|
||||
before_action :require_user!
|
||||
before_action :set_translation
|
||||
|
||||
rescue_from TranslationService::NotConfiguredError, with: :not_found
|
||||
|
@ -3,8 +3,14 @@
|
||||
class Api::V1::Timelines::BaseController < Api::BaseController
|
||||
after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
|
||||
|
||||
before_action :require_user!, if: :require_auth?
|
||||
|
||||
private
|
||||
|
||||
def require_auth?
|
||||
!Setting.timeline_preview
|
||||
end
|
||||
|
||||
def pagination_collection
|
||||
@statuses
|
||||
end
|
||||
|
@ -1,7 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
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_statuses
|
||||
|
||||
@ -17,10 +17,6 @@ class Api::V1::Timelines::LinkController < Api::V1::Timelines::BaseController
|
||||
|
||||
private
|
||||
|
||||
def require_auth?
|
||||
!Setting.timeline_preview
|
||||
end
|
||||
|
||||
def set_preview_card
|
||||
@preview_card = PreviewCard.joins(:trend).merge(PreviewCardTrend.allowed).find_by!(url: params[:url])
|
||||
end
|
||||
|
@ -1,7 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
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
|
||||
|
||||
@ -13,10 +13,6 @@ class Api::V1::Timelines::PublicController < Api::V1::Timelines::BaseController
|
||||
|
||||
private
|
||||
|
||||
def require_auth?
|
||||
!Setting.timeline_preview
|
||||
end
|
||||
|
||||
def load_statuses
|
||||
preloaded_public_statuses_page
|
||||
end
|
||||
|
@ -1,7 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
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
|
||||
|
||||
def show
|
||||
|
@ -25,6 +25,14 @@ class Auth::RegistrationsController < Devise::RegistrationsController
|
||||
super(&:build_invite_request)
|
||||
end
|
||||
|
||||
def edit # rubocop:disable Lint/UselessMethodDefinition
|
||||
super
|
||||
end
|
||||
|
||||
def create # rubocop:disable Lint/UselessMethodDefinition
|
||||
super
|
||||
end
|
||||
|
||||
def update
|
||||
super do |resource|
|
||||
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
|
||||
Web::PushSubscription.unsubscribe_for(params[:id], current_resource_owner)
|
||||
Doorkeeper::Application.find_by(id: params[:id])&.close_streaming_sessions(current_resource_owner)
|
||||
super
|
||||
end
|
||||
|
||||
|
@ -4,27 +4,42 @@ module Admin::AccountModerationNotesHelper
|
||||
def admin_account_link_to(account, path: nil)
|
||||
return if account.nil?
|
||||
|
||||
link_to path || admin_account_path(account.id), class: name_tag_classes(account), title: account.acct do
|
||||
safe_join([
|
||||
image_tag(account.avatar.url, width: 15, height: 15, alt: '', class: 'avatar'),
|
||||
content_tag(:span, account.acct, class: 'username'),
|
||||
], ' ')
|
||||
end
|
||||
link_to(
|
||||
labeled_account_avatar(account),
|
||||
path || admin_account_path(account.id),
|
||||
class: class_names('name-tag', suspended: suspended_account?(account)),
|
||||
title: account.acct
|
||||
)
|
||||
end
|
||||
|
||||
def admin_account_inline_link_to(account)
|
||||
return if account.nil?
|
||||
|
||||
link_to admin_account_path(account.id), class: name_tag_classes(account, true), title: account.acct do
|
||||
content_tag(:span, account.acct, class: 'username')
|
||||
end
|
||||
link_to(
|
||||
account_inline_text(account),
|
||||
admin_account_path(account.id),
|
||||
class: class_names('inline-name-tag', suspended: suspended_account?(account)),
|
||||
title: account.acct
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def name_tag_classes(account, inline = false)
|
||||
classes = [inline ? 'inline-name-tag' : 'name-tag']
|
||||
classes << 'suspended' if account.suspended? || (account.local? && account.user.nil?)
|
||||
classes.join(' ')
|
||||
def labeled_account_avatar(account)
|
||||
safe_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
|
||||
|
@ -15,15 +15,15 @@ module Admin::ActionLogsHelper
|
||||
link_to log.human_identifier, admin_roles_path(log.target_id)
|
||||
when 'Report'
|
||||
link_to "##{log.human_identifier.presence || log.target_id}", admin_report_path(log.target_id)
|
||||
when 'DomainBlock', 'DomainAllow', 'EmailDomainBlock', 'UnavailableDomain'
|
||||
link_to log.human_identifier, "https://#{log.human_identifier.presence}"
|
||||
when 'Instance', 'DomainBlock', 'DomainAllow', 'UnavailableDomain'
|
||||
log.human_identifier.present? ? link_to(log.human_identifier, admin_instance_path(log.human_identifier)) : I18n.t('admin.action_logs.unavailable_instance')
|
||||
when 'Status'
|
||||
link_to log.human_identifier, log.permalink
|
||||
when 'AccountWarning'
|
||||
link_to log.human_identifier, disputes_strike_path(log.target_id)
|
||||
when 'Announcement'
|
||||
link_to truncate(log.human_identifier), edit_admin_announcement_path(log.target_id)
|
||||
when 'IpBlock', 'Instance', 'CustomEmoji'
|
||||
when 'IpBlock', 'EmailDomainBlock', 'CustomEmoji'
|
||||
log.human_identifier
|
||||
when 'CanonicalEmailBlock'
|
||||
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)
|
||||
end
|
||||
|
||||
def use_mask_icon?
|
||||
instance_presenter.app_icon.blank?
|
||||
end
|
||||
|
||||
# glitch-soc addition to handle the multiple flavors
|
||||
def preload_locale_pack
|
||||
supported_locales = Themes.instance.flavour(current_flavour)['locales']
|
||||
|
@ -141,7 +141,7 @@ module JsonLdHelper
|
||||
def safe_for_forwarding?(original, compacted)
|
||||
original.without('@context', 'signature').all? do |key, value|
|
||||
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)
|
||||
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));
|
||||
}
|
||||
|
||||
if (status.reblog && status.reblog.id) {
|
||||
if (status.reblog?.id) {
|
||||
processStatus(status.reblog);
|
||||
}
|
||||
|
||||
if (status.poll && status.poll.id) {
|
||||
if (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);
|
||||
|
@ -36,6 +36,17 @@ export function normalizeStatus(status, normalOldStatus, settings) {
|
||||
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) {
|
||||
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_PERMISSION = 'NOTIFICATIONS_SET_BROWSER_PERMISSION';
|
||||
|
||||
export const NOTIFICATION_POLICY_FETCH_REQUEST = 'NOTIFICATION_POLICY_FETCH_REQUEST';
|
||||
export const NOTIFICATION_POLICY_FETCH_SUCCESS = 'NOTIFICATION_POLICY_FETCH_SUCCESS';
|
||||
export const NOTIFICATION_POLICY_FETCH_FAIL = 'NOTIFICATION_POLICY_FETCH_FAIL';
|
||||
|
||||
export const NOTIFICATION_REQUESTS_FETCH_REQUEST = 'NOTIFICATION_REQUESTS_FETCH_REQUEST';
|
||||
export const NOTIFICATION_REQUESTS_FETCH_SUCCESS = 'NOTIFICATION_REQUESTS_FETCH_SUCCESS';
|
||||
export const NOTIFICATION_REQUESTS_FETCH_FAIL = 'NOTIFICATION_REQUESTS_FETCH_FAIL';
|
||||
@ -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) => {
|
||||
const params = {};
|
||||
|
||||
|
@ -77,7 +77,7 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
|
||||
},
|
||||
|
||||
onDisconnect() {
|
||||
dispatch(disconnectTimeline(timelineId));
|
||||
dispatch(disconnectTimeline({ timeline: timelineId }));
|
||||
|
||||
if (options.fallback) {
|
||||
// @ts-expect-error
|
||||
|
@ -7,9 +7,11 @@ import { toServerSideType } from 'flavours/glitch/utils/filters';
|
||||
|
||||
import { importFetchedStatus, importFetchedStatuses } from './importer';
|
||||
import { submitMarkers } from './markers';
|
||||
import {timelineDelete} from './timelines_typed';
|
||||
|
||||
export { disconnectTimeline } from './timelines_typed';
|
||||
|
||||
export const TIMELINE_UPDATE = 'TIMELINE_UPDATE';
|
||||
export const TIMELINE_DELETE = 'TIMELINE_DELETE';
|
||||
export const TIMELINE_CLEAR = 'TIMELINE_CLEAR';
|
||||
|
||||
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_LOAD_PENDING = 'TIMELINE_LOAD_PENDING';
|
||||
export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT';
|
||||
export const TIMELINE_CONNECT = 'TIMELINE_CONNECT';
|
||||
|
||||
export const TIMELINE_MARK_AS_PARTIAL = 'TIMELINE_MARK_AS_PARTIAL';
|
||||
@ -73,16 +74,10 @@ export function updateTimeline(timeline, status, accept) {
|
||||
export function deleteFromTimelines(id) {
|
||||
return (dispatch, getState) => {
|
||||
const accountId = getState().getIn(['statuses', id, 'account']);
|
||||
const references = getState().get('statuses').filter(status => status.get('reblog') === id).map(status => status.get('id'));
|
||||
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);
|
||||
|
||||
dispatch({
|
||||
type: TIMELINE_DELETE,
|
||||
id,
|
||||
accountId,
|
||||
references,
|
||||
reblogOf,
|
||||
});
|
||||
dispatch(timelineDelete({ statusId: 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 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 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) => {
|
||||
return expandTimeline(`hashtag:${hashtag}${local ? ':local' : ''}`, `/api/v1/timelines/tag/${hashtag}`, {
|
||||
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 => ({
|
||||
type: TIMELINE_MARK_AS_PARTIAL,
|
||||
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 { importFetchedStatuses } from './importer';
|
||||
import { importFetchedStatuses, importFetchedAccounts } from './importer';
|
||||
|
||||
export const TRENDS_TAGS_FETCH_REQUEST = 'TRENDS_TAGS_FETCH_REQUEST';
|
||||
export const TRENDS_TAGS_FETCH_SUCCESS = 'TRENDS_TAGS_FETCH_SUCCESS';
|
||||
@ -49,8 +49,11 @@ export const fetchTrendingLinks = () => (dispatch) => {
|
||||
dispatch(fetchTrendingLinksRequest());
|
||||
|
||||
api()
|
||||
.get('/api/v1/trends/links')
|
||||
.then(({ data }) => dispatch(fetchTrendingLinksSuccess(data)))
|
||||
.get('/api/v1/trends/links', { params: { limit: 20 } })
|
||||
.then(({ data }) => {
|
||||
dispatch(importFetchedAccounts(data.flatMap(link => link.authors.map(author => author.account)).filter(account => !!account)));
|
||||
dispatch(fetchTrendingLinksSuccess(data));
|
||||
})
|
||||
.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>(
|
||||
method: Method,
|
||||
url: string,
|
||||
params?: Record<string, unknown>,
|
||||
args: {
|
||||
params?: RequestParamsOrData;
|
||||
data?: RequestParamsOrData;
|
||||
} = {},
|
||||
) {
|
||||
const { data } = await api().request<ApiResponse>({
|
||||
method,
|
||||
url: '/api/' + url,
|
||||
data: params,
|
||||
...args,
|
||||
});
|
||||
|
||||
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';
|
||||
|
||||
export const apiSubmitAccountNote = (id: string, value: string) =>
|
||||
apiRequest<ApiRelationshipJSON>('post', `v1/accounts/${id}/note`, {
|
||||
apiRequestPost<ApiRelationshipJSON>(`v1/accounts/${id}/note`, {
|
||||
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';
|
||||
|
||||
export const apiReblog = (statusId: string, visibility: StatusVisibility) =>
|
||||
apiRequest<{ reblog: Status }>('post', `v1/statuses/${statusId}/reblog`, {
|
||||
apiRequestPost<{ reblog: Status }>(`v1/statuses/${statusId}/reblog`, {
|
||||
visibility,
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export interface ApiPreviewCardAuthorJSON {
|
||||
name: string;
|
||||
url: string;
|
||||
account?: ApiAccountJSON;
|
||||
}
|
||||
|
||||
export interface ApiPreviewCardJSON {
|
||||
url: string;
|
||||
title: string;
|
||||
@ -38,6 +44,7 @@ export interface ApiPreviewCardJSON {
|
||||
type: string;
|
||||
author_name: string;
|
||||
author_url: string;
|
||||
author_account?: ApiAccountJSON;
|
||||
provider_name: string;
|
||||
provider_url: string;
|
||||
html: string;
|
||||
@ -48,6 +55,7 @@ export interface ApiPreviewCardJSON {
|
||||
embed_url: string;
|
||||
blurhash: string;
|
||||
published_at: string;
|
||||
authors: ApiPreviewCardAuthorJSON[];
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className='server-banner'>
|
||||
<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>
|
||||
|
||||
<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'>
|
||||
{isLoading ? (
|
||||
@ -84,10 +86,6 @@ class ServerBanner extends PureComponent {
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
@ -810,7 +810,7 @@ class Status extends ImmutablePureComponent {
|
||||
{prepend}
|
||||
|
||||
<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')}
|
||||
style={isCollapsed && background ? { backgroundImage: `url(${background})` } : null}
|
||||
>
|
||||
|
@ -181,7 +181,8 @@ class StatusContent extends PureComponent {
|
||||
|
||||
if (mention) {
|
||||
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') {
|
||||
while (link.firstChild) link.removeChild(link.firstChild);
|
||||
link.appendChild(document.createTextNode('@'));
|
||||
|
@ -51,6 +51,7 @@ export default class StatusHeader extends PureComponent {
|
||||
target='_blank'
|
||||
onClick={this.handleAccountClick}
|
||||
rel='noopener noreferrer'
|
||||
data-hover-card-account={status.getIn(['account', 'id'])}
|
||||
>
|
||||
<div className='status__avatar'>
|
||||
{statusAvatar}
|
||||
|
@ -33,6 +33,7 @@ export default class StatusList extends ImmutablePureComponent {
|
||||
withCounters: PropTypes.bool,
|
||||
timelineId: PropTypes.string.isRequired,
|
||||
lastId: PropTypes.string,
|
||||
bindToDocument: PropTypes.bool,
|
||||
regex: PropTypes.string,
|
||||
};
|
||||
|
||||
|
@ -40,6 +40,7 @@ export default class StatusPrepend extends PureComponent {
|
||||
onClick={this.handleClick}
|
||||
href={account.get('url')}
|
||||
className='status__display-name'
|
||||
data-hover-card-account={account.get('id')}
|
||||
>
|
||||
<bdi>
|
||||
<strong
|
||||
|
@ -23,7 +23,6 @@ import { makeGetAccount, getAccountHidden } from '../../../selectors';
|
||||
import Header from '../components/header';
|
||||
|
||||
const messages = defineMessages({
|
||||
cancelFollowRequestConfirm: { id: 'confirmations.cancel_follow_request.confirm', defaultMessage: 'Withdraw request' },
|
||||
unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
|
||||
blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Block entire domain' },
|
||||
});
|
||||
@ -43,7 +42,7 @@ const makeMapStateToProps = () => {
|
||||
const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||
|
||||
onFollow (account) {
|
||||
if (account.getIn(['relationship', 'following'])) {
|
||||
if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
|
||||
dispatch(openModal({
|
||||
modalType: 'CONFIRM',
|
||||
modalProps: {
|
||||
@ -52,15 +51,6 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||
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')));
|
||||
}
|
||||
|
@ -110,18 +110,6 @@ class LanguageDropdownMenu extends PureComponent {
|
||||
}).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 => {
|
||||
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 });
|
||||
|
||||
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>
|
||||
<strong
|
||||
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 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 { Avatar } from 'flavours/glitch/components/avatar';
|
||||
import { Button } from 'flavours/glitch/components/button';
|
||||
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 { domain } from 'flavours/glitch/initial_state';
|
||||
|
||||
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" },
|
||||
});
|
||||
|
||||
export const Card = ({ id, source }) => {
|
||||
const intl = useIntl();
|
||||
const account = useSelector(state => state.getIn(['accounts', id]));
|
||||
const relationship = useSelector(state => state.getIn(['relationships', id]));
|
||||
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(() => {
|
||||
dispatch(dismissSuggestion(id));
|
||||
@ -74,7 +61,7 @@ export const Card = ({ id, source }) => {
|
||||
<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>
|
||||
<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>
|
||||
|
@ -1,61 +1,91 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
|
||||
|
||||
import { Blurhash } from 'flavours/glitch/components/blurhash';
|
||||
import { accountsCountRenderer } from 'flavours/glitch/components/hashtag';
|
||||
import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp';
|
||||
import { ShortNumber } from 'flavours/glitch/components/short_number';
|
||||
import { Skeleton } from 'flavours/glitch/components/skeleton';
|
||||
|
||||
export default class Story extends PureComponent {
|
||||
import { AuthorLink } from './author_link';
|
||||
|
||||
static propTypes = {
|
||||
url: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
lang: PropTypes.string,
|
||||
publisher: PropTypes.string,
|
||||
publishedAt: PropTypes.string,
|
||||
author: PropTypes.string,
|
||||
sharedTimes: PropTypes.number,
|
||||
thumbnail: PropTypes.string,
|
||||
thumbnailDescription: PropTypes.string,
|
||||
blurhash: PropTypes.string,
|
||||
expanded: PropTypes.bool,
|
||||
};
|
||||
const sharesCountRenderer = (displayNumber, pluralReady) => (
|
||||
<FormattedMessage
|
||||
id='link_preview.shares'
|
||||
defaultMessage='{count, plural, one {{counter} post} other {{counter} posts}}'
|
||||
values={{
|
||||
count: pluralReady,
|
||||
counter: <strong>{displayNumber}</strong>,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
state = {
|
||||
thumbnailLoaded: false,
|
||||
};
|
||||
export const Story = ({
|
||||
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 () {
|
||||
const { expanded, url, title, lang, publisher, author, publishedAt, sharedTimes, thumbnail, thumbnailDescription, blurhash } = this.props;
|
||||
|
||||
const { thumbnailLoaded } = this.state;
|
||||
|
||||
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>
|
||||
return (
|
||||
<div className={classNames('story', { expanded })}>
|
||||
<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__thumbnail'>
|
||||
{thumbnail ? (
|
||||
<>
|
||||
<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} />
|
||||
</>
|
||||
) : <Skeleton />}
|
||||
<a className='story__details__title' lang={lang} href={url} target='blank' rel='noopener'>
|
||||
{title ? title : <Skeleton />}
|
||||
</a>
|
||||
|
||||
<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 />}
|
||||
{typeof sharedTimes === 'number' ? <Link className='story__details__shared__pill' to={`/links/${encodeURIComponent(url)}`}><ShortNumber value={sharedTimes} renderer={sharesCountRenderer} /></Link> : <Skeleton width='10ch' />}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
</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 { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
|
||||
|
||||
import Story from './components/story';
|
||||
import { Story } from './components/story';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
links: state.getIn(['trends', 'links', 'items']),
|
||||
@ -75,6 +75,7 @@ class Links extends PureComponent {
|
||||
publisher={link.get('provider_name')}
|
||||
publishedAt={link.get('published_at')}
|
||||
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}
|
||||
thumbnail={link.get('image')}
|
||||
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 CloseIcon from '@/material-icons/400-24px/close.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 { fetchSuggestions, dismissSuggestion } from 'flavours/glitch/actions/suggestions';
|
||||
import { Avatar } from 'flavours/glitch/components/avatar';
|
||||
import { Button } from 'flavours/glitch/components/button';
|
||||
import { DisplayName } from 'flavours/glitch/components/display_name';
|
||||
import { FollowButton } from 'flavours/glitch/components/follow_button';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import { IconButton } from 'flavours/glitch/components/icon_button';
|
||||
import { VerifiedBadge } from 'flavours/glitch/components/verified_badge';
|
||||
@ -79,18 +78,8 @@ Source.propTypes = {
|
||||
const Card = ({ id, sources }) => {
|
||||
const intl = useIntl();
|
||||
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 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(() => {
|
||||
dispatch(dismissSuggestion(id));
|
||||
@ -109,7 +98,7 @@ const Card = ({ id, sources }) => {
|
||||
{firstVerifiedField ? <VerifiedBadge link={firstVerifiedField.get('value')} /> : <Source id={sources.get(0)} />}
|
||||
</div>
|
||||
|
||||
<Button text={intl.formatMessage(following ? messages.unfollow : messages.follow)} secondary={following} onClick={handleFollow} />
|
||||
<FollowButton accountId={id} />
|
||||
</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,
|
||||
browserSupport: PropTypes.bool,
|
||||
browserPermission: PropTypes.string,
|
||||
notificationPolicy: ImmutablePropTypes.map,
|
||||
notificationPolicy: PropTypes.object.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>
|
||||
|
||||
<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>
|
||||
<span className='hint'><FormattedMessage id='notifications.policy.filter_not_following_hint' defaultMessage='Until you manually approve them' /></span>
|
||||
</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>
|
||||
<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 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>
|
||||
<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 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>
|
||||
<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>
|
||||
|
@ -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')}
|
||||
to={`/@${targetAccount.get('acct')}`}
|
||||
dangerouslySetInnerHTML={targetDisplayNameHtml}
|
||||
data-hover-card-account={targetAccount.get('id')}
|
||||
/>
|
||||
</bdi>
|
||||
);
|
||||
@ -448,6 +449,7 @@ class Notification extends ImmutablePureComponent {
|
||||
title={account.get('acct')}
|
||||
to={`/@${account.get('acct')}`}
|
||||
dangerouslySetInnerHTML={displayNameHtml}
|
||||
data-hover-card-account={account.get('id')}
|
||||
/>
|
||||
</bdi>
|
||||
);
|
||||
|
@ -4,7 +4,8 @@ import { connect } from 'react-redux';
|
||||
|
||||
import { showAlert } from '../../../actions/alerts';
|
||||
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 { changeSetting } from '../../../actions/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' },
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {import('flavours/glitch/store').RootState} state
|
||||
*/
|
||||
const mapStateToProps = state => ({
|
||||
settings: state.getIn(['settings', 'notifications']),
|
||||
pushSettings: state.get('push_notifications'),
|
||||
alertsEnabled: state.getIn(['settings', 'notifications', 'alerts']).includes(true),
|
||||
browserSupport: state.getIn(['notifications', 'browserSupport']),
|
||||
browserPermission: state.getIn(['notifications', 'browserPermission']),
|
||||
notificationPolicy: state.get('notificationPolicy'),
|
||||
notificationPolicy: state.notificationPolicy,
|
||||
});
|
||||
|
||||
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 OpenInNewIcon from '@/material-icons/400-24px/open_in_new.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 { 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 { useBlurhash } from 'flavours/glitch/initial_state';
|
||||
import { decode as decodeIDNA } from 'flavours/glitch/utils/idna';
|
||||
@ -48,20 +47,6 @@ const addAutoPlay = 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 {
|
||||
|
||||
static propTypes = {
|
||||
@ -142,7 +127,7 @@ export default class Card extends PureComponent {
|
||||
const interactive = card.get('type') === 'video';
|
||||
const language = card.get('language') || '';
|
||||
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 = (
|
||||
<div className='status-card__content'>
|
||||
@ -248,7 +233,7 @@ export default class Card extends PureComponent {
|
||||
{description}
|
||||
</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 (
|
||||
<div style={outerStyle}>
|
||||
<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>
|
||||
<DisplayName account={status.get('account')} localDomain={this.props.domain} />
|
||||
</a>
|
||||
|
@ -1,11 +1,8 @@
|
||||
import Column from '../../../components/column';
|
||||
import ColumnHeader from '../../../components/column_header';
|
||||
import Column from 'flavours/glitch/components/column';
|
||||
import { ColumnHeader } from 'flavours/glitch/components/column_header';
|
||||
import type { Props as ColumnHeaderProps } from 'flavours/glitch/components/column_header';
|
||||
|
||||
interface Props {
|
||||
multiColumn?: boolean;
|
||||
}
|
||||
|
||||
export const ColumnLoading: React.FC<Props> = (otherProps) => (
|
||||
export const ColumnLoading: React.FC<ColumnHeaderProps> = (otherProps) => (
|
||||
<Column>
|
||||
<ColumnHeader {...otherProps} />
|
||||
<div className='scrollable' />
|
||||
|
@ -22,7 +22,8 @@ const SignInBanner = () => {
|
||||
if (sso_redirect) {
|
||||
return (
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
@ -44,7 +45,8 @@ const SignInBanner = () => {
|
||||
|
||||
return (
|
||||
<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}
|
||||
<a href='/auth/sign_in' className='button button--block button-tertiary'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Login' /></a>
|
||||
</div>
|
||||
|
@ -15,6 +15,7 @@ import { HotKeys } from 'react-hotkeys';
|
||||
import { changeLayout } from 'flavours/glitch/actions/app';
|
||||
import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'flavours/glitch/actions/markers';
|
||||
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 { PictureInPicture } from 'flavours/glitch/features/picture_in_picture';
|
||||
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
|
||||
@ -57,6 +58,7 @@ import {
|
||||
FavouritedStatuses,
|
||||
BookmarkedStatuses,
|
||||
FollowedTags,
|
||||
LinkTimeline,
|
||||
ListTimeline,
|
||||
Blocks,
|
||||
DomainBlocks,
|
||||
@ -210,6 +212,7 @@ class SwitchingColumnsArea extends PureComponent {
|
||||
<WrappedRoute path='/public/remote' exact component={Firehose} componentParams={{ feedType: 'public:remote' }} content={children} />
|
||||
<WrappedRoute path={['/conversations', '/timelines/direct']} component={DirectTimeline} 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='/notifications' component={Notifications} content={children} exact />
|
||||
<WrappedRoute path='/notifications/requests' component={NotificationRequests} content={children} exact />
|
||||
@ -648,6 +651,7 @@ class UI extends PureComponent {
|
||||
|
||||
{layout !== 'mobile' && <PictureInPicture />}
|
||||
<NotificationsContainer />
|
||||
<HoverCardController />
|
||||
<LoadingBarContainer className='loading-bar' />
|
||||
<ModalContainer />
|
||||
<UploadArea active={draggingOver} onClose={this.closeUploadModal} />
|
||||
|
@ -213,3 +213,7 @@ export function NotificationRequests () {
|
||||
export function NotificationRequest () {
|
||||
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';
|
||||
|
||||
// Temporary until we type it correctly
|
||||
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