mirror of
https://git.kescher.at/CatCatNya/catstodon.git
synced 2025-01-18 12:44:04 +01:00
Merge branch 'emoji-reactions-base' into develop
This commit is contained in:
commit
85aae42f26
560 changed files with 9719 additions and 11611 deletions
59
.annotaterb.yml
Normal file
59
.annotaterb.yml
Normal file
|
@ -0,0 +1,59 @@
|
|||
---
|
||||
:position: before
|
||||
:position_in_additional_file_patterns: before
|
||||
:position_in_class: before
|
||||
:position_in_factory: before
|
||||
:position_in_fixture: before
|
||||
:position_in_routes: before
|
||||
:position_in_serializer: before
|
||||
:position_in_test: before
|
||||
:classified_sort: true
|
||||
:exclude_controllers: true
|
||||
:exclude_factories: true
|
||||
:exclude_fixtures: true
|
||||
:exclude_helpers: true
|
||||
:exclude_scaffolds: true
|
||||
:exclude_serializers: true
|
||||
:exclude_sti_subclasses: true
|
||||
:exclude_tests: true
|
||||
:force: false
|
||||
:format_markdown: false
|
||||
:format_rdoc: false
|
||||
:format_yard: false
|
||||
:frozen: false
|
||||
:ignore_model_sub_dir: false
|
||||
:ignore_unknown_models: false
|
||||
:include_version: false
|
||||
:show_complete_foreign_keys: false
|
||||
:show_foreign_keys: false
|
||||
:show_indexes: false
|
||||
:simple_indexes: false
|
||||
:sort: false
|
||||
:timestamp: false
|
||||
:trace: false
|
||||
:with_comment: true
|
||||
:with_column_comments: true
|
||||
:with_table_comments: true
|
||||
:active_admin: false
|
||||
:command:
|
||||
:debug: false
|
||||
:hide_default_column_types: ''
|
||||
:hide_limit_column_types: 'integer,boolean'
|
||||
:ignore_columns:
|
||||
:ignore_routes:
|
||||
:models: true
|
||||
:routes: false
|
||||
:skip_on_db_migrate: false
|
||||
:target_action: :do_annotations
|
||||
:wrapper:
|
||||
:wrapper_close:
|
||||
:wrapper_open:
|
||||
:classes_default_to_s: []
|
||||
:additional_file_patterns: []
|
||||
:model_dir:
|
||||
- app/models
|
||||
:require: []
|
||||
:root_dir:
|
||||
- ''
|
||||
|
||||
:show_check_constraints: false
|
|
@ -69,7 +69,7 @@ services:
|
|||
hard: -1
|
||||
|
||||
libretranslate:
|
||||
image: libretranslate/libretranslate:v1.6.1
|
||||
image: libretranslate/libretranslate:v1.6.2
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- lt-data:/home/libretranslate/.local
|
||||
|
|
3
.github/ISSUE_TEMPLATE/1.web_bug_report.yml
vendored
3
.github/ISSUE_TEMPLATE/1.web_bug_report.yml
vendored
|
@ -1,7 +1,6 @@
|
|||
name: Bug Report (Web Interface)
|
||||
description: There is a problem using Mastodon's web interface.
|
||||
labels: ['status/to triage', 'area/web interface']
|
||||
type: Bug
|
||||
labels: [bug, 'status/to triage', 'area/web interface']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
name: Bug Report (server / API)
|
||||
description: |
|
||||
There is a problem with the HTTP server, REST API, ActivityPub interaction, etc.
|
||||
labels: ['status/to triage']
|
||||
type: 'Bug'
|
||||
labels: [bug, 'status/to triage']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
|
|
3
.github/ISSUE_TEMPLATE/3.troubleshooting.yml
vendored
3
.github/ISSUE_TEMPLATE/3.troubleshooting.yml
vendored
|
@ -1,8 +1,7 @@
|
|||
name: Deployment troubleshooting
|
||||
description: |
|
||||
You are a server administrator and you are encountering a technical issue during installation, upgrade or operations of Mastodon.
|
||||
labels: ['status/to triage']
|
||||
type: 'Troubleshooting'
|
||||
labels: [bug, 'status/to triage']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
|
|
2
.github/ISSUE_TEMPLATE/4.feature_request.yml
vendored
2
.github/ISSUE_TEMPLATE/4.feature_request.yml
vendored
|
@ -1,6 +1,6 @@
|
|||
name: Feature Request
|
||||
description: I have a suggestion
|
||||
type: Suggestion
|
||||
labels: [suggestion]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
|
|
1
.github/workflows/build-container-image.yml
vendored
1
.github/workflows/build-container-image.yml
vendored
|
@ -92,6 +92,7 @@ jobs:
|
|||
build-args: |
|
||||
MASTODON_VERSION_PRERELEASE=${{ inputs.version_prerelease }}
|
||||
MASTODON_VERSION_METADATA=${{ inputs.version_metadata }}
|
||||
SOURCE_COMMIT=${{ github.sha }}
|
||||
platforms: ${{ inputs.platforms }}
|
||||
provenance: false
|
||||
builder: ${{ steps.buildx.outputs.name || steps.buildx-native.outputs.name }}
|
||||
|
|
2
.github/workflows/bundler-audit.yml
vendored
2
.github/workflows/bundler-audit.yml
vendored
|
@ -36,4 +36,4 @@ jobs:
|
|||
bundler-cache: true
|
||||
|
||||
- name: Run bundler-audit
|
||||
run: bundle exec bundler-audit check --update
|
||||
run: bin/bundler-audit check --update
|
||||
|
|
10
.github/workflows/check-i18n.yml
vendored
10
.github/workflows/check-i18n.yml
vendored
|
@ -35,18 +35,18 @@ jobs:
|
|||
git diff --exit-code
|
||||
|
||||
- name: Check locale file normalization
|
||||
run: bundle exec i18n-tasks check-normalized
|
||||
run: bin/i18n-tasks check-normalized
|
||||
|
||||
- name: Check for unused strings
|
||||
run: bundle exec i18n-tasks unused
|
||||
run: bin/i18n-tasks unused
|
||||
|
||||
- name: Check for missing strings in English YML
|
||||
run: |
|
||||
bundle exec i18n-tasks add-missing -l en
|
||||
bin/i18n-tasks add-missing -l en
|
||||
git diff --exit-code
|
||||
|
||||
- name: Check for wrong string interpolations
|
||||
run: bundle exec i18n-tasks check-consistent-interpolations
|
||||
run: bin/i18n-tasks check-consistent-interpolations
|
||||
|
||||
- name: Check that all required locale files exist
|
||||
run: bundle exec rake repo:check_locales_files
|
||||
run: bin/rake repo:check_locales_files
|
||||
|
|
|
@ -47,7 +47,7 @@ jobs:
|
|||
uses: ./.github/actions/setup-ruby
|
||||
|
||||
- name: Run i18n normalize task
|
||||
run: bundle exec i18n-tasks normalize
|
||||
run: bin/i18n-tasks normalize
|
||||
|
||||
# Create or update the pull request
|
||||
- name: Create Pull Request
|
||||
|
|
2
.github/workflows/crowdin-download.yml
vendored
2
.github/workflows/crowdin-download.yml
vendored
|
@ -49,7 +49,7 @@ jobs:
|
|||
uses: ./.github/actions/setup-ruby
|
||||
|
||||
- name: Run i18n normalize task
|
||||
run: bundle exec i18n-tasks normalize
|
||||
run: bin/i18n-tasks normalize
|
||||
|
||||
# Create or update the pull request
|
||||
- name: Create Pull Request
|
||||
|
|
2
.github/workflows/lint-haml.yml
vendored
2
.github/workflows/lint-haml.yml
vendored
|
@ -43,4 +43,4 @@ jobs:
|
|||
- name: Run haml-lint
|
||||
run: |
|
||||
echo "::add-matcher::.github/workflows/haml-lint-problem-matcher.json"
|
||||
bundle exec haml-lint --reporter github
|
||||
bin/haml-lint --reporter github
|
||||
|
|
24
CHANGELOG.md
24
CHANGELOG.md
|
@ -8,7 +8,29 @@ All release dates, as well as most other dates, are intended to be read as "with
|
|||
|
||||
- Upstream changes
|
||||
|
||||
## [v4.3.0+cat.1.0.1]
|
||||
## [v4.3.2+cat+1.0.0] - 2024-12-30
|
||||
|
||||
- Upstream changes, see [GitHub Release](https://github.com/glitch-soc/mastodon/releases/tag/v4.3.2)
|
||||
or [CHANGELOG_glitch.md](CHANGELOG_glitch.md)
|
||||
- Cherry-picked emoji reactions patch changes
|
||||
|
||||
## [v4.3.1+cat.1.0.2] - 2024-11-17
|
||||
|
||||
- Bump Ruby version to 3.3.6
|
||||
|
||||
## [v4.3.1+cat.1.0.1] - 2024-11-08
|
||||
|
||||
- Fix invisible reactions on detailed statuses when logged out (by Essem)
|
||||
|
||||
## [v4.3.1+cat.1.0.0] - 2024-11-01
|
||||
|
||||
- Upstream changes (see [upstream release](https://github.com/glitch-soc/mastodon/releases/tag/v4.3.1)).
|
||||
|
||||
## [v4.3.0+cat.1.0.2] - 2024-10-12
|
||||
|
||||
- Upstream changes
|
||||
|
||||
## [v4.3.0+cat.1.0.1] - 2024-10-10
|
||||
|
||||
- Upstream changes
|
||||
- First stable Catstodon release, similar to [glitch-soc](https://github.com/glitch-soc/mastodon/releases/tag/v4.3.0).
|
||||
|
|
|
@ -68,7 +68,7 @@ The following changelog entries focus on changes visible to users, administrator
|
|||
- `GET /api/v2/notifications`: https://docs.joinmastodon.org/methods/grouped_notifications/#get-grouped
|
||||
- `GET /api/v2/notifications/:group_key`: https://docs.joinmastodon.org/methods/grouped_notifications/#get-notification-group
|
||||
- `GET /api/v2/notifications/:group_key/accounts`: https://docs.joinmastodon.org/methods/grouped_notifications/#get-group-accounts
|
||||
- `POST /api/v2/notifications/:group_key/dimsiss`: https://docs.joinmastodon.org/methods/grouped_notifications/#dismiss-group
|
||||
- `POST /api/v2/notifications/:group_key/dismiss`: https://docs.joinmastodon.org/methods/grouped_notifications/#dismiss-group
|
||||
- `GET /api/v2/notifications/:unread_count`: https://docs.joinmastodon.org/methods/grouped_notifications/#unread-group-count
|
||||
- **Add notification policies, filtered notifications and notification requests** (#29366, #29529, #29433, #29565, #29567, #29572, #29575, #29588, #29646, #29652, #29658, #29666, #29693, #29699, #29737, #29706, #29570, #29752, #29810, #29826, #30114, #30251, #30559, #29868, #31008, #31011, #30996, #31149, #31220, #31222, #31225, #31242, #31262, #31250, #31273, #31310, #31316, #31322, #31329, #31324, #31331, #31343, #31342, #31309, #31358, #31378, #31406, #31256, #31456, #31419, #31457, #31508, #31540, #31541, #31723, #32062 and #32281 by @ClearlyClaire, @Gargron, @TheEssem, @mgmn, @oneiros, and @renchap)\
|
||||
The old “Block notifications from non-followers”, “Block notifications from people you don't follow” and “Block direct messages from people you don't follow” notification settings have been replaced by a new set of settings found directly in the notification column.\
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# syntax=docker/dockerfile:1.10
|
||||
# syntax=docker/dockerfile:1.11
|
||||
|
||||
# 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
|
||||
|
@ -29,6 +29,8 @@ FROM docker.io/ruby:${RUBY_VERSION}-slim-${DEBIAN_VERSION} AS ruby
|
|||
ARG MASTODON_VERSION_PRERELEASE=""
|
||||
# Append build metadata or fork information to version.rb [--build-arg MASTODON_VERSION_METADATA="pr-123456"]
|
||||
ARG MASTODON_VERSION_METADATA=""
|
||||
# Will be available as Mastodon::Version.source_commit
|
||||
ARG SOURCE_COMMIT=""
|
||||
|
||||
# Allow Ruby on Rails to serve static files
|
||||
# See: https://docs.joinmastodon.org/admin/config/#rails_serve_static_files
|
||||
|
@ -48,6 +50,7 @@ ENV \
|
|||
# Apply Mastodon version information
|
||||
MASTODON_VERSION_PRERELEASE="${MASTODON_VERSION_PRERELEASE}" \
|
||||
MASTODON_VERSION_METADATA="${MASTODON_VERSION_METADATA}" \
|
||||
SOURCE_COMMIT="${SOURCE_COMMIT}" \
|
||||
# Apply Mastodon static files and YJIT options
|
||||
RAILS_SERVE_STATIC_FILES=${RAILS_SERVE_STATIC_FILES} \
|
||||
RUBY_YJIT_ENABLE=${RUBY_YJIT_ENABLE} \
|
||||
|
|
4
Gemfile
4
Gemfile
|
@ -114,7 +114,7 @@ group :opentelemetry do
|
|||
gem 'opentelemetry-instrumentation-net_http', '~> 0.22.4', require: false
|
||||
gem 'opentelemetry-instrumentation-pg', '~> 0.29.0', require: false
|
||||
gem 'opentelemetry-instrumentation-rack', '~> 0.25.0', require: false
|
||||
gem 'opentelemetry-instrumentation-rails', '~> 0.32.0', require: false
|
||||
gem 'opentelemetry-instrumentation-rails', '~> 0.33.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
|
||||
|
@ -172,7 +172,7 @@ group :development do
|
|||
gem 'rubocop-rspec_rails', require: false
|
||||
|
||||
# Annotates modules with schema
|
||||
gem 'annotate', '~> 3.2'
|
||||
gem 'annotaterb', '~> 4.13'
|
||||
|
||||
# Enhanced error message pages for development
|
||||
gem 'better_errors', '~> 2.9'
|
||||
|
|
65
Gemfile.lock
65
Gemfile.lock
|
@ -90,23 +90,20 @@ GEM
|
|||
public_suffix (>= 2.0.2, < 7.0)
|
||||
aes_key_wrap (1.1.0)
|
||||
android_key_attestation (0.3.0)
|
||||
annotate (3.2.0)
|
||||
activerecord (>= 3.2, < 8.0)
|
||||
rake (>= 10.4, < 14.0)
|
||||
annotaterb (4.13.0)
|
||||
ast (2.4.2)
|
||||
attr_required (1.0.2)
|
||||
awrence (1.2.1)
|
||||
aws-eventstream (1.3.0)
|
||||
aws-partitions (1.1004.0)
|
||||
aws-sdk-core (3.212.0)
|
||||
aws-partitions (1.1013.0)
|
||||
aws-sdk-core (3.214.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
aws-sdk-kms (1.95.0)
|
||||
aws-sdk-kms (1.96.0)
|
||||
aws-sdk-core (~> 3, >= 3.210.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.170.1)
|
||||
aws-sdk-s3 (1.174.0)
|
||||
aws-sdk-core (~> 3, >= 3.210.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
|
@ -202,7 +199,7 @@ GEM
|
|||
activerecord (>= 4.2, < 9.0)
|
||||
docile (1.4.1)
|
||||
domain_name (0.6.20240107)
|
||||
doorkeeper (5.7.1)
|
||||
doorkeeper (5.8.0)
|
||||
railties (>= 5)
|
||||
dotenv (3.1.4)
|
||||
drb (2.2.1)
|
||||
|
@ -348,10 +345,11 @@ GEM
|
|||
json-ld-preloaded (3.3.1)
|
||||
json-ld (~> 3.3)
|
||||
rdf (~> 3.3)
|
||||
json-schema (5.0.1)
|
||||
json-schema (5.1.0)
|
||||
addressable (~> 2.8)
|
||||
jsonapi-renderer (0.2.2)
|
||||
jwt (2.7.1)
|
||||
jwt (2.9.3)
|
||||
base64
|
||||
kaminari (1.2.2)
|
||||
activesupport (>= 4.1.0)
|
||||
kaminari-actionview (= 1.2.2)
|
||||
|
@ -413,7 +411,7 @@ GEM
|
|||
minitest (5.25.1)
|
||||
msgpack (1.7.5)
|
||||
multi_json (1.15.0)
|
||||
mutex_m (0.2.0)
|
||||
mutex_m (0.3.0)
|
||||
net-http (0.5.0)
|
||||
uri
|
||||
net-imap (0.5.1)
|
||||
|
@ -426,7 +424,7 @@ GEM
|
|||
timeout
|
||||
net-smtp (0.5.0)
|
||||
net-protocol
|
||||
nio4r (2.7.3)
|
||||
nio4r (2.7.4)
|
||||
nokogiri (1.16.7)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
|
@ -474,19 +472,19 @@ GEM
|
|||
opentelemetry-common (~> 0.20)
|
||||
opentelemetry-sdk (~> 1.2)
|
||||
opentelemetry-semantic_conventions
|
||||
opentelemetry-helpers-sql-obfuscation (0.2.0)
|
||||
opentelemetry-helpers-sql-obfuscation (0.2.1)
|
||||
opentelemetry-common (~> 0.21)
|
||||
opentelemetry-instrumentation-action_mailer (0.2.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-active_support (~> 0.1)
|
||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||
opentelemetry-instrumentation-action_pack (0.9.0)
|
||||
opentelemetry-instrumentation-action_pack (0.10.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||
opentelemetry-instrumentation-rack (~> 0.21)
|
||||
opentelemetry-instrumentation-action_view (0.7.2)
|
||||
opentelemetry-instrumentation-action_view (0.7.3)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-active_support (~> 0.1)
|
||||
opentelemetry-instrumentation-active_support (~> 0.6)
|
||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||
opentelemetry-instrumentation-active_job (0.7.8)
|
||||
opentelemetry-api (~> 1.0)
|
||||
|
@ -494,7 +492,7 @@ GEM
|
|||
opentelemetry-instrumentation-active_model_serializers (0.20.2)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||
opentelemetry-instrumentation-active_record (0.8.0)
|
||||
opentelemetry-instrumentation-active_record (0.8.1)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||
opentelemetry-instrumentation-active_support (0.6.0)
|
||||
|
@ -507,32 +505,32 @@ GEM
|
|||
opentelemetry-instrumentation-concurrent_ruby (0.21.4)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||
opentelemetry-instrumentation-excon (0.22.4)
|
||||
opentelemetry-instrumentation-excon (0.22.5)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||
opentelemetry-instrumentation-faraday (0.24.6)
|
||||
opentelemetry-instrumentation-faraday (0.24.7)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||
opentelemetry-instrumentation-http (0.23.4)
|
||||
opentelemetry-instrumentation-http (0.23.5)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||
opentelemetry-instrumentation-http_client (0.22.7)
|
||||
opentelemetry-instrumentation-http_client (0.22.8)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||
opentelemetry-instrumentation-net_http (0.22.7)
|
||||
opentelemetry-instrumentation-net_http (0.22.8)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||
opentelemetry-instrumentation-pg (0.29.0)
|
||||
opentelemetry-instrumentation-pg (0.29.1)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-helpers-sql-obfuscation
|
||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||
opentelemetry-instrumentation-rack (0.25.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||
opentelemetry-instrumentation-rails (0.32.0)
|
||||
opentelemetry-instrumentation-rails (0.33.1)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-action_mailer (~> 0.2.0)
|
||||
opentelemetry-instrumentation-action_pack (~> 0.9.0)
|
||||
opentelemetry-instrumentation-action_pack (~> 0.10.0)
|
||||
opentelemetry-instrumentation-action_view (~> 0.7.0)
|
||||
opentelemetry-instrumentation-active_job (~> 0.7.0)
|
||||
opentelemetry-instrumentation-active_record (~> 0.8.0)
|
||||
|
@ -582,7 +580,7 @@ GEM
|
|||
psych (5.2.0)
|
||||
stringio
|
||||
public_suffix (6.0.1)
|
||||
puma (6.4.3)
|
||||
puma (6.5.0)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.4.0)
|
||||
activesupport (>= 3.0.0)
|
||||
|
@ -754,7 +752,7 @@ GEM
|
|||
activerecord (>= 4.0.0)
|
||||
railties (>= 4.0.0)
|
||||
securerandom (0.3.2)
|
||||
selenium-webdriver (4.26.0)
|
||||
selenium-webdriver (4.27.0)
|
||||
base64 (~> 0.2)
|
||||
logger (~> 1.4)
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
|
@ -846,9 +844,8 @@ GEM
|
|||
public_suffix
|
||||
warden (1.2.9)
|
||||
rack (>= 2.0.9)
|
||||
webauthn (3.1.0)
|
||||
webauthn (3.2.2)
|
||||
android_key_attestation (~> 0.3.0)
|
||||
awrence (~> 1.1)
|
||||
bindata (~> 2.4)
|
||||
cbor (~> 0.5.9)
|
||||
cose (~> 1.1)
|
||||
|
@ -884,7 +881,7 @@ PLATFORMS
|
|||
DEPENDENCIES
|
||||
active_model_serializers (~> 0.10)
|
||||
addressable (~> 2.8)
|
||||
annotate (~> 3.2)
|
||||
annotaterb (~> 4.13)
|
||||
aws-sdk-s3 (~> 1.123)
|
||||
better_errors (~> 2.9)
|
||||
binding_of_caller (~> 1.0)
|
||||
|
@ -968,7 +965,7 @@ DEPENDENCIES
|
|||
opentelemetry-instrumentation-net_http (~> 0.22.4)
|
||||
opentelemetry-instrumentation-pg (~> 0.29.0)
|
||||
opentelemetry-instrumentation-rack (~> 0.25.0)
|
||||
opentelemetry-instrumentation-rails (~> 0.32.0)
|
||||
opentelemetry-instrumentation-rails (~> 0.33.0)
|
||||
opentelemetry-instrumentation-redis (~> 0.25.3)
|
||||
opentelemetry-instrumentation-sidekiq (~> 0.25.2)
|
||||
opentelemetry-sdk (~> 1.4)
|
||||
|
@ -1033,7 +1030,7 @@ DEPENDENCIES
|
|||
xorcist (~> 1.1)
|
||||
|
||||
RUBY VERSION
|
||||
ruby 3.3.5p100
|
||||
ruby 3.3.6p108
|
||||
|
||||
BUNDLED WITH
|
||||
2.5.22
|
||||
2.5.23
|
||||
|
|
|
@ -5,6 +5,8 @@ module Admin
|
|||
before_action :set_instances, only: :index
|
||||
before_action :set_instance, except: :index
|
||||
|
||||
LOGS_LIMIT = 5
|
||||
|
||||
def index
|
||||
authorize :instance, :index?
|
||||
preload_delivery_failures!
|
||||
|
@ -13,7 +15,7 @@ module Admin
|
|||
def show
|
||||
authorize :instance, :show?
|
||||
@time_period = (6.days.ago.to_date...Time.now.utc.to_date)
|
||||
@action_logs = Admin::ActionLogFilter.new(target_domain: @instance.domain).results.limit(5)
|
||||
@action_logs = Admin::ActionLogFilter.new(target_domain: @instance.domain).results.limit(LOGS_LIMIT)
|
||||
end
|
||||
|
||||
def destroy
|
||||
|
|
|
@ -12,7 +12,7 @@ class Api::V1::Accounts::FamiliarFollowersController < Api::BaseController
|
|||
private
|
||||
|
||||
def set_accounts
|
||||
@accounts = Account.without_suspended.where(id: account_ids).select('id, hide_collections')
|
||||
@accounts = Account.without_suspended.where(id: account_ids).select(:id, :hide_collections)
|
||||
end
|
||||
|
||||
def familiar_followers
|
||||
|
|
|
@ -5,6 +5,8 @@ class Api::V1::FeaturedTags::SuggestionsController < Api::BaseController
|
|||
before_action :require_user!
|
||||
before_action :set_recently_used_tags, only: :index
|
||||
|
||||
RECENT_TAGS_LIMIT = 10
|
||||
|
||||
def index
|
||||
render json: @recently_used_tags, each_serializer: REST::TagSerializer, relationships: TagRelationshipsPresenter.new(@recently_used_tags, current_user&.account_id)
|
||||
end
|
||||
|
@ -12,6 +14,6 @@ class Api::V1::FeaturedTags::SuggestionsController < Api::BaseController
|
|||
private
|
||||
|
||||
def set_recently_used_tags
|
||||
@recently_used_tags = Tag.suggestions_for_account(current_account).limit(10)
|
||||
@recently_used_tags = Tag.suggestions_for_account(current_account).limit(RECENT_TAGS_LIMIT)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -15,17 +15,12 @@ class Api::V1::Lists::AccountsController < Api::BaseController
|
|||
end
|
||||
|
||||
def create
|
||||
ApplicationRecord.transaction do
|
||||
list_accounts.each do |account|
|
||||
@list.accounts << account
|
||||
end
|
||||
end
|
||||
|
||||
AddAccountsToListService.new.call(@list, Account.find(account_ids))
|
||||
render_empty
|
||||
end
|
||||
|
||||
def destroy
|
||||
ListAccount.where(list: @list, account_id: account_ids).destroy_all
|
||||
RemoveAccountsFromListService.new.call(@list, Account.where(id: account_ids))
|
||||
render_empty
|
||||
end
|
||||
|
||||
|
@ -43,10 +38,6 @@ class Api::V1::Lists::AccountsController < Api::BaseController
|
|||
end
|
||||
end
|
||||
|
||||
def list_accounts
|
||||
Account.find(account_ids)
|
||||
end
|
||||
|
||||
def account_ids
|
||||
Array(resource_params[:account_ids])
|
||||
end
|
||||
|
|
|
@ -25,7 +25,6 @@ class ApplicationController < ActionController::Base
|
|||
helper_method :use_seamless_external_login?
|
||||
helper_method :sso_account_settings
|
||||
helper_method :limited_federation_mode?
|
||||
helper_method :body_class_string
|
||||
helper_method :skip_csrf_meta_tags?
|
||||
|
||||
rescue_from ActionController::ParameterMissing, Paperclip::AdapterRegistry::NoHandlerError, with: :bad_request
|
||||
|
@ -155,10 +154,6 @@ class ApplicationController < ActionController::Base
|
|||
@current_session = SessionActivation.find_by(session_id: cookies.signed['_session_id']) if cookies.signed['_session_id'].present?
|
||||
end
|
||||
|
||||
def body_class_string
|
||||
@body_classes || ''
|
||||
end
|
||||
|
||||
def respond_with_error(code)
|
||||
respond_to do |format|
|
||||
format.any { render "errors/#{code}", layout: 'error', status: code, formats: [:html] }
|
||||
|
|
|
@ -28,7 +28,7 @@ module CacheConcern
|
|||
def render_with_cache(**options)
|
||||
raise ArgumentError, 'Only JSON render calls are supported' unless options.key?(:json) || block_given?
|
||||
|
||||
key = options.delete(:key) || [[params[:controller], params[:action]].join('/'), options[:json].respond_to?(:cache_key) ? options[:json].cache_key : nil, options[:fields].nil? ? nil : options[:fields].join(',')].compact.join(':')
|
||||
key = options.delete(:key) || [[params[:controller], params[:action]].join('/'), options[:json].respond_to?(:cache_key) ? options[:json].cache_key : nil, options[:fields]&.join(',')].compact.join(':')
|
||||
expires_in = options.delete(:expires_in) || 3.minutes
|
||||
body = Rails.cache.read(key, raw: true)
|
||||
|
||||
|
|
|
@ -5,6 +5,8 @@ class Settings::FeaturedTagsController < Settings::BaseController
|
|||
before_action :set_featured_tag, except: [:index, :create]
|
||||
before_action :set_recently_used_tags, only: :index
|
||||
|
||||
RECENT_TAGS_LIMIT = 10
|
||||
|
||||
def index
|
||||
@featured_tag = FeaturedTag.new
|
||||
end
|
||||
|
@ -38,7 +40,7 @@ class Settings::FeaturedTagsController < Settings::BaseController
|
|||
end
|
||||
|
||||
def set_recently_used_tags
|
||||
@recently_used_tags = Tag.suggestions_for_account(current_account).limit(10)
|
||||
@recently_used_tags = Tag.suggestions_for_account(current_account).limit(RECENT_TAGS_LIMIT)
|
||||
end
|
||||
|
||||
def featured_tag_params
|
||||
|
|
|
@ -24,6 +24,8 @@ class Settings::ImportsController < Settings::BaseController
|
|||
lists: false,
|
||||
}.freeze
|
||||
|
||||
RECENT_IMPORTS_LIMIT = 10
|
||||
|
||||
def index
|
||||
@import = Form::Import.new(current_account: current_account)
|
||||
end
|
||||
|
@ -96,6 +98,6 @@ class Settings::ImportsController < Settings::BaseController
|
|||
end
|
||||
|
||||
def set_recent_imports
|
||||
@recent_imports = current_account.bulk_imports.reorder(id: :desc).limit(10)
|
||||
@recent_imports = current_account.bulk_imports.reorder(id: :desc).limit(RECENT_IMPORTS_LIMIT)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -143,7 +143,7 @@ module ApplicationHelper
|
|||
end
|
||||
|
||||
def body_classes
|
||||
output = body_class_string.split
|
||||
output = []
|
||||
output << content_for(:body_classes)
|
||||
output << "flavour-#{current_flavour.parameterize}"
|
||||
output << "skin-#{current_skin.parameterize}"
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module SelfDestructHelper
|
||||
VERIFY_PURPOSE = 'self-destruct'
|
||||
|
||||
def self.self_destruct?
|
||||
value = ENV.fetch('SELF_DESTRUCT', nil)
|
||||
value.present? && Rails.application.message_verifier('self-destruct').verify(value) == ENV['LOCAL_DOMAIN']
|
||||
value = Rails.configuration.x.mastodon.self_destruct_value
|
||||
value.present? && Rails.application.message_verifier(VERIFY_PURPOSE).verify(value) == ENV['LOCAL_DOMAIN']
|
||||
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
||||
false
|
||||
end
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module StatusesHelper
|
||||
EMBEDDED_CONTROLLER = 'statuses'
|
||||
EMBEDDED_ACTION = 'embed'
|
||||
|
||||
VISIBLITY_ICONS = {
|
||||
public: 'globe',
|
||||
unlisted: 'lock_open',
|
||||
|
@ -60,18 +57,10 @@ module StatusesHelper
|
|||
components.compact_blank.join("\n\n")
|
||||
end
|
||||
|
||||
def stream_link_target
|
||||
embedded_view? ? '_blank' : nil
|
||||
end
|
||||
|
||||
def visibility_icon(status)
|
||||
VISIBLITY_ICONS[status.visibility.to_sym]
|
||||
end
|
||||
|
||||
def embedded_view?
|
||||
params[:controller] == EMBEDDED_CONTROLLER && params[:action] == EMBEDDED_ACTION
|
||||
end
|
||||
|
||||
def prefers_autoplay?
|
||||
ActiveModel::Type::Boolean.new.cast(params[:autoplay]) || current_user&.setting_auto_play_gif
|
||||
end
|
||||
|
|
|
@ -230,62 +230,6 @@ function loaded() {
|
|||
}
|
||||
},
|
||||
);
|
||||
|
||||
Rails.delegate(
|
||||
document,
|
||||
'button.status__content__spoiler-link',
|
||||
'click',
|
||||
function () {
|
||||
if (!(this instanceof HTMLButtonElement)) return;
|
||||
|
||||
const statusEl = this.parentNode?.parentNode;
|
||||
|
||||
if (
|
||||
!(
|
||||
statusEl instanceof HTMLDivElement &&
|
||||
statusEl.classList.contains('.status__content')
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
if (statusEl.dataset.spoiler === 'expanded') {
|
||||
statusEl.dataset.spoiler = 'folded';
|
||||
this.textContent = new IntlMessageFormat(
|
||||
localeData['status.show_more'] ?? 'Show more',
|
||||
locale,
|
||||
).format() as string;
|
||||
} else {
|
||||
statusEl.dataset.spoiler = 'expanded';
|
||||
this.textContent = new IntlMessageFormat(
|
||||
localeData['status.show_less'] ?? 'Show less',
|
||||
locale,
|
||||
).format() as string;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
document
|
||||
.querySelectorAll<HTMLButtonElement>('button.status__content__spoiler-link')
|
||||
.forEach((spoilerLink) => {
|
||||
const statusEl = spoilerLink.parentNode?.parentNode;
|
||||
|
||||
if (
|
||||
!(
|
||||
statusEl instanceof HTMLDivElement &&
|
||||
statusEl.classList.contains('.status__content')
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
const message =
|
||||
statusEl.dataset.spoiler === 'expanded'
|
||||
? (localeData['status.show_less'] ?? 'Show less')
|
||||
: (localeData['status.show_more'] ?? 'Show more');
|
||||
spoilerLink.textContent = new IntlMessageFormat(
|
||||
message,
|
||||
locale,
|
||||
).format() as string;
|
||||
});
|
||||
}
|
||||
|
||||
Rails.delegate(
|
||||
|
@ -439,6 +383,24 @@ Rails.delegate(document, '#registration_new_user,#new_user', 'submit', () => {
|
|||
});
|
||||
});
|
||||
|
||||
Rails.delegate(document, '.rules-list button', 'click', ({ target }) => {
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const button = target.closest('button');
|
||||
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (button.ariaExpanded === 'true') {
|
||||
button.ariaExpanded = 'false';
|
||||
} else {
|
||||
button.ariaExpanded = 'true';
|
||||
}
|
||||
});
|
||||
|
||||
function main() {
|
||||
ready(loaded).catch((error: unknown) => {
|
||||
console.error(error);
|
||||
|
|
|
@ -74,19 +74,6 @@ export const FOLLOW_REQUEST_AUTHORIZE_FAIL = 'FOLLOW_REQUEST_AUTHORIZE_FAIL';
|
|||
export const FOLLOW_REQUEST_REJECT_REQUEST = 'FOLLOW_REQUEST_REJECT_REQUEST';
|
||||
export const FOLLOW_REQUEST_REJECT_FAIL = 'FOLLOW_REQUEST_REJECT_FAIL';
|
||||
|
||||
export const PINNED_ACCOUNTS_FETCH_REQUEST = 'PINNED_ACCOUNTS_FETCH_REQUEST';
|
||||
export const PINNED_ACCOUNTS_FETCH_SUCCESS = 'PINNED_ACCOUNTS_FETCH_SUCCESS';
|
||||
export const PINNED_ACCOUNTS_FETCH_FAIL = 'PINNED_ACCOUNTS_FETCH_FAIL';
|
||||
|
||||
export const PINNED_ACCOUNTS_SUGGESTIONS_FETCH_REQUEST = 'PINNED_ACCOUNTS_SUGGESTIONS_FETCH_REQUEST';
|
||||
export const PINNED_ACCOUNTS_SUGGESTIONS_FETCH_SUCCESS = 'PINNED_ACCOUNTS_SUGGESTIONS_FETCH_SUCCESS';
|
||||
export const PINNED_ACCOUNTS_SUGGESTIONS_FETCH_FAIL = 'PINNED_ACCOUNTS_SUGGESTIONS_FETCH_FAIL';
|
||||
|
||||
export const PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CLEAR = 'PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CLEAR';
|
||||
export const PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CHANGE = 'PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CHANGE';
|
||||
|
||||
export const PINNED_ACCOUNTS_EDITOR_RESET = 'PINNED_ACCOUNTS_EDITOR_RESET';
|
||||
|
||||
export const ACCOUNT_REVEAL = 'ACCOUNT_REVEAL';
|
||||
|
||||
export * from './accounts_typed';
|
||||
|
@ -686,38 +673,6 @@ export function unpinAccountFail(error) {
|
|||
};
|
||||
}
|
||||
|
||||
export function fetchPinnedAccounts() {
|
||||
return (dispatch) => {
|
||||
dispatch(fetchPinnedAccountsRequest());
|
||||
|
||||
api().get('/api/v1/endorsements', { params: { limit: 0 } }).then(response => {
|
||||
dispatch(importFetchedAccounts(response.data));
|
||||
dispatch(fetchPinnedAccountsSuccess(response.data));
|
||||
}).catch(err => dispatch(fetchPinnedAccountsFail(err)));
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchPinnedAccountsRequest() {
|
||||
return {
|
||||
type: PINNED_ACCOUNTS_FETCH_REQUEST,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchPinnedAccountsSuccess(accounts, next) {
|
||||
return {
|
||||
type: PINNED_ACCOUNTS_FETCH_SUCCESS,
|
||||
accounts,
|
||||
next,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchPinnedAccountsFail(error) {
|
||||
return {
|
||||
type: PINNED_ACCOUNTS_FETCH_FAIL,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export const updateAccount = ({ displayName, note, avatar, header, discoverable, indexable }) => (dispatch) => {
|
||||
const data = new FormData();
|
||||
|
||||
|
@ -742,62 +697,3 @@ export const navigateToProfile = (accountId) => {
|
|||
}
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchPinnedAccountsSuggestions(q) {
|
||||
return (dispatch) => {
|
||||
dispatch(fetchPinnedAccountsSuggestionsRequest());
|
||||
|
||||
const params = {
|
||||
q,
|
||||
resolve: false,
|
||||
limit: 4,
|
||||
following: true,
|
||||
};
|
||||
|
||||
api().get('/api/v1/accounts/search', { params }).then(response => {
|
||||
dispatch(importFetchedAccounts(response.data));
|
||||
dispatch(fetchPinnedAccountsSuggestionsSuccess(q, response.data));
|
||||
}).catch(err => dispatch(fetchPinnedAccountsSuggestionsFail(err)));
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchPinnedAccountsSuggestionsRequest() {
|
||||
return {
|
||||
type: PINNED_ACCOUNTS_SUGGESTIONS_FETCH_REQUEST,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchPinnedAccountsSuggestionsSuccess(query, accounts) {
|
||||
return {
|
||||
type: PINNED_ACCOUNTS_SUGGESTIONS_FETCH_SUCCESS,
|
||||
query,
|
||||
accounts,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchPinnedAccountsSuggestionsFail(error) {
|
||||
return {
|
||||
type: PINNED_ACCOUNTS_SUGGESTIONS_FETCH_FAIL,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export function clearPinnedAccountsSuggestions() {
|
||||
return {
|
||||
type: PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CLEAR,
|
||||
};
|
||||
}
|
||||
|
||||
export function changePinnedAccountsSuggestions(value) {
|
||||
return {
|
||||
type: PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CHANGE,
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
export function resetPinnedAccountsEditor() {
|
||||
return {
|
||||
type: PINNED_ACCOUNTS_EDITOR_RESET,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -1,8 +1,5 @@
|
|||
import api from '../api';
|
||||
|
||||
import { showAlertForError } from './alerts';
|
||||
import { importFetchedAccounts } from './importer';
|
||||
|
||||
export const LIST_FETCH_REQUEST = 'LIST_FETCH_REQUEST';
|
||||
export const LIST_FETCH_SUCCESS = 'LIST_FETCH_SUCCESS';
|
||||
export const LIST_FETCH_FAIL = 'LIST_FETCH_FAIL';
|
||||
|
@ -11,45 +8,10 @@ export const LISTS_FETCH_REQUEST = 'LISTS_FETCH_REQUEST';
|
|||
export const LISTS_FETCH_SUCCESS = 'LISTS_FETCH_SUCCESS';
|
||||
export const LISTS_FETCH_FAIL = 'LISTS_FETCH_FAIL';
|
||||
|
||||
export const LIST_EDITOR_TITLE_CHANGE = 'LIST_EDITOR_TITLE_CHANGE';
|
||||
export const LIST_EDITOR_RESET = 'LIST_EDITOR_RESET';
|
||||
export const LIST_EDITOR_SETUP = 'LIST_EDITOR_SETUP';
|
||||
|
||||
export const LIST_CREATE_REQUEST = 'LIST_CREATE_REQUEST';
|
||||
export const LIST_CREATE_SUCCESS = 'LIST_CREATE_SUCCESS';
|
||||
export const LIST_CREATE_FAIL = 'LIST_CREATE_FAIL';
|
||||
|
||||
export const LIST_UPDATE_REQUEST = 'LIST_UPDATE_REQUEST';
|
||||
export const LIST_UPDATE_SUCCESS = 'LIST_UPDATE_SUCCESS';
|
||||
export const LIST_UPDATE_FAIL = 'LIST_UPDATE_FAIL';
|
||||
|
||||
export const LIST_DELETE_REQUEST = 'LIST_DELETE_REQUEST';
|
||||
export const LIST_DELETE_SUCCESS = 'LIST_DELETE_SUCCESS';
|
||||
export const LIST_DELETE_FAIL = 'LIST_DELETE_FAIL';
|
||||
|
||||
export const LIST_ACCOUNTS_FETCH_REQUEST = 'LIST_ACCOUNTS_FETCH_REQUEST';
|
||||
export const LIST_ACCOUNTS_FETCH_SUCCESS = 'LIST_ACCOUNTS_FETCH_SUCCESS';
|
||||
export const LIST_ACCOUNTS_FETCH_FAIL = 'LIST_ACCOUNTS_FETCH_FAIL';
|
||||
|
||||
export const LIST_EDITOR_SUGGESTIONS_CHANGE = 'LIST_EDITOR_SUGGESTIONS_CHANGE';
|
||||
export const LIST_EDITOR_SUGGESTIONS_READY = 'LIST_EDITOR_SUGGESTIONS_READY';
|
||||
export const LIST_EDITOR_SUGGESTIONS_CLEAR = 'LIST_EDITOR_SUGGESTIONS_CLEAR';
|
||||
|
||||
export const LIST_EDITOR_ADD_REQUEST = 'LIST_EDITOR_ADD_REQUEST';
|
||||
export const LIST_EDITOR_ADD_SUCCESS = 'LIST_EDITOR_ADD_SUCCESS';
|
||||
export const LIST_EDITOR_ADD_FAIL = 'LIST_EDITOR_ADD_FAIL';
|
||||
|
||||
export const LIST_EDITOR_REMOVE_REQUEST = 'LIST_EDITOR_REMOVE_REQUEST';
|
||||
export const LIST_EDITOR_REMOVE_SUCCESS = 'LIST_EDITOR_REMOVE_SUCCESS';
|
||||
export const LIST_EDITOR_REMOVE_FAIL = 'LIST_EDITOR_REMOVE_FAIL';
|
||||
|
||||
export const LIST_ADDER_RESET = 'LIST_ADDER_RESET';
|
||||
export const LIST_ADDER_SETUP = 'LIST_ADDER_SETUP';
|
||||
|
||||
export const LIST_ADDER_LISTS_FETCH_REQUEST = 'LIST_ADDER_LISTS_FETCH_REQUEST';
|
||||
export const LIST_ADDER_LISTS_FETCH_SUCCESS = 'LIST_ADDER_LISTS_FETCH_SUCCESS';
|
||||
export const LIST_ADDER_LISTS_FETCH_FAIL = 'LIST_ADDER_LISTS_FETCH_FAIL';
|
||||
|
||||
export const fetchList = id => (dispatch, getState) => {
|
||||
if (getState().getIn(['lists', id])) {
|
||||
return;
|
||||
|
@ -100,89 +62,6 @@ export const fetchListsFail = error => ({
|
|||
error,
|
||||
});
|
||||
|
||||
export const submitListEditor = shouldReset => (dispatch, getState) => {
|
||||
const listId = getState().getIn(['listEditor', 'listId']);
|
||||
const title = getState().getIn(['listEditor', 'title']);
|
||||
|
||||
if (listId === null) {
|
||||
dispatch(createList(title, shouldReset));
|
||||
} else {
|
||||
dispatch(updateList(listId, title, shouldReset));
|
||||
}
|
||||
};
|
||||
|
||||
export const setupListEditor = listId => (dispatch, getState) => {
|
||||
dispatch({
|
||||
type: LIST_EDITOR_SETUP,
|
||||
list: getState().getIn(['lists', listId]),
|
||||
});
|
||||
|
||||
dispatch(fetchListAccounts(listId));
|
||||
};
|
||||
|
||||
export const changeListEditorTitle = value => ({
|
||||
type: LIST_EDITOR_TITLE_CHANGE,
|
||||
value,
|
||||
});
|
||||
|
||||
export const createList = (title, shouldReset) => (dispatch) => {
|
||||
dispatch(createListRequest());
|
||||
|
||||
api().post('/api/v1/lists', { title }).then(({ data }) => {
|
||||
dispatch(createListSuccess(data));
|
||||
|
||||
if (shouldReset) {
|
||||
dispatch(resetListEditor());
|
||||
}
|
||||
}).catch(err => dispatch(createListFail(err)));
|
||||
};
|
||||
|
||||
export const createListRequest = () => ({
|
||||
type: LIST_CREATE_REQUEST,
|
||||
});
|
||||
|
||||
export const createListSuccess = list => ({
|
||||
type: LIST_CREATE_SUCCESS,
|
||||
list,
|
||||
});
|
||||
|
||||
export const createListFail = error => ({
|
||||
type: LIST_CREATE_FAIL,
|
||||
error,
|
||||
});
|
||||
|
||||
export const updateList = (id, title, shouldReset, isExclusive, replies_policy) => (dispatch) => {
|
||||
dispatch(updateListRequest(id));
|
||||
|
||||
api().put(`/api/v1/lists/${id}`, { title, replies_policy, exclusive: typeof isExclusive === 'undefined' ? undefined : !!isExclusive }).then(({ data }) => {
|
||||
dispatch(updateListSuccess(data));
|
||||
|
||||
if (shouldReset) {
|
||||
dispatch(resetListEditor());
|
||||
}
|
||||
}).catch(err => dispatch(updateListFail(id, err)));
|
||||
};
|
||||
|
||||
export const updateListRequest = id => ({
|
||||
type: LIST_UPDATE_REQUEST,
|
||||
id,
|
||||
});
|
||||
|
||||
export const updateListSuccess = list => ({
|
||||
type: LIST_UPDATE_SUCCESS,
|
||||
list,
|
||||
});
|
||||
|
||||
export const updateListFail = (id, error) => ({
|
||||
type: LIST_UPDATE_FAIL,
|
||||
id,
|
||||
error,
|
||||
});
|
||||
|
||||
export const resetListEditor = () => ({
|
||||
type: LIST_EDITOR_RESET,
|
||||
});
|
||||
|
||||
export const deleteList = id => (dispatch) => {
|
||||
dispatch(deleteListRequest(id));
|
||||
|
||||
|
@ -206,167 +85,3 @@ export const deleteListFail = (id, error) => ({
|
|||
id,
|
||||
error,
|
||||
});
|
||||
|
||||
export const fetchListAccounts = listId => (dispatch) => {
|
||||
dispatch(fetchListAccountsRequest(listId));
|
||||
|
||||
api().get(`/api/v1/lists/${listId}/accounts`, { params: { limit: 0 } }).then(({ data }) => {
|
||||
dispatch(importFetchedAccounts(data));
|
||||
dispatch(fetchListAccountsSuccess(listId, data));
|
||||
}).catch(err => dispatch(fetchListAccountsFail(listId, err)));
|
||||
};
|
||||
|
||||
export const fetchListAccountsRequest = id => ({
|
||||
type: LIST_ACCOUNTS_FETCH_REQUEST,
|
||||
id,
|
||||
});
|
||||
|
||||
export const fetchListAccountsSuccess = (id, accounts, next) => ({
|
||||
type: LIST_ACCOUNTS_FETCH_SUCCESS,
|
||||
id,
|
||||
accounts,
|
||||
next,
|
||||
});
|
||||
|
||||
export const fetchListAccountsFail = (id, error) => ({
|
||||
type: LIST_ACCOUNTS_FETCH_FAIL,
|
||||
id,
|
||||
error,
|
||||
});
|
||||
|
||||
export const fetchListSuggestions = q => (dispatch) => {
|
||||
const params = {
|
||||
q,
|
||||
resolve: false,
|
||||
limit: 4,
|
||||
following: true,
|
||||
};
|
||||
|
||||
api().get('/api/v1/accounts/search', { params }).then(({ data }) => {
|
||||
dispatch(importFetchedAccounts(data));
|
||||
dispatch(fetchListSuggestionsReady(q, data));
|
||||
}).catch(error => dispatch(showAlertForError(error)));
|
||||
};
|
||||
|
||||
export const fetchListSuggestionsReady = (query, accounts) => ({
|
||||
type: LIST_EDITOR_SUGGESTIONS_READY,
|
||||
query,
|
||||
accounts,
|
||||
});
|
||||
|
||||
export const clearListSuggestions = () => ({
|
||||
type: LIST_EDITOR_SUGGESTIONS_CLEAR,
|
||||
});
|
||||
|
||||
export const changeListSuggestions = value => ({
|
||||
type: LIST_EDITOR_SUGGESTIONS_CHANGE,
|
||||
value,
|
||||
});
|
||||
|
||||
export const addToListEditor = accountId => (dispatch, getState) => {
|
||||
dispatch(addToList(getState().getIn(['listEditor', 'listId']), accountId));
|
||||
};
|
||||
|
||||
export const addToList = (listId, accountId) => (dispatch) => {
|
||||
dispatch(addToListRequest(listId, accountId));
|
||||
|
||||
api().post(`/api/v1/lists/${listId}/accounts`, { account_ids: [accountId] })
|
||||
.then(() => dispatch(addToListSuccess(listId, accountId)))
|
||||
.catch(err => dispatch(addToListFail(listId, accountId, err)));
|
||||
};
|
||||
|
||||
export const addToListRequest = (listId, accountId) => ({
|
||||
type: LIST_EDITOR_ADD_REQUEST,
|
||||
listId,
|
||||
accountId,
|
||||
});
|
||||
|
||||
export const addToListSuccess = (listId, accountId) => ({
|
||||
type: LIST_EDITOR_ADD_SUCCESS,
|
||||
listId,
|
||||
accountId,
|
||||
});
|
||||
|
||||
export const addToListFail = (listId, accountId, error) => ({
|
||||
type: LIST_EDITOR_ADD_FAIL,
|
||||
listId,
|
||||
accountId,
|
||||
error,
|
||||
});
|
||||
|
||||
export const removeFromListEditor = accountId => (dispatch, getState) => {
|
||||
dispatch(removeFromList(getState().getIn(['listEditor', 'listId']), accountId));
|
||||
};
|
||||
|
||||
export const removeFromList = (listId, accountId) => (dispatch) => {
|
||||
dispatch(removeFromListRequest(listId, accountId));
|
||||
|
||||
api().delete(`/api/v1/lists/${listId}/accounts`, { params: { account_ids: [accountId] } })
|
||||
.then(() => dispatch(removeFromListSuccess(listId, accountId)))
|
||||
.catch(err => dispatch(removeFromListFail(listId, accountId, err)));
|
||||
};
|
||||
|
||||
export const removeFromListRequest = (listId, accountId) => ({
|
||||
type: LIST_EDITOR_REMOVE_REQUEST,
|
||||
listId,
|
||||
accountId,
|
||||
});
|
||||
|
||||
export const removeFromListSuccess = (listId, accountId) => ({
|
||||
type: LIST_EDITOR_REMOVE_SUCCESS,
|
||||
listId,
|
||||
accountId,
|
||||
});
|
||||
|
||||
export const removeFromListFail = (listId, accountId, error) => ({
|
||||
type: LIST_EDITOR_REMOVE_FAIL,
|
||||
listId,
|
||||
accountId,
|
||||
error,
|
||||
});
|
||||
|
||||
export const resetListAdder = () => ({
|
||||
type: LIST_ADDER_RESET,
|
||||
});
|
||||
|
||||
export const setupListAdder = accountId => (dispatch, getState) => {
|
||||
dispatch({
|
||||
type: LIST_ADDER_SETUP,
|
||||
account: getState().getIn(['accounts', accountId]),
|
||||
});
|
||||
dispatch(fetchLists());
|
||||
dispatch(fetchAccountLists(accountId));
|
||||
};
|
||||
|
||||
export const fetchAccountLists = accountId => (dispatch) => {
|
||||
dispatch(fetchAccountListsRequest(accountId));
|
||||
|
||||
api().get(`/api/v1/accounts/${accountId}/lists`)
|
||||
.then(({ data }) => dispatch(fetchAccountListsSuccess(accountId, data)))
|
||||
.catch(err => dispatch(fetchAccountListsFail(accountId, err)));
|
||||
};
|
||||
|
||||
export const fetchAccountListsRequest = id => ({
|
||||
type:LIST_ADDER_LISTS_FETCH_REQUEST,
|
||||
id,
|
||||
});
|
||||
|
||||
export const fetchAccountListsSuccess = (id, lists) => ({
|
||||
type: LIST_ADDER_LISTS_FETCH_SUCCESS,
|
||||
id,
|
||||
lists,
|
||||
});
|
||||
|
||||
export const fetchAccountListsFail = (id, err) => ({
|
||||
type: LIST_ADDER_LISTS_FETCH_FAIL,
|
||||
id,
|
||||
err,
|
||||
});
|
||||
|
||||
export const addToListAdder = listId => (dispatch, getState) => {
|
||||
dispatch(addToList(listId, getState().getIn(['listAdder', 'accountId'])));
|
||||
};
|
||||
|
||||
export const removeFromListAdder = listId => (dispatch, getState) => {
|
||||
dispatch(removeFromList(listId, getState().getIn(['listAdder', 'accountId'])));
|
||||
};
|
||||
|
|
13
app/javascript/flavours/glitch/actions/lists_typed.ts
Normal file
13
app/javascript/flavours/glitch/actions/lists_typed.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { apiCreate, apiUpdate } from 'flavours/glitch/api/lists';
|
||||
import type { List } from 'flavours/glitch/models/list';
|
||||
import { createDataLoadingThunk } from 'flavours/glitch/store/typed_functions';
|
||||
|
||||
export const createList = createDataLoadingThunk(
|
||||
'list/create',
|
||||
(list: Partial<List>) => apiCreate(list),
|
||||
);
|
||||
|
||||
export const updateList = createDataLoadingThunk(
|
||||
'list/update',
|
||||
(list: Partial<List>) => apiUpdate(list),
|
||||
);
|
|
@ -141,6 +141,9 @@ export const pollRecentNotifications = createDataLoadingThunk(
|
|||
|
||||
return { notifications };
|
||||
},
|
||||
{
|
||||
useLoadingBar: false,
|
||||
},
|
||||
);
|
||||
|
||||
export const processNewNotificationForGroups = createAppAsyncThunk(
|
||||
|
|
|
@ -1,58 +0,0 @@
|
|||
import api from '../api';
|
||||
|
||||
import { fetchRelationships } from './accounts';
|
||||
import { importFetchedAccounts } from './importer';
|
||||
|
||||
export const SUGGESTIONS_FETCH_REQUEST = 'SUGGESTIONS_FETCH_REQUEST';
|
||||
export const SUGGESTIONS_FETCH_SUCCESS = 'SUGGESTIONS_FETCH_SUCCESS';
|
||||
export const SUGGESTIONS_FETCH_FAIL = 'SUGGESTIONS_FETCH_FAIL';
|
||||
|
||||
export const SUGGESTIONS_DISMISS = 'SUGGESTIONS_DISMISS';
|
||||
|
||||
export function fetchSuggestions(withRelationships = false) {
|
||||
return (dispatch) => {
|
||||
dispatch(fetchSuggestionsRequest());
|
||||
|
||||
api().get('/api/v2/suggestions', { params: { limit: 20 } }).then(response => {
|
||||
dispatch(importFetchedAccounts(response.data.map(x => x.account)));
|
||||
dispatch(fetchSuggestionsSuccess(response.data));
|
||||
|
||||
if (withRelationships) {
|
||||
dispatch(fetchRelationships(response.data.map(item => item.account.id)));
|
||||
}
|
||||
}).catch(error => dispatch(fetchSuggestionsFail(error)));
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchSuggestionsRequest() {
|
||||
return {
|
||||
type: SUGGESTIONS_FETCH_REQUEST,
|
||||
skipLoading: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchSuggestionsSuccess(suggestions) {
|
||||
return {
|
||||
type: SUGGESTIONS_FETCH_SUCCESS,
|
||||
suggestions,
|
||||
skipLoading: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchSuggestionsFail(error) {
|
||||
return {
|
||||
type: SUGGESTIONS_FETCH_FAIL,
|
||||
error,
|
||||
skipLoading: true,
|
||||
skipAlert: true,
|
||||
};
|
||||
}
|
||||
|
||||
export const dismissSuggestion = accountId => (dispatch) => {
|
||||
dispatch({
|
||||
type: SUGGESTIONS_DISMISS,
|
||||
id: accountId,
|
||||
});
|
||||
|
||||
api().delete(`/api/v1/suggestions/${accountId}`).catch(() => {});
|
||||
};
|
24
app/javascript/flavours/glitch/actions/suggestions.ts
Normal file
24
app/javascript/flavours/glitch/actions/suggestions.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import {
|
||||
apiGetSuggestions,
|
||||
apiDeleteSuggestion,
|
||||
} from 'flavours/glitch/api/suggestions';
|
||||
import { createDataLoadingThunk } from 'flavours/glitch/store/typed_functions';
|
||||
|
||||
import { fetchRelationships } from './accounts';
|
||||
import { importFetchedAccounts } from './importer';
|
||||
|
||||
export const fetchSuggestions = createDataLoadingThunk(
|
||||
'suggestions/fetch',
|
||||
() => apiGetSuggestions(20),
|
||||
(data, { dispatch }) => {
|
||||
dispatch(importFetchedAccounts(data.map((x) => x.account)));
|
||||
dispatch(fetchRelationships(data.map((x) => x.account.id)));
|
||||
|
||||
return data;
|
||||
},
|
||||
);
|
||||
|
||||
export const dismissSuggestion = createDataLoadingThunk(
|
||||
'suggestions/dismiss',
|
||||
({ accountId }: { accountId: string }) => apiDeleteSuggestion(accountId),
|
||||
);
|
|
@ -68,6 +68,7 @@ export async function apiRequest<ApiResponse = unknown>(
|
|||
method: Method,
|
||||
url: string,
|
||||
args: {
|
||||
signal?: AbortSignal;
|
||||
params?: RequestParamsOrData;
|
||||
data?: RequestParamsOrData;
|
||||
timeout?: number;
|
||||
|
|
32
app/javascript/flavours/glitch/api/lists.ts
Normal file
32
app/javascript/flavours/glitch/api/lists.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
import {
|
||||
apiRequestPost,
|
||||
apiRequestPut,
|
||||
apiRequestGet,
|
||||
apiRequestDelete,
|
||||
} from 'flavours/glitch/api';
|
||||
import type { ApiAccountJSON } from 'flavours/glitch/api_types/accounts';
|
||||
import type { ApiListJSON } from 'flavours/glitch/api_types/lists';
|
||||
|
||||
export const apiCreate = (list: Partial<ApiListJSON>) =>
|
||||
apiRequestPost<ApiListJSON>('v1/lists', list);
|
||||
|
||||
export const apiUpdate = (list: Partial<ApiListJSON>) =>
|
||||
apiRequestPut<ApiListJSON>(`v1/lists/${list.id}`, list);
|
||||
|
||||
export const apiGetAccounts = (listId: string) =>
|
||||
apiRequestGet<ApiAccountJSON[]>(`v1/lists/${listId}/accounts`, {
|
||||
limit: 0,
|
||||
});
|
||||
|
||||
export const apiGetAccountLists = (accountId: string) =>
|
||||
apiRequestGet<ApiListJSON[]>(`v1/accounts/${accountId}/lists`);
|
||||
|
||||
export const apiAddAccountToList = (listId: string, accountId: string) =>
|
||||
apiRequestPost(`v1/lists/${listId}/accounts`, {
|
||||
account_ids: [accountId],
|
||||
});
|
||||
|
||||
export const apiRemoveAccountFromList = (listId: string, accountId: string) =>
|
||||
apiRequestDelete(`v1/lists/${listId}/accounts`, {
|
||||
account_ids: [accountId],
|
||||
});
|
8
app/javascript/flavours/glitch/api/suggestions.ts
Normal file
8
app/javascript/flavours/glitch/api/suggestions.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { apiRequestGet, apiRequestDelete } from 'flavours/glitch/api';
|
||||
import type { ApiSuggestionJSON } from 'flavours/glitch/api_types/suggestions';
|
||||
|
||||
export const apiGetSuggestions = (limit: number) =>
|
||||
apiRequestGet<ApiSuggestionJSON[]>('v2/suggestions', { limit });
|
||||
|
||||
export const apiDeleteSuggestion = (accountId: string) =>
|
||||
apiRequestDelete(`v1/suggestions/${accountId}`);
|
10
app/javascript/flavours/glitch/api_types/lists.ts
Normal file
10
app/javascript/flavours/glitch/api_types/lists.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
// See app/serializers/rest/list_serializer.rb
|
||||
|
||||
export type RepliesPolicyType = 'list' | 'followed' | 'none';
|
||||
|
||||
export interface ApiListJSON {
|
||||
id: string;
|
||||
title: string;
|
||||
exclusive: boolean;
|
||||
replies_policy: RepliesPolicyType;
|
||||
}
|
13
app/javascript/flavours/glitch/api_types/suggestions.ts
Normal file
13
app/javascript/flavours/glitch/api_types/suggestions.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import type { ApiAccountJSON } from 'flavours/glitch/api_types/accounts';
|
||||
|
||||
export type ApiSuggestionSourceJSON =
|
||||
| 'featured'
|
||||
| 'most_followed'
|
||||
| 'most_interactions'
|
||||
| 'similar_to_recently_followed'
|
||||
| 'friends_of_friends';
|
||||
|
||||
export interface ApiSuggestionJSON {
|
||||
sources: [ApiSuggestionSourceJSON, ...ApiSuggestionSourceJSON[]];
|
||||
account: ApiAccountJSON;
|
||||
}
|
|
@ -9,6 +9,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
|||
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
import { EmptyAccount } from 'flavours/glitch/components/empty_account';
|
||||
import { FollowButton } from 'flavours/glitch/components/follow_button';
|
||||
import { ShortNumber } from 'flavours/glitch/components/short_number';
|
||||
import { VerifiedBadge } from 'flavours/glitch/components/verified_badge';
|
||||
|
||||
|
@ -23,9 +24,6 @@ import { Permalink } from './permalink';
|
|||
import { RelativeTimestamp } from './relative_timestamp';
|
||||
|
||||
const messages = defineMessages({
|
||||
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
||||
cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Withdraw follow request' },
|
||||
unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' },
|
||||
unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' },
|
||||
mute_notifications: { id: 'account.mute_notifications_short', defaultMessage: 'Mute notifications' },
|
||||
|
@ -35,13 +33,9 @@ const messages = defineMessages({
|
|||
more: { id: 'status.more', defaultMessage: 'More' },
|
||||
});
|
||||
|
||||
const Account = ({ size = 46, account, onFollow, onBlock, onMute, onMuteNotifications, hidden, minimal, defaultAction, withBio }) => {
|
||||
const Account = ({ size = 46, account, onBlock, onMute, onMuteNotifications, hidden, minimal, defaultAction, withBio }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const handleFollow = useCallback(() => {
|
||||
onFollow(account);
|
||||
}, [onFollow, account]);
|
||||
|
||||
const handleBlock = useCallback(() => {
|
||||
onBlock(account);
|
||||
}, [onBlock, account]);
|
||||
|
@ -74,13 +68,12 @@ const Account = ({ size = 46, account, onFollow, onBlock, onMute, onMuteNotifica
|
|||
let buttons;
|
||||
|
||||
if (account.get('id') !== me && account.get('relationship', null) !== null) {
|
||||
const following = account.getIn(['relationship', 'following']);
|
||||
const requested = account.getIn(['relationship', 'requested']);
|
||||
const blocking = account.getIn(['relationship', 'blocking']);
|
||||
const muting = account.getIn(['relationship', 'muting']);
|
||||
|
||||
if (requested) {
|
||||
buttons = <Button text={intl.formatMessage(messages.cancel_follow_request)} onClick={handleFollow} />;
|
||||
buttons = <FollowButton accountId={account.get('id')} />;
|
||||
} else if (blocking) {
|
||||
buttons = <Button text={intl.formatMessage(messages.unblock)} onClick={handleBlock} />;
|
||||
} else if (muting) {
|
||||
|
@ -109,9 +102,11 @@ const Account = ({ size = 46, account, onFollow, onBlock, onMute, onMuteNotifica
|
|||
buttons = <Button text={intl.formatMessage(messages.mute)} onClick={handleMute} />;
|
||||
} else if (defaultAction === 'block') {
|
||||
buttons = <Button text={intl.formatMessage(messages.block)} onClick={handleBlock} />;
|
||||
} else if (!account.get('suspended') && !account.get('moved') || following) {
|
||||
buttons = <Button text={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={handleFollow} />;
|
||||
} else {
|
||||
buttons = <FollowButton accountId={account.get('id')} />;
|
||||
}
|
||||
} else {
|
||||
buttons = <FollowButton accountId={account.get('id')} />;
|
||||
}
|
||||
|
||||
let muteTimeRemaining;
|
||||
|
@ -170,7 +165,6 @@ const Account = ({ size = 46, account, onFollow, onBlock, onMute, onMuteNotifica
|
|||
Account.propTypes = {
|
||||
size: PropTypes.number,
|
||||
account: ImmutablePropTypes.record,
|
||||
onFollow: PropTypes.func,
|
||||
onBlock: PropTypes.func,
|
||||
onMute: PropTypes.func,
|
||||
onMuteNotifications: PropTypes.func,
|
||||
|
|
|
@ -7,11 +7,11 @@ import { Icon } from './icon';
|
|||
|
||||
interface Props {
|
||||
value: string;
|
||||
checked: boolean;
|
||||
indeterminate: boolean;
|
||||
name: string;
|
||||
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
label: React.ReactNode;
|
||||
checked?: boolean;
|
||||
indeterminate?: boolean;
|
||||
name?: string;
|
||||
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
label?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const CheckBox: React.FC<Props> = ({
|
||||
|
@ -30,6 +30,7 @@ export const CheckBox: React.FC<Props> = ({
|
|||
value={value}
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
readOnly={!onChange}
|
||||
/>
|
||||
|
||||
<span
|
||||
|
@ -42,7 +43,7 @@ export const CheckBox: React.FC<Props> = ({
|
|||
)}
|
||||
</span>
|
||||
|
||||
<span>{label}</span>
|
||||
{label && <span>{label}</span>}
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -24,7 +24,7 @@ function useHandleClick(onClick?: OnClickCallback) {
|
|||
}, [history, onClick]);
|
||||
}
|
||||
|
||||
export const ColumnBackButton: React.FC<{ onClick: OnClickCallback }> = ({
|
||||
export const ColumnBackButton: React.FC<{ onClick?: OnClickCallback }> = ({
|
||||
onClick,
|
||||
}) => {
|
||||
const handleClick = useHandleClick(onClick);
|
||||
|
|
|
@ -0,0 +1,67 @@
|
|||
import { useCallback, useState, useEffect, useRef } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
export const ColumnSearchHeader: React.FC<{
|
||||
onBack: () => void;
|
||||
onSubmit: (value: string) => void;
|
||||
onActivate: () => void;
|
||||
placeholder: string;
|
||||
active: boolean;
|
||||
}> = ({ onBack, onActivate, onSubmit, placeholder, active }) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
setValue('');
|
||||
}
|
||||
}, [active]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setValue(value);
|
||||
onSubmit(value);
|
||||
},
|
||||
[setValue, onSubmit],
|
||||
);
|
||||
|
||||
const handleKeyUp = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onBack();
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
},
|
||||
[onBack],
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
onActivate();
|
||||
}, [onActivate]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
onSubmit(value);
|
||||
}, [onSubmit, value]);
|
||||
|
||||
return (
|
||||
<form className='column-search-header' onSubmit={handleSubmit}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type='search'
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyUp={handleKeyUp}
|
||||
placeholder={placeholder}
|
||||
onFocus={handleFocus}
|
||||
/>
|
||||
|
||||
{active && (
|
||||
<button type='button' className='link-button' onClick={onBack}>
|
||||
<FormattedMessage id='column_search.cancel' defaultMessage='Cancel' />
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
};
|
|
@ -99,7 +99,12 @@ export const FollowButton: React.FC<{
|
|||
return (
|
||||
<Button
|
||||
onClick={handleClick}
|
||||
disabled={relationship?.blocked_by || relationship?.blocking}
|
||||
disabled={
|
||||
relationship?.blocked_by ||
|
||||
relationship?.blocking ||
|
||||
(!(relationship?.following || relationship?.requested) &&
|
||||
(account?.suspended || !!account?.moved))
|
||||
}
|
||||
secondary={following}
|
||||
className={following ? 'button--destructive' : undefined}
|
||||
>
|
||||
|
|
22
app/javascript/flavours/glitch/components/gif.tsx
Normal file
22
app/javascript/flavours/glitch/components/gif.tsx
Normal file
|
@ -0,0 +1,22 @@
|
|||
import { useHovering } from '@/hooks/useHovering';
|
||||
import { autoPlayGif } from 'flavours/glitch/initial_state';
|
||||
|
||||
export const GIF: React.FC<{
|
||||
src: string;
|
||||
staticSrc: string;
|
||||
className: string;
|
||||
animate?: boolean;
|
||||
}> = ({ src, staticSrc, className, animate = autoPlayGif }) => {
|
||||
const { hovering, handleMouseEnter, handleMouseLeave } = useHovering(animate);
|
||||
|
||||
return (
|
||||
<img
|
||||
className={className}
|
||||
src={hovering || animate ? src : staticSrc}
|
||||
alt=''
|
||||
role='presentation'
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -80,6 +80,7 @@ class ScrollableList extends PureComponent {
|
|||
children: PropTypes.node,
|
||||
bindToDocument: PropTypes.bool,
|
||||
preventScroll: PropTypes.bool,
|
||||
footer: PropTypes.node,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
|
@ -324,7 +325,7 @@ class ScrollableList extends PureComponent {
|
|||
};
|
||||
|
||||
render () {
|
||||
const { children, scrollKey, trackScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, emptyMessage, onLoadMore } = this.props;
|
||||
const { children, scrollKey, trackScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, footer, emptyMessage, onLoadMore } = this.props;
|
||||
const { fullscreen } = this.state;
|
||||
const childrenCount = Children.count(children);
|
||||
|
||||
|
@ -342,11 +343,13 @@ class ScrollableList extends PureComponent {
|
|||
<div className='scrollable__append'>
|
||||
<LoadingIndicator />
|
||||
</div>
|
||||
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
} else if (isLoading || childrenCount > 0 || hasMore || !emptyMessage) {
|
||||
scrollableArea = (
|
||||
<div className={classNames('scrollable', { fullscreen })} ref={this.setRef} onMouseMove={this.handleMouseMove}>
|
||||
<div className={classNames('scrollable scrollable--flex', { fullscreen })} ref={this.setRef} onMouseMove={this.handleMouseMove}>
|
||||
<div role='feed' className='item-list'>
|
||||
{prepend}
|
||||
|
||||
|
@ -375,6 +378,8 @@ class ScrollableList extends PureComponent {
|
|||
|
||||
{!hasMore && append}
|
||||
</div>
|
||||
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
|
@ -385,6 +390,8 @@ class ScrollableList extends PureComponent {
|
|||
<div className='empty-column-indicator'>
|
||||
{emptyMessage}
|
||||
</div>
|
||||
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -13,6 +13,13 @@ const mapStateToProps = state => ({
|
|||
openedViaKeyboard: state.dropdownMenu.keyboard,
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {any} dispatch
|
||||
* @param {Object} root0
|
||||
* @param {any} [root0.status]
|
||||
* @param {any} root0.items
|
||||
* @param {any} [root0.scrollKey]
|
||||
*/
|
||||
const mapDispatchToProps = (dispatch, { status, items, scrollKey }) => ({
|
||||
onOpen(id, onItemClick, keyboard) {
|
||||
dispatch(isUserTouching() ? openModal({
|
||||
|
|
|
@ -230,62 +230,6 @@ function loaded() {
|
|||
}
|
||||
},
|
||||
);
|
||||
|
||||
Rails.delegate(
|
||||
document,
|
||||
'button.status__content__spoiler-link',
|
||||
'click',
|
||||
function () {
|
||||
if (!(this instanceof HTMLButtonElement)) return;
|
||||
|
||||
const statusEl = this.parentNode?.parentNode;
|
||||
|
||||
if (
|
||||
!(
|
||||
statusEl instanceof HTMLDivElement &&
|
||||
statusEl.classList.contains('.status__content')
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
if (statusEl.dataset.spoiler === 'expanded') {
|
||||
statusEl.dataset.spoiler = 'folded';
|
||||
this.textContent = new IntlMessageFormat(
|
||||
localeData['status.show_more'] ?? 'Show more',
|
||||
locale,
|
||||
).format() as string;
|
||||
} else {
|
||||
statusEl.dataset.spoiler = 'expanded';
|
||||
this.textContent = new IntlMessageFormat(
|
||||
localeData['status.show_less'] ?? 'Show less',
|
||||
locale,
|
||||
).format() as string;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
document
|
||||
.querySelectorAll<HTMLButtonElement>('button.status__content__spoiler-link')
|
||||
.forEach((spoilerLink) => {
|
||||
const statusEl = spoilerLink.parentNode?.parentNode;
|
||||
|
||||
if (
|
||||
!(
|
||||
statusEl instanceof HTMLDivElement &&
|
||||
statusEl.classList.contains('.status__content')
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
const message =
|
||||
statusEl.dataset.spoiler === 'expanded'
|
||||
? (localeData['status.show_less'] ?? 'Show less')
|
||||
: (localeData['status.show_more'] ?? 'Show more');
|
||||
spoilerLink.textContent = new IntlMessageFormat(
|
||||
message,
|
||||
locale,
|
||||
).format() as string;
|
||||
});
|
||||
}
|
||||
|
||||
Rails.delegate(
|
||||
|
@ -439,6 +383,24 @@ Rails.delegate(document, '#registration_new_user,#new_user', 'submit', () => {
|
|||
});
|
||||
});
|
||||
|
||||
Rails.delegate(document, '.rules-list button', 'click', ({ target }) => {
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const button = target.closest('button');
|
||||
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (button.ariaExpanded === 'true') {
|
||||
button.ariaExpanded = 'false';
|
||||
} else {
|
||||
button.ariaExpanded = 'true';
|
||||
}
|
||||
});
|
||||
|
||||
function main() {
|
||||
ready(loaded).catch((error: unknown) => {
|
||||
console.error(error);
|
||||
|
|
|
@ -45,7 +45,7 @@ class Links extends PureComponent {
|
|||
|
||||
const banner = (
|
||||
<DismissableBanner id='explore/links'>
|
||||
<FormattedMessage id='dismissable_banner.explore_links' defaultMessage='These are news stories being shared the most on the social web today. Newer news stories posted by more different people are ranked higher.' />
|
||||
<FormattedMessage id='dismissable_banner.explore_links' defaultMessage='These news stories are being shared the most on the fediverse today. Newer news stories posted by more different people are ranked higher.' />
|
||||
</DismissableBanner>
|
||||
);
|
||||
|
||||
|
|
|
@ -58,7 +58,7 @@ class Statuses extends PureComponent {
|
|||
return (
|
||||
<StatusList
|
||||
trackScroll
|
||||
prepend={<DismissableBanner id='explore/statuses'><FormattedMessage id='dismissable_banner.explore_statuses' defaultMessage='These are posts from across the social web that are gaining traction today. Newer posts with more boosts and favorites are ranked higher.' /></DismissableBanner>}
|
||||
prepend={<DismissableBanner id='explore/statuses'><FormattedMessage id='dismissable_banner.explore_statuses' defaultMessage='These posts from across the fediverse are gaining traction today. Newer posts with more boosts and favorites are ranked higher.' /></DismissableBanner>}
|
||||
alwaysPrepend
|
||||
timelineId='explore'
|
||||
statusIds={statusIds}
|
||||
|
|
|
@ -5,25 +5,24 @@ import { FormattedMessage } from 'react-intl';
|
|||
|
||||
import { withRouter } from 'react-router-dom';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { fetchSuggestions, dismissSuggestion } from 'flavours/glitch/actions/suggestions';
|
||||
import { fetchSuggestions } from 'flavours/glitch/actions/suggestions';
|
||||
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
||||
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
|
||||
|
||||
import { Card } from './components/card';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
suggestions: state.getIn(['suggestions', 'items']),
|
||||
isLoading: state.getIn(['suggestions', 'isLoading']),
|
||||
suggestions: state.suggestions.items,
|
||||
isLoading: state.suggestions.isLoading,
|
||||
});
|
||||
|
||||
class Suggestions extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
isLoading: PropTypes.bool,
|
||||
suggestions: ImmutablePropTypes.list,
|
||||
suggestions: PropTypes.array,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
...WithRouterPropTypes,
|
||||
};
|
||||
|
@ -32,22 +31,17 @@ class Suggestions extends PureComponent {
|
|||
const { dispatch, suggestions, history } = this.props;
|
||||
|
||||
// If we're navigating back to the screen, do not trigger a reload
|
||||
if (history.action === 'POP' && suggestions.size > 0) {
|
||||
if (history.action === 'POP' && suggestions.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(fetchSuggestions(true));
|
||||
dispatch(fetchSuggestions());
|
||||
}
|
||||
|
||||
handleDismiss = (accountId) => {
|
||||
const { dispatch } = this.props;
|
||||
dispatch(dismissSuggestion(accountId));
|
||||
};
|
||||
|
||||
render () {
|
||||
const { isLoading, suggestions } = this.props;
|
||||
|
||||
if (!isLoading && suggestions.isEmpty()) {
|
||||
if (!isLoading && suggestions.length === 0) {
|
||||
return (
|
||||
<div className='explore__suggestions scrollable scrollable--flex'>
|
||||
<div className='empty-column-indicator'>
|
||||
|
@ -61,9 +55,9 @@ class Suggestions extends PureComponent {
|
|||
<div className='explore__suggestions scrollable' data-nosnippet>
|
||||
{isLoading ? <LoadingIndicator /> : suggestions.map(suggestion => (
|
||||
<Card
|
||||
key={suggestion.get('account')}
|
||||
id={suggestion.get('account')}
|
||||
source={suggestion.getIn(['sources', 0])}
|
||||
key={suggestion.account_id}
|
||||
id={suggestion.account_id}
|
||||
source={suggestion.sources[0]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
@ -44,7 +44,7 @@ class Tags extends PureComponent {
|
|||
|
||||
const banner = (
|
||||
<DismissableBanner id='explore/tags'>
|
||||
<FormattedMessage id='dismissable_banner.explore_tags' defaultMessage='These are hashtags that are gaining traction on the social web today. Hashtags that are used by more different people are ranked higher.' />
|
||||
<FormattedMessage id='dismissable_banner.explore_tags' defaultMessage='These hashtags are gaining traction on the fediverse today. Hashtags that are used by more different people are ranked higher.' />
|
||||
</DismissableBanner>
|
||||
);
|
||||
|
||||
|
|
|
@ -159,7 +159,7 @@ const Firehose = ({ feedType, multiColumn }) => {
|
|||
<DismissableBanner id='public_timeline'>
|
||||
<FormattedMessage
|
||||
id='dismissable_banner.public_timeline'
|
||||
defaultMessage='These are the most recent public posts from people on the social web that people on {domain} follow.'
|
||||
defaultMessage='These are the most recent public posts from people on the fediverse that people on {domain} follow.'
|
||||
values={{ domain }}
|
||||
/>
|
||||
</DismissableBanner>
|
||||
|
|
|
@ -8,11 +8,9 @@ import { connect } from 'react-redux';
|
|||
import BlockIcon from '@/material-icons/400-24px/block.svg?react';
|
||||
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
import PersonCheckIcon from '@/material-icons/400-24px/person_check.svg?react';
|
||||
import PushPinIcon from '@/material-icons/400-24px/push_pin.svg?react';
|
||||
import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
|
||||
import VolumeOffIcon from '@/material-icons/400-24px/volume_off.svg?react';
|
||||
import { openModal } from 'flavours/glitch/actions/modal';
|
||||
import Column from 'flavours/glitch/features/ui/components/column';
|
||||
import ColumnLink from 'flavours/glitch/features/ui/components/column_link';
|
||||
import ColumnSubheading from 'flavours/glitch/features/ui/components/column_subheading';
|
||||
|
@ -27,7 +25,6 @@ const messages = defineMessages({
|
|||
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
|
||||
pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' },
|
||||
keyboard_shortcuts: { id: 'navigation_bar.keyboard_shortcuts', defaultMessage: 'Keyboard shortcuts' },
|
||||
featured_users: { id: 'navigation_bar.featured_users', defaultMessage: 'Featured users' },
|
||||
});
|
||||
|
||||
class GettingStartedMisc extends ImmutablePureComponent {
|
||||
|
@ -38,12 +35,6 @@ class GettingStartedMisc extends ImmutablePureComponent {
|
|||
dispatch: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
openFeaturedAccountsModal = () => {
|
||||
this.props.dispatch(openModal({
|
||||
modalType: 'PINNED_ACCOUNTS_EDITOR',
|
||||
}));
|
||||
};
|
||||
|
||||
render () {
|
||||
const { intl } = this.props;
|
||||
const { signedIn } = this.props.identity;
|
||||
|
@ -54,7 +45,6 @@ class GettingStartedMisc extends ImmutablePureComponent {
|
|||
<ColumnSubheading text={intl.formatMessage(messages.subheading)} />
|
||||
{signedIn && (<ColumnLink key='favourites' icon='star' iconComponent={StarIcon} text={intl.formatMessage(messages.favourites)} to='/favourites' />)}
|
||||
{signedIn && (<ColumnLink key='pinned' icon='thumb-tack' iconComponent={PushPinIcon} text={intl.formatMessage(messages.pins)} to='/pinned' />)}
|
||||
{signedIn && (<ColumnLink key='featured_users' icon='users' iconComponent={PersonCheckIcon} text={intl.formatMessage(messages.featured_users)} onClick={this.openFeaturedAccountsModal} />)}
|
||||
{signedIn && (<ColumnLink key='mutes' icon='volume-off' iconComponent={VolumeOffIcon} text={intl.formatMessage(messages.mutes)} to='/mutes' />)}
|
||||
{signedIn && (<ColumnLink key='blocks' icon='ban' iconComponent={BlockIcon} text={intl.formatMessage(messages.blocks)} to='/blocks' />)}
|
||||
{signedIn && (<ColumnLink key='domain_blocks' icon='minus-circle' iconComponent={BlockIcon} text={intl.formatMessage(messages.domain_blocks)} to='/domain_blocks' />)}
|
||||
|
|
|
@ -4,12 +4,17 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
|||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
import { Button } from 'flavours/glitch/components/button';
|
||||
import { ShortNumber } from 'flavours/glitch/components/short_number';
|
||||
import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
|
||||
import { withIdentity } from 'flavours/glitch/identity_context';
|
||||
import { PERMISSION_MANAGE_TAXONOMIES } from 'flavours/glitch/permissions';
|
||||
|
||||
const messages = defineMessages({
|
||||
followHashtag: { id: 'hashtag.follow', defaultMessage: 'Follow hashtag' },
|
||||
unfollowHashtag: { id: 'hashtag.unfollow', defaultMessage: 'Unfollow hashtag' },
|
||||
adminModeration: { id: 'hashtag.admin_moderation', defaultMessage: 'Open moderation interface for #{name}' },
|
||||
});
|
||||
|
||||
const usesRenderer = (displayNumber, pluralReady) => (
|
||||
|
@ -45,11 +50,18 @@ const usesTodayRenderer = (displayNumber, pluralReady) => (
|
|||
/>
|
||||
);
|
||||
|
||||
export const HashtagHeader = injectIntl(({ tag, intl, disabled, onClick }) => {
|
||||
export const HashtagHeader = withIdentity(injectIntl(({ tag, intl, disabled, onClick, identity }) => {
|
||||
if (!tag) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { signedIn, permissions } = identity;
|
||||
const menu = [];
|
||||
|
||||
if (signedIn && (permissions & PERMISSION_MANAGE_TAXONOMIES) === PERMISSION_MANAGE_TAXONOMIES ) {
|
||||
menu.push({ text: intl.formatMessage(messages.adminModeration, { name: tag.get("name") }), href: `/admin/tags/${tag.get('id')}` });
|
||||
}
|
||||
|
||||
const [uses, people] = tag.get('history').reduce((arr, day) => [arr[0] + day.get('uses') * 1, arr[1] + day.get('accounts') * 1], [0, 0]);
|
||||
const dividingCircle = <span aria-hidden>{' · '}</span>;
|
||||
|
||||
|
@ -57,8 +69,11 @@ export const HashtagHeader = injectIntl(({ tag, intl, disabled, onClick }) => {
|
|||
<div className='hashtag-header'>
|
||||
<div className='hashtag-header__header'>
|
||||
<h1>#{tag.get('name')}</h1>
|
||||
<div className='hashtag-header__header__buttons'>
|
||||
{ menu.length > 0 && <DropdownMenuContainer disabled={menu.length === 0} items={menu} icon='ellipsis-v' iconComponent={MoreHorizIcon} size={24} direction='right' /> }
|
||||
<Button onClick={onClick} text={intl.formatMessage(tag.get('following') ? messages.unfollowHashtag : messages.followHashtag)} disabled={disabled} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<ShortNumber value={uses} renderer={usesRenderer} />
|
||||
|
@ -69,7 +84,7 @@ export const HashtagHeader = injectIntl(({ tag, intl, disabled, onClick }) => {
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}));
|
||||
|
||||
HashtagHeader.propTypes = {
|
||||
tag: ImmutablePropTypes.map,
|
||||
|
|
|
@ -1,217 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { useEffect, useCallback, useRef, useState } from 'react';
|
||||
|
||||
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
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 { changeSetting } from 'flavours/glitch/actions/settings';
|
||||
import { fetchSuggestions, dismissSuggestion } from 'flavours/glitch/actions/suggestions';
|
||||
import { Avatar } from 'flavours/glitch/components/avatar';
|
||||
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';
|
||||
import { domain } from 'flavours/glitch/initial_state';
|
||||
|
||||
const messages = defineMessages({
|
||||
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
||||
previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
|
||||
next: { id: 'lightbox.next', defaultMessage: 'Next' },
|
||||
dismiss: { id: 'follow_suggestions.dismiss', defaultMessage: "Don't show again" },
|
||||
friendsOfFriendsHint: { id: 'follow_suggestions.hints.friends_of_friends', defaultMessage: 'This profile is popular among the people you follow.' },
|
||||
similarToRecentlyFollowedHint: { id: 'follow_suggestions.hints.similar_to_recently_followed', defaultMessage: 'This profile is similar to the profiles you have most recently followed.' },
|
||||
featuredHint: { id: 'follow_suggestions.hints.featured', defaultMessage: 'This profile has been hand-picked by the {domain} team.' },
|
||||
mostFollowedHint: { id: 'follow_suggestions.hints.most_followed', defaultMessage: 'This profile is one of the most followed on {domain}.'},
|
||||
mostInteractionsHint: { id: 'follow_suggestions.hints.most_interactions', defaultMessage: 'This profile has been recently getting a lot of attention on {domain}.' },
|
||||
});
|
||||
|
||||
const Source = ({ id }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
let label, hint;
|
||||
|
||||
switch (id) {
|
||||
case 'friends_of_friends':
|
||||
hint = intl.formatMessage(messages.friendsOfFriendsHint);
|
||||
label = <FormattedMessage id='follow_suggestions.personalized_suggestion' defaultMessage='Personalized suggestion' />;
|
||||
break;
|
||||
case 'similar_to_recently_followed':
|
||||
hint = intl.formatMessage(messages.similarToRecentlyFollowedHint);
|
||||
label = <FormattedMessage id='follow_suggestions.personalized_suggestion' defaultMessage='Personalized suggestion' />;
|
||||
break;
|
||||
case 'featured':
|
||||
hint = intl.formatMessage(messages.featuredHint, { domain });
|
||||
label = <FormattedMessage id='follow_suggestions.curated_suggestion' defaultMessage='Staff pick' />;
|
||||
break;
|
||||
case 'most_followed':
|
||||
hint = intl.formatMessage(messages.mostFollowedHint, { domain });
|
||||
label = <FormattedMessage id='follow_suggestions.popular_suggestion' defaultMessage='Popular suggestion' />;
|
||||
break;
|
||||
case 'most_interactions':
|
||||
hint = intl.formatMessage(messages.mostInteractionsHint, { domain });
|
||||
label = <FormattedMessage id='follow_suggestions.popular_suggestion' defaultMessage='Popular suggestion' />;
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='inline-follow-suggestions__body__scrollable__card__text-stack__source' title={hint}>
|
||||
<Icon icon={InfoIcon} />
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Source.propTypes = {
|
||||
id: PropTypes.oneOf(['friends_of_friends', 'similar_to_recently_followed', 'featured', 'most_followed', 'most_interactions']),
|
||||
};
|
||||
|
||||
const Card = ({ id, sources }) => {
|
||||
const intl = useIntl();
|
||||
const account = useSelector(state => state.getIn(['accounts', id]));
|
||||
const firstVerifiedField = account.get('fields').find(item => !!item.get('verified_at'));
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
dispatch(dismissSuggestion(id));
|
||||
}, [id, dispatch]);
|
||||
|
||||
return (
|
||||
<div className='inline-follow-suggestions__body__scrollable__card'>
|
||||
<IconButton iconComponent={CloseIcon} onClick={handleDismiss} title={intl.formatMessage(messages.dismiss)} />
|
||||
|
||||
<div className='inline-follow-suggestions__body__scrollable__card__avatar'>
|
||||
<Link to={`/@${account.get('acct')}`}><Avatar account={account} size={72} /></Link>
|
||||
</div>
|
||||
|
||||
<div className='inline-follow-suggestions__body__scrollable__card__text-stack'>
|
||||
<Link to={`/@${account.get('acct')}`}><DisplayName account={account} /></Link>
|
||||
{firstVerifiedField ? <VerifiedBadge link={firstVerifiedField.get('value')} /> : <Source id={sources.get(0)} />}
|
||||
</div>
|
||||
|
||||
<FollowButton accountId={id} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Card.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
sources: ImmutablePropTypes.list,
|
||||
};
|
||||
|
||||
const DISMISSIBLE_ID = 'home/follow-suggestions';
|
||||
|
||||
export const InlineFollowSuggestions = ({ hidden }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const suggestions = useSelector(state => state.getIn(['suggestions', 'items']));
|
||||
const isLoading = useSelector(state => state.getIn(['suggestions', 'isLoading']));
|
||||
const dismissed = useSelector(state => state.getIn(['settings', 'dismissed_banners', DISMISSIBLE_ID]));
|
||||
const bodyRef = useRef();
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchSuggestions());
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!bodyRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (getComputedStyle(bodyRef.current).direction === 'rtl') {
|
||||
setCanScrollLeft((bodyRef.current.clientWidth - bodyRef.current.scrollLeft) < bodyRef.current.scrollWidth);
|
||||
setCanScrollRight(bodyRef.current.scrollLeft < 0);
|
||||
} else {
|
||||
setCanScrollLeft(bodyRef.current.scrollLeft > 0);
|
||||
setCanScrollRight((bodyRef.current.scrollLeft + bodyRef.current.clientWidth) < bodyRef.current.scrollWidth);
|
||||
}
|
||||
}, [setCanScrollRight, setCanScrollLeft, bodyRef, suggestions]);
|
||||
|
||||
const handleLeftNav = useCallback(() => {
|
||||
bodyRef.current.scrollLeft -= 200;
|
||||
}, [bodyRef]);
|
||||
|
||||
const handleRightNav = useCallback(() => {
|
||||
bodyRef.current.scrollLeft += 200;
|
||||
}, [bodyRef]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!bodyRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (getComputedStyle(bodyRef.current).direction === 'rtl') {
|
||||
setCanScrollLeft((bodyRef.current.clientWidth - bodyRef.current.scrollLeft) < bodyRef.current.scrollWidth);
|
||||
setCanScrollRight(bodyRef.current.scrollLeft < 0);
|
||||
} else {
|
||||
setCanScrollLeft(bodyRef.current.scrollLeft > 0);
|
||||
setCanScrollRight((bodyRef.current.scrollLeft + bodyRef.current.clientWidth) < bodyRef.current.scrollWidth);
|
||||
}
|
||||
}, [setCanScrollRight, setCanScrollLeft, bodyRef]);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
dispatch(changeSetting(['dismissed_banners', DISMISSIBLE_ID], true));
|
||||
}, [dispatch]);
|
||||
|
||||
if (dismissed || (!isLoading && suggestions.isEmpty())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hidden) {
|
||||
return (
|
||||
<div className='inline-follow-suggestions' />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='inline-follow-suggestions'>
|
||||
<div className='inline-follow-suggestions__header'>
|
||||
<h3><FormattedMessage id='follow_suggestions.who_to_follow' defaultMessage='Who to follow' /></h3>
|
||||
|
||||
<div className='inline-follow-suggestions__header__actions'>
|
||||
<button className='link-button' onClick={handleDismiss}><FormattedMessage id='follow_suggestions.dismiss' defaultMessage="Don't show again" /></button>
|
||||
<Link to='/explore/suggestions' className='link-button'><FormattedMessage id='follow_suggestions.view_all' defaultMessage='View all' /></Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='inline-follow-suggestions__body'>
|
||||
<div className='inline-follow-suggestions__body__scrollable' ref={bodyRef} onScroll={handleScroll}>
|
||||
{suggestions.map(suggestion => (
|
||||
<Card
|
||||
key={suggestion.get('account')}
|
||||
id={suggestion.get('account')}
|
||||
sources={suggestion.get('sources')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{canScrollLeft && (
|
||||
<button className='inline-follow-suggestions__body__scroll-button left' onClick={handleLeftNav} aria-label={intl.formatMessage(messages.previous)}>
|
||||
<div className='inline-follow-suggestions__body__scroll-button__icon'><Icon icon={ChevronLeftIcon} /></div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{canScrollRight && (
|
||||
<button className='inline-follow-suggestions__body__scroll-button right' onClick={handleRightNav} aria-label={intl.formatMessage(messages.next)}>
|
||||
<div className='inline-follow-suggestions__body__scroll-button__icon'><Icon icon={ChevronRightIcon} /></div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
InlineFollowSuggestions.propTypes = {
|
||||
hidden: PropTypes.bool,
|
||||
};
|
|
@ -0,0 +1,326 @@
|
|||
import { useEffect, useCallback, useRef, useState } from 'react';
|
||||
|
||||
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
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 { changeSetting } from 'flavours/glitch/actions/settings';
|
||||
import {
|
||||
fetchSuggestions,
|
||||
dismissSuggestion,
|
||||
} from 'flavours/glitch/actions/suggestions';
|
||||
import type { ApiSuggestionSourceJSON } from 'flavours/glitch/api_types/suggestions';
|
||||
import { Avatar } from 'flavours/glitch/components/avatar';
|
||||
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';
|
||||
import { domain } from 'flavours/glitch/initial_state';
|
||||
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
||||
previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
|
||||
next: { id: 'lightbox.next', defaultMessage: 'Next' },
|
||||
dismiss: {
|
||||
id: 'follow_suggestions.dismiss',
|
||||
defaultMessage: "Don't show again",
|
||||
},
|
||||
friendsOfFriendsHint: {
|
||||
id: 'follow_suggestions.hints.friends_of_friends',
|
||||
defaultMessage: 'This profile is popular among the people you follow.',
|
||||
},
|
||||
similarToRecentlyFollowedHint: {
|
||||
id: 'follow_suggestions.hints.similar_to_recently_followed',
|
||||
defaultMessage:
|
||||
'This profile is similar to the profiles you have most recently followed.',
|
||||
},
|
||||
featuredHint: {
|
||||
id: 'follow_suggestions.hints.featured',
|
||||
defaultMessage: 'This profile has been hand-picked by the {domain} team.',
|
||||
},
|
||||
mostFollowedHint: {
|
||||
id: 'follow_suggestions.hints.most_followed',
|
||||
defaultMessage: 'This profile is one of the most followed on {domain}.',
|
||||
},
|
||||
mostInteractionsHint: {
|
||||
id: 'follow_suggestions.hints.most_interactions',
|
||||
defaultMessage:
|
||||
'This profile has been recently getting a lot of attention on {domain}.',
|
||||
},
|
||||
});
|
||||
|
||||
const Source: React.FC<{
|
||||
id: ApiSuggestionSourceJSON;
|
||||
}> = ({ id }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
let label, hint;
|
||||
|
||||
switch (id) {
|
||||
case 'friends_of_friends':
|
||||
hint = intl.formatMessage(messages.friendsOfFriendsHint);
|
||||
label = (
|
||||
<FormattedMessage
|
||||
id='follow_suggestions.personalized_suggestion'
|
||||
defaultMessage='Personalized suggestion'
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'similar_to_recently_followed':
|
||||
hint = intl.formatMessage(messages.similarToRecentlyFollowedHint);
|
||||
label = (
|
||||
<FormattedMessage
|
||||
id='follow_suggestions.personalized_suggestion'
|
||||
defaultMessage='Personalized suggestion'
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'featured':
|
||||
hint = intl.formatMessage(messages.featuredHint, { domain });
|
||||
label = (
|
||||
<FormattedMessage
|
||||
id='follow_suggestions.curated_suggestion'
|
||||
defaultMessage='Staff pick'
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'most_followed':
|
||||
hint = intl.formatMessage(messages.mostFollowedHint, { domain });
|
||||
label = (
|
||||
<FormattedMessage
|
||||
id='follow_suggestions.popular_suggestion'
|
||||
defaultMessage='Popular suggestion'
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'most_interactions':
|
||||
hint = intl.formatMessage(messages.mostInteractionsHint, { domain });
|
||||
label = (
|
||||
<FormattedMessage
|
||||
id='follow_suggestions.popular_suggestion'
|
||||
defaultMessage='Popular suggestion'
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className='inline-follow-suggestions__body__scrollable__card__text-stack__source'
|
||||
title={hint}
|
||||
>
|
||||
<Icon id='' icon={InfoIcon} />
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Card: React.FC<{
|
||||
id: string;
|
||||
sources: [ApiSuggestionSourceJSON, ...ApiSuggestionSourceJSON[]];
|
||||
}> = ({ id, sources }) => {
|
||||
const intl = useIntl();
|
||||
const account = useAppSelector((state) => state.accounts.get(id));
|
||||
const firstVerifiedField = account?.fields.find((item) => !!item.verified_at);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
void dispatch(dismissSuggestion({ accountId: id }));
|
||||
}, [id, dispatch]);
|
||||
|
||||
return (
|
||||
<div className='inline-follow-suggestions__body__scrollable__card'>
|
||||
<IconButton
|
||||
icon=''
|
||||
iconComponent={CloseIcon}
|
||||
onClick={handleDismiss}
|
||||
title={intl.formatMessage(messages.dismiss)}
|
||||
/>
|
||||
|
||||
<div className='inline-follow-suggestions__body__scrollable__card__avatar'>
|
||||
<Link to={`/@${account?.acct}`}>
|
||||
<Avatar account={account} size={72} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className='inline-follow-suggestions__body__scrollable__card__text-stack'>
|
||||
<Link to={`/@${account?.acct}`}>
|
||||
<DisplayName account={account} />
|
||||
</Link>
|
||||
{firstVerifiedField ? (
|
||||
<VerifiedBadge link={firstVerifiedField.value} />
|
||||
) : (
|
||||
<Source id={sources[0]} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FollowButton accountId={id} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DISMISSIBLE_ID = 'home/follow-suggestions';
|
||||
|
||||
export const InlineFollowSuggestions: React.FC<{
|
||||
hidden?: boolean;
|
||||
}> = ({ hidden }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const suggestions = useAppSelector((state) => state.suggestions.items);
|
||||
const isLoading = useAppSelector((state) => state.suggestions.isLoading);
|
||||
const dismissed = useAppSelector(
|
||||
(state) =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
|
||||
state.settings.getIn(['dismissed_banners', DISMISSIBLE_ID]) as boolean,
|
||||
);
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
void dispatch(fetchSuggestions());
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!bodyRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (getComputedStyle(bodyRef.current).direction === 'rtl') {
|
||||
setCanScrollLeft(
|
||||
bodyRef.current.clientWidth - bodyRef.current.scrollLeft <
|
||||
bodyRef.current.scrollWidth,
|
||||
);
|
||||
setCanScrollRight(bodyRef.current.scrollLeft < 0);
|
||||
} else {
|
||||
setCanScrollLeft(bodyRef.current.scrollLeft > 0);
|
||||
setCanScrollRight(
|
||||
bodyRef.current.scrollLeft + bodyRef.current.clientWidth <
|
||||
bodyRef.current.scrollWidth,
|
||||
);
|
||||
}
|
||||
}, [setCanScrollRight, setCanScrollLeft, suggestions]);
|
||||
|
||||
const handleLeftNav = useCallback(() => {
|
||||
if (!bodyRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
bodyRef.current.scrollLeft -= 200;
|
||||
}, []);
|
||||
|
||||
const handleRightNav = useCallback(() => {
|
||||
if (!bodyRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
bodyRef.current.scrollLeft += 200;
|
||||
}, []);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!bodyRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (getComputedStyle(bodyRef.current).direction === 'rtl') {
|
||||
setCanScrollLeft(
|
||||
bodyRef.current.clientWidth - bodyRef.current.scrollLeft <
|
||||
bodyRef.current.scrollWidth,
|
||||
);
|
||||
setCanScrollRight(bodyRef.current.scrollLeft < 0);
|
||||
} else {
|
||||
setCanScrollLeft(bodyRef.current.scrollLeft > 0);
|
||||
setCanScrollRight(
|
||||
bodyRef.current.scrollLeft + bodyRef.current.clientWidth <
|
||||
bodyRef.current.scrollWidth,
|
||||
);
|
||||
}
|
||||
}, [setCanScrollRight, setCanScrollLeft]);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
dispatch(changeSetting(['dismissed_banners', DISMISSIBLE_ID], true));
|
||||
}, [dispatch]);
|
||||
|
||||
if (dismissed || (!isLoading && suggestions.length === 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hidden) {
|
||||
return <div className='inline-follow-suggestions' />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='inline-follow-suggestions'>
|
||||
<div className='inline-follow-suggestions__header'>
|
||||
<h3>
|
||||
<FormattedMessage
|
||||
id='follow_suggestions.who_to_follow'
|
||||
defaultMessage='Who to follow'
|
||||
/>
|
||||
</h3>
|
||||
|
||||
<div className='inline-follow-suggestions__header__actions'>
|
||||
<button className='link-button' onClick={handleDismiss}>
|
||||
<FormattedMessage
|
||||
id='follow_suggestions.dismiss'
|
||||
defaultMessage="Don't show again"
|
||||
/>
|
||||
</button>
|
||||
<Link to='/explore/suggestions' className='link-button'>
|
||||
<FormattedMessage
|
||||
id='follow_suggestions.view_all'
|
||||
defaultMessage='View all'
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='inline-follow-suggestions__body'>
|
||||
<div
|
||||
className='inline-follow-suggestions__body__scrollable'
|
||||
ref={bodyRef}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{suggestions.map((suggestion) => (
|
||||
<Card
|
||||
key={suggestion.account_id}
|
||||
id={suggestion.account_id}
|
||||
sources={suggestion.sources}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{canScrollLeft && (
|
||||
<button
|
||||
className='inline-follow-suggestions__body__scroll-button left'
|
||||
onClick={handleLeftNav}
|
||||
aria-label={intl.formatMessage(messages.previous)}
|
||||
>
|
||||
<div className='inline-follow-suggestions__body__scroll-button__icon'>
|
||||
<Icon id='' icon={ChevronLeftIcon} />
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{canScrollRight && (
|
||||
<button
|
||||
className='inline-follow-suggestions__body__scroll-button right'
|
||||
onClick={handleRightNav}
|
||||
aria-label={intl.formatMessage(messages.next)}
|
||||
>
|
||||
<div className='inline-follow-suggestions__body__scroll-button__icon'>
|
||||
<Icon id='' icon={ChevronRightIcon} />
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -1,43 +0,0 @@
|
|||
import { injectIntl } from 'react-intl';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { Avatar } from '../../../components/avatar';
|
||||
import { DisplayName } from '../../../components/display_name';
|
||||
import { makeGetAccount } from '../../../selectors';
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const getAccount = makeGetAccount();
|
||||
|
||||
const mapStateToProps = (state, { accountId }) => ({
|
||||
account: getAccount(state, accountId),
|
||||
});
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
class Account extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.record.isRequired,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { account } = this.props;
|
||||
return (
|
||||
<div className='account'>
|
||||
<div className='account__wrapper'>
|
||||
<div className='account__display-name'>
|
||||
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
|
||||
<DisplayName account={account} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(makeMapStateToProps)(injectIntl(Account));
|
|
@ -1,75 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import AddIcon from '@/material-icons/400-24px/add.svg?react';
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
|
||||
import { removeFromListAdder, addToListAdder } from '../../../actions/lists';
|
||||
import { IconButton } from '../../../components/icon_button';
|
||||
|
||||
const messages = defineMessages({
|
||||
remove: { id: 'lists.account.remove', defaultMessage: 'Remove from list' },
|
||||
add: { id: 'lists.account.add', defaultMessage: 'Add to list' },
|
||||
});
|
||||
|
||||
const MapStateToProps = (state, { listId, added }) => ({
|
||||
list: state.get('lists').get(listId),
|
||||
added: typeof added === 'undefined' ? state.getIn(['listAdder', 'lists', 'items']).includes(listId) : added,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch, { listId }) => ({
|
||||
onRemove: () => dispatch(removeFromListAdder(listId)),
|
||||
onAdd: () => dispatch(addToListAdder(listId)),
|
||||
});
|
||||
|
||||
class List extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
list: ImmutablePropTypes.map.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
onRemove: PropTypes.func.isRequired,
|
||||
onAdd: PropTypes.func.isRequired,
|
||||
added: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
added: false,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { list, intl, onRemove, onAdd, added } = this.props;
|
||||
|
||||
let button;
|
||||
|
||||
if (added) {
|
||||
button = <IconButton icon='times' iconComponent={CloseIcon} title={intl.formatMessage(messages.remove)} onClick={onRemove} />;
|
||||
} else {
|
||||
button = <IconButton icon='plus' iconComponent={AddIcon} title={intl.formatMessage(messages.add)} onClick={onAdd} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='list'>
|
||||
<div className='list__wrapper'>
|
||||
<div className='list__display-name'>
|
||||
<Icon id='list-ul' icon={ListAltIcon} className='column-link__icon' />
|
||||
{list.get('title')}
|
||||
</div>
|
||||
|
||||
<div className='account__relationship'>
|
||||
{button}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(MapStateToProps, mapDispatchToProps)(injectIntl(List));
|
|
@ -1,76 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
import { injectIntl } from 'react-intl';
|
||||
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { setupListAdder, resetListAdder } from '../../actions/lists';
|
||||
import NewListForm from '../lists/components/new_list_form';
|
||||
|
||||
import Account from './components/account';
|
||||
import List from './components/list';
|
||||
// hack
|
||||
|
||||
const getOrderedLists = createSelector([state => state.get('lists')], lists => {
|
||||
if (!lists) {
|
||||
return lists;
|
||||
}
|
||||
|
||||
return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title')));
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
listIds: getOrderedLists(state).map(list=>list.get('id')),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onInitialize: accountId => dispatch(setupListAdder(accountId)),
|
||||
onReset: () => dispatch(resetListAdder()),
|
||||
});
|
||||
|
||||
class ListAdder extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
accountId: PropTypes.string.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
onInitialize: PropTypes.func.isRequired,
|
||||
onReset: PropTypes.func.isRequired,
|
||||
listIds: ImmutablePropTypes.list.isRequired,
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
const { onInitialize, accountId } = this.props;
|
||||
onInitialize(accountId);
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
const { onReset } = this.props;
|
||||
onReset();
|
||||
}
|
||||
|
||||
render () {
|
||||
const { accountId, listIds } = this.props;
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal list-adder'>
|
||||
<div className='list-adder__account'>
|
||||
<Account accountId={accountId} />
|
||||
</div>
|
||||
|
||||
<NewListForm />
|
||||
|
||||
|
||||
<div className='list-adder__lists'>
|
||||
{listIds.map(ListId => <List key={ListId} listId={ListId} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(ListAdder));
|
213
app/javascript/flavours/glitch/features/list_adder/index.tsx
Normal file
213
app/javascript/flavours/glitch/features/list_adder/index.tsx
Normal file
|
@ -0,0 +1,213 @@
|
|||
import { useEffect, useState, useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import { isFulfilled } from '@reduxjs/toolkit';
|
||||
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
|
||||
import { fetchLists } from 'flavours/glitch/actions/lists';
|
||||
import { createList } from 'flavours/glitch/actions/lists_typed';
|
||||
import {
|
||||
apiGetAccountLists,
|
||||
apiAddAccountToList,
|
||||
apiRemoveAccountFromList,
|
||||
} from 'flavours/glitch/api/lists';
|
||||
import type { ApiListJSON } from 'flavours/glitch/api_types/lists';
|
||||
import { Button } from 'flavours/glitch/components/button';
|
||||
import { CheckBox } from 'flavours/glitch/components/check_box';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import { IconButton } from 'flavours/glitch/components/icon_button';
|
||||
import { getOrderedLists } from 'flavours/glitch/selectors/lists';
|
||||
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
newList: {
|
||||
id: 'lists.new_list_name',
|
||||
defaultMessage: 'New list name',
|
||||
},
|
||||
createList: {
|
||||
id: 'lists.create',
|
||||
defaultMessage: 'Create',
|
||||
},
|
||||
close: {
|
||||
id: 'lightbox.close',
|
||||
defaultMessage: 'Close',
|
||||
},
|
||||
});
|
||||
|
||||
const ListItem: React.FC<{
|
||||
id: string;
|
||||
title: string;
|
||||
checked: boolean;
|
||||
onChange: (id: string, checked: boolean) => void;
|
||||
}> = ({ id, title, checked, onChange }) => {
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(id, e.target.checked);
|
||||
},
|
||||
[id, onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/label-has-associated-control
|
||||
<label className='lists__item'>
|
||||
<div className='lists__item__title'>
|
||||
<Icon id='list-ul' icon={ListAltIcon} />
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
|
||||
<CheckBox value={id} checked={checked} onChange={handleChange} />
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
const NewListItem: React.FC<{
|
||||
onCreate: (list: ApiListJSON) => void;
|
||||
}> = ({ onCreate }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const [title, setTitle] = useState('');
|
||||
|
||||
const handleChange = useCallback(
|
||||
({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setTitle(value);
|
||||
},
|
||||
[setTitle],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (title.trim().length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
void dispatch(createList({ title })).then((result) => {
|
||||
if (isFulfilled(result)) {
|
||||
onCreate(result.payload);
|
||||
setTitle('');
|
||||
}
|
||||
|
||||
return '';
|
||||
});
|
||||
}, [setTitle, dispatch, onCreate, title]);
|
||||
|
||||
return (
|
||||
<form className='lists__item' onSubmit={handleSubmit}>
|
||||
<label className='lists__item__title'>
|
||||
<Icon id='list-ul' icon={ListAltIcon} />
|
||||
|
||||
<input
|
||||
type='text'
|
||||
value={title}
|
||||
onChange={handleChange}
|
||||
maxLength={30}
|
||||
required
|
||||
placeholder={intl.formatMessage(messages.newList)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<Button text={intl.formatMessage(messages.createList)} type='submit' />
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const ListAdder: React.FC<{
|
||||
accountId: string;
|
||||
onClose: () => void;
|
||||
}> = ({ accountId, onClose }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const account = useAppSelector((state) => state.accounts.get(accountId));
|
||||
const lists = useAppSelector((state) => getOrderedLists(state));
|
||||
const [listIds, setListIds] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchLists());
|
||||
|
||||
apiGetAccountLists(accountId)
|
||||
.then((data) => {
|
||||
setListIds(data.map((l) => l.id));
|
||||
return '';
|
||||
})
|
||||
.catch(() => {
|
||||
// Nothing
|
||||
});
|
||||
}, [dispatch, setListIds, accountId]);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(listId: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
setListIds((currentListIds) => [listId, ...currentListIds]);
|
||||
|
||||
apiAddAccountToList(listId, accountId).catch(() => {
|
||||
setListIds((currentListIds) =>
|
||||
currentListIds.filter((id) => id !== listId),
|
||||
);
|
||||
});
|
||||
} else {
|
||||
setListIds((currentListIds) =>
|
||||
currentListIds.filter((id) => id !== listId),
|
||||
);
|
||||
|
||||
apiRemoveAccountFromList(listId, accountId).catch(() => {
|
||||
setListIds((currentListIds) => [listId, ...currentListIds]);
|
||||
});
|
||||
}
|
||||
},
|
||||
[setListIds, accountId],
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(
|
||||
(list: ApiListJSON) => {
|
||||
setListIds((currentListIds) => [list.id, ...currentListIds]);
|
||||
|
||||
apiAddAccountToList(list.id, accountId).catch(() => {
|
||||
setListIds((currentListIds) =>
|
||||
currentListIds.filter((id) => id !== list.id),
|
||||
);
|
||||
});
|
||||
},
|
||||
[setListIds, accountId],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal dialog-modal'>
|
||||
<div className='dialog-modal__header'>
|
||||
<IconButton
|
||||
className='dialog-modal__header__close'
|
||||
title={intl.formatMessage(messages.close)}
|
||||
icon='times'
|
||||
iconComponent={CloseIcon}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
<span className='dialog-modal__header__title'>
|
||||
<FormattedMessage
|
||||
id='lists.add_to_lists'
|
||||
defaultMessage='Add {name} to lists'
|
||||
values={{ name: <strong>@{account?.acct}</strong> }}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='dialog-modal__content'>
|
||||
<div className='lists-scrollable'>
|
||||
<NewListItem onCreate={handleCreate} />
|
||||
|
||||
{lists.map((list) => (
|
||||
<ListItem
|
||||
key={list.id}
|
||||
id={list.id}
|
||||
title={list.title}
|
||||
checked={listIds.includes(list.id)}
|
||||
onChange={handleToggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default ListAdder;
|
|
@ -1,82 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import AddIcon from '@/material-icons/400-24px/add.svg?react';
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
|
||||
import { removeFromListEditor, addToListEditor } from '../../../actions/lists';
|
||||
import { Avatar } from '../../../components/avatar';
|
||||
import { DisplayName } from '../../../components/display_name';
|
||||
import { IconButton } from '../../../components/icon_button';
|
||||
import { makeGetAccount } from '../../../selectors';
|
||||
|
||||
const messages = defineMessages({
|
||||
remove: { id: 'lists.account.remove', defaultMessage: 'Remove from list' },
|
||||
add: { id: 'lists.account.add', defaultMessage: 'Add to list' },
|
||||
});
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const getAccount = makeGetAccount();
|
||||
|
||||
const mapStateToProps = (state, { accountId, added }) => ({
|
||||
account: getAccount(state, accountId),
|
||||
added: typeof added === 'undefined' ? state.getIn(['listEditor', 'accounts', 'items']).includes(accountId) : added,
|
||||
});
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
const mapDispatchToProps = (dispatch, { accountId }) => ({
|
||||
onRemove: () => dispatch(removeFromListEditor(accountId)),
|
||||
onAdd: () => dispatch(addToListEditor(accountId)),
|
||||
});
|
||||
|
||||
class Account extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.record.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
onRemove: PropTypes.func.isRequired,
|
||||
onAdd: PropTypes.func.isRequired,
|
||||
added: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
added: false,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { account, intl, onRemove, onAdd, added } = this.props;
|
||||
|
||||
let button;
|
||||
|
||||
if (added) {
|
||||
button = <IconButton icon='times' iconComponent={CloseIcon} title={intl.formatMessage(messages.remove)} onClick={onRemove} />;
|
||||
} else {
|
||||
button = <IconButton icon='plus' iconComponent={AddIcon} title={intl.formatMessage(messages.add)} onClick={onAdd} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='account'>
|
||||
<div className='account__wrapper'>
|
||||
<div className='account__display-name'>
|
||||
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
|
||||
<DisplayName account={account} />
|
||||
</div>
|
||||
|
||||
<div className='account__relationship'>
|
||||
{button}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(makeMapStateToProps, mapDispatchToProps)(injectIntl(Account));
|
|
@ -1,76 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||
|
||||
import { changeListEditorTitle, submitListEditor } from '../../../actions/lists';
|
||||
import { IconButton } from '../../../components/icon_button';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'lists.edit.submit', defaultMessage: 'Change title' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
value: state.getIn(['listEditor', 'title']),
|
||||
disabled: !state.getIn(['listEditor', 'isChanged']) || !state.getIn(['listEditor', 'title']),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onChange: value => dispatch(changeListEditorTitle(value)),
|
||||
onSubmit: () => dispatch(submitListEditor(false)),
|
||||
});
|
||||
|
||||
class ListForm extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
value: PropTypes.string.isRequired,
|
||||
disabled: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
handleChange = e => {
|
||||
this.props.onChange(e.target.value);
|
||||
};
|
||||
|
||||
handleSubmit = e => {
|
||||
e.preventDefault();
|
||||
this.props.onSubmit();
|
||||
};
|
||||
|
||||
handleClick = () => {
|
||||
this.props.onSubmit();
|
||||
};
|
||||
|
||||
render () {
|
||||
const { value, disabled, intl } = this.props;
|
||||
|
||||
const title = intl.formatMessage(messages.title);
|
||||
|
||||
return (
|
||||
<form className='column-inline-form' onSubmit={this.handleSubmit}>
|
||||
<input
|
||||
className='setting-text'
|
||||
value={value}
|
||||
onChange={this.handleChange}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
disabled={disabled}
|
||||
icon='check'
|
||||
iconComponent={CheckIcon}
|
||||
title={title}
|
||||
onClick={this.handleClick}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(ListForm));
|
|
@ -1,83 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import CancelIcon from '@/material-icons/400-24px/cancel.svg?react';
|
||||
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
|
||||
import { fetchListSuggestions, clearListSuggestions, changeListSuggestions } from '../../../actions/lists';
|
||||
|
||||
const messages = defineMessages({
|
||||
search: { id: 'lists.search', defaultMessage: 'Search among people you follow' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
value: state.getIn(['listEditor', 'suggestions', 'value']),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onSubmit: value => dispatch(fetchListSuggestions(value)),
|
||||
onClear: () => dispatch(clearListSuggestions()),
|
||||
onChange: value => dispatch(changeListSuggestions(value)),
|
||||
});
|
||||
|
||||
class Search extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
intl: PropTypes.object.isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
onClear: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
handleChange = e => {
|
||||
this.props.onChange(e.target.value);
|
||||
};
|
||||
|
||||
handleKeyUp = e => {
|
||||
if (e.keyCode === 13) {
|
||||
this.props.onSubmit(this.props.value);
|
||||
}
|
||||
};
|
||||
|
||||
handleClear = () => {
|
||||
this.props.onClear();
|
||||
};
|
||||
|
||||
render () {
|
||||
const { value, intl } = this.props;
|
||||
const hasValue = value.length > 0;
|
||||
|
||||
return (
|
||||
<div className='list-editor__search search'>
|
||||
<label>
|
||||
<span style={{ display: 'none' }}>{intl.formatMessage(messages.search)}</span>
|
||||
|
||||
<input
|
||||
className='search__input'
|
||||
type='text'
|
||||
value={value}
|
||||
onChange={this.handleChange}
|
||||
onKeyUp={this.handleKeyUp}
|
||||
placeholder={intl.formatMessage(messages.search)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div role='button' tabIndex={0} className='search__icon' onClick={this.handleClear}>
|
||||
<Icon id='search' icon={SearchIcon} className={classNames({ active: !hasValue })} />
|
||||
<Icon id='times-circle' icon={CancelIcon} aria-label={intl.formatMessage(messages.search)} className={classNames({ active: hasValue })} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(Search));
|
|
@ -1,83 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
import { injectIntl } from 'react-intl';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import spring from 'react-motion/lib/spring';
|
||||
|
||||
import { setupListEditor, clearListSuggestions, resetListEditor } from '../../actions/lists';
|
||||
import Motion from '../ui/util/optional_motion';
|
||||
|
||||
import Account from './components/account';
|
||||
import EditListForm from './components/edit_list_form';
|
||||
import Search from './components/search';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
accountIds: state.getIn(['listEditor', 'accounts', 'items']),
|
||||
searchAccountIds: state.getIn(['listEditor', 'suggestions', 'items']),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onInitialize: listId => dispatch(setupListEditor(listId)),
|
||||
onClear: () => dispatch(clearListSuggestions()),
|
||||
onReset: () => dispatch(resetListEditor()),
|
||||
});
|
||||
|
||||
class ListEditor extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
listId: PropTypes.string.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
onInitialize: PropTypes.func.isRequired,
|
||||
onClear: PropTypes.func.isRequired,
|
||||
onReset: PropTypes.func.isRequired,
|
||||
accountIds: ImmutablePropTypes.list.isRequired,
|
||||
searchAccountIds: ImmutablePropTypes.list.isRequired,
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
const { onInitialize, listId } = this.props;
|
||||
onInitialize(listId);
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
const { onReset } = this.props;
|
||||
onReset();
|
||||
}
|
||||
|
||||
render () {
|
||||
const { accountIds, searchAccountIds, onClear } = this.props;
|
||||
const showSearch = searchAccountIds.size > 0;
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal list-editor'>
|
||||
<EditListForm />
|
||||
|
||||
<Search />
|
||||
|
||||
<div className='drawer__pager'>
|
||||
<div className='drawer__inner list-editor__accounts'>
|
||||
{accountIds.map(accountId => <Account key={accountId} accountId={accountId} added />)}
|
||||
</div>
|
||||
|
||||
{showSearch && <div role='button' tabIndex={-1} className='drawer__backdrop' onClick={onClear} />}
|
||||
|
||||
<Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
|
||||
{({ x }) => (
|
||||
<div className='drawer__inner backdrop' style={{ transform: x === 0 ? null : `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
|
||||
{searchAccountIds.map(accountId => <Account key={accountId} accountId={accountId} />)}
|
||||
</div>
|
||||
)}
|
||||
</Motion>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(ListEditor));
|
|
@ -1,21 +1,19 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { Link, withRouter } from 'react-router-dom';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import Toggle from 'react-toggle';
|
||||
|
||||
import DeleteIcon from '@/material-icons/400-24px/delete.svg?react';
|
||||
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
|
||||
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
|
||||
import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
|
||||
import { fetchList, updateList } from 'flavours/glitch/actions/lists';
|
||||
import { fetchList } from 'flavours/glitch/actions/lists';
|
||||
import { openModal } from 'flavours/glitch/actions/modal';
|
||||
import { connectListStream } from 'flavours/glitch/actions/streaming';
|
||||
import { expandListTimeline } from 'flavours/glitch/actions/timelines';
|
||||
|
@ -23,17 +21,10 @@ import Column from 'flavours/glitch/components/column';
|
|||
import ColumnHeader from 'flavours/glitch/components/column_header';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
||||
import { RadioButton } from 'flavours/glitch/components/radio_button';
|
||||
import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error';
|
||||
import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
|
||||
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
|
||||
|
||||
const messages = defineMessages({
|
||||
followed: { id: 'lists.replies_policy.followed', defaultMessage: 'Any followed user' },
|
||||
none: { id: 'lists.replies_policy.none', defaultMessage: 'No one' },
|
||||
list: { id: 'lists.replies_policy.list', defaultMessage: 'Members of the list' },
|
||||
});
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
list: state.getIn(['lists', props.params.id]),
|
||||
hasUnread: state.getIn(['timelines', `list:${props.params.id}`, 'unread']) > 0,
|
||||
|
@ -115,13 +106,6 @@ class ListTimeline extends PureComponent {
|
|||
this.props.dispatch(expandListTimeline(id, { maxId }));
|
||||
};
|
||||
|
||||
handleEditClick = () => {
|
||||
this.props.dispatch(openModal({
|
||||
modalType: 'LIST_EDITOR',
|
||||
modalProps: { listId: this.props.params.id },
|
||||
}));
|
||||
};
|
||||
|
||||
handleDeleteClick = () => {
|
||||
const { dispatch, columnId } = this.props;
|
||||
const { id } = this.props.params;
|
||||
|
@ -129,25 +113,11 @@ class ListTimeline extends PureComponent {
|
|||
dispatch(openModal({ modalType: 'CONFIRM_DELETE_LIST', modalProps: { listId: id, columnId } }));
|
||||
};
|
||||
|
||||
handleRepliesPolicyChange = ({ target }) => {
|
||||
const { dispatch } = this.props;
|
||||
const { id } = this.props.params;
|
||||
dispatch(updateList(id, undefined, false, undefined, target.value));
|
||||
};
|
||||
|
||||
onExclusiveToggle = ({ target }) => {
|
||||
const { dispatch } = this.props;
|
||||
const { id } = this.props.params;
|
||||
dispatch(updateList(id, undefined, false, target.checked, undefined));
|
||||
};
|
||||
|
||||
render () {
|
||||
const { hasUnread, columnId, multiColumn, list, intl } = this.props;
|
||||
const { hasUnread, columnId, multiColumn, list } = this.props;
|
||||
const { id } = this.props.params;
|
||||
const pinned = !!columnId;
|
||||
const title = list ? list.get('title') : id;
|
||||
const replies_policy = list ? list.get('replies_policy') : undefined;
|
||||
const isExclusive = list ? list.get('exclusive') : undefined;
|
||||
|
||||
if (typeof list === 'undefined') {
|
||||
return (
|
||||
|
@ -178,35 +148,14 @@ class ListTimeline extends PureComponent {
|
|||
>
|
||||
<div className='column-settings'>
|
||||
<section className='column-header__links'>
|
||||
<button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleEditClick}>
|
||||
<Link to={`/lists/${id}/edit`} className='text-btn column-header__setting-btn'>
|
||||
<Icon id='pencil' icon={EditIcon} /> <FormattedMessage id='lists.edit' defaultMessage='Edit list' />
|
||||
</button>
|
||||
</Link>
|
||||
|
||||
<button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleDeleteClick}>
|
||||
<Icon id='trash' icon={DeleteIcon} /> <FormattedMessage id='lists.delete' defaultMessage='Delete list' />
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className='setting-toggle'>
|
||||
<Toggle id={`list-${id}-exclusive`} checked={isExclusive} onChange={this.onExclusiveToggle} />
|
||||
<label htmlFor={`list-${id}-exclusive`} className='setting-toggle__label'>
|
||||
<FormattedMessage id='lists.exclusive' defaultMessage='Hide these posts from home' />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{replies_policy !== undefined && (
|
||||
<section aria-labelledby={`list-${id}-replies-policy`}>
|
||||
<h3 id={`list-${id}-replies-policy`}><FormattedMessage id='lists.replies_policy.title' defaultMessage='Show replies to:' /></h3>
|
||||
|
||||
<div className='column-settings__row'>
|
||||
{ ['none', 'list', 'followed'].map(policy => (
|
||||
<RadioButton name='order' key={policy} value={policy} label={intl.formatMessage(messages[policy])} checked={replies_policy === policy} onChange={this.handleRepliesPolicyChange} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</ColumnHeader>
|
||||
|
||||
|
@ -229,4 +178,4 @@ class ListTimeline extends PureComponent {
|
|||
|
||||
}
|
||||
|
||||
export default withRouter(connect(mapStateToProps)(injectIntl(ListTimeline)));
|
||||
export default withRouter(connect(mapStateToProps)(ListTimeline));
|
||||
|
|
|
@ -1,80 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { changeListEditorTitle, submitListEditor } from 'flavours/glitch/actions/lists';
|
||||
import { Button } from 'flavours/glitch/components/button';
|
||||
|
||||
const messages = defineMessages({
|
||||
label: { id: 'lists.new.title_placeholder', defaultMessage: 'New list title' },
|
||||
title: { id: 'lists.new.create', defaultMessage: 'Add list' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
value: state.getIn(['listEditor', 'title']),
|
||||
disabled: state.getIn(['listEditor', 'isSubmitting']),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onChange: value => dispatch(changeListEditorTitle(value)),
|
||||
onSubmit: () => dispatch(submitListEditor(true)),
|
||||
});
|
||||
|
||||
class NewListForm extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
value: PropTypes.string.isRequired,
|
||||
disabled: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
handleChange = e => {
|
||||
this.props.onChange(e.target.value);
|
||||
};
|
||||
|
||||
handleSubmit = e => {
|
||||
e.preventDefault();
|
||||
this.props.onSubmit();
|
||||
};
|
||||
|
||||
handleClick = () => {
|
||||
this.props.onSubmit();
|
||||
};
|
||||
|
||||
render () {
|
||||
const { value, disabled, intl } = this.props;
|
||||
|
||||
const label = intl.formatMessage(messages.label);
|
||||
const title = intl.formatMessage(messages.title);
|
||||
|
||||
return (
|
||||
<form className='column-inline-form' onSubmit={this.handleSubmit}>
|
||||
<label>
|
||||
<span style={{ display: 'none' }}>{label}</span>
|
||||
|
||||
<input
|
||||
className='setting-text'
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={this.handleChange}
|
||||
placeholder={label}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<Button
|
||||
disabled={disabled || !value}
|
||||
text={title}
|
||||
onClick={this.handleClick}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(NewListForm));
|
|
@ -1,91 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
|
||||
import { fetchLists } from 'flavours/glitch/actions/lists';
|
||||
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
||||
import ScrollableList from 'flavours/glitch/components/scrollable_list';
|
||||
import Column from 'flavours/glitch/features/ui/components/column';
|
||||
import ColumnLink from 'flavours/glitch/features/ui/components/column_link';
|
||||
import ColumnSubheading from 'flavours/glitch/features/ui/components/column_subheading';
|
||||
|
||||
import NewListForm from './components/new_list_form';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.lists', defaultMessage: 'Lists' },
|
||||
subheading: { id: 'lists.subheading', defaultMessage: 'Your lists' },
|
||||
});
|
||||
|
||||
const getOrderedLists = createSelector([state => state.get('lists')], lists => {
|
||||
if (!lists) {
|
||||
return lists;
|
||||
}
|
||||
|
||||
return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title')));
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
lists: getOrderedLists(state),
|
||||
});
|
||||
|
||||
class Lists extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
params: PropTypes.object.isRequired,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
lists: ImmutablePropTypes.list,
|
||||
intl: PropTypes.object.isRequired,
|
||||
multiColumn: PropTypes.bool,
|
||||
};
|
||||
|
||||
UNSAFE_componentWillMount () {
|
||||
this.props.dispatch(fetchLists());
|
||||
}
|
||||
|
||||
render () {
|
||||
const { intl, lists, multiColumn } = this.props;
|
||||
|
||||
if (!lists) {
|
||||
return (
|
||||
<Column>
|
||||
<LoadingIndicator />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.lists' defaultMessage="You don't have any lists yet. When you create one, it will show up here." />;
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn} icon='bars' iconComponent={ListAltIcon} heading={intl.formatMessage(messages.heading)} alwaysShowBackButton>
|
||||
<NewListForm />
|
||||
|
||||
<ColumnSubheading text={intl.formatMessage(messages.subheading)} />
|
||||
<ScrollableList
|
||||
scrollKey='lists'
|
||||
emptyMessage={emptyMessage}
|
||||
bindToDocument={!multiColumn}
|
||||
>
|
||||
{lists.map(list =>
|
||||
<ColumnLink key={list.get('id')} to={`/lists/${list.get('id')}`} icon='list-ul' iconComponent={ListAltIcon} text={list.get('title')} />,
|
||||
)}
|
||||
</ScrollableList>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.heading)}</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(injectIntl(Lists));
|
145
app/javascript/flavours/glitch/features/lists/index.tsx
Normal file
145
app/javascript/flavours/glitch/features/lists/index.tsx
Normal file
|
@ -0,0 +1,145 @@
|
|||
import { useEffect, useMemo, useCallback } from 'react';
|
||||
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import AddIcon from '@/material-icons/400-24px/add.svg?react';
|
||||
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
|
||||
import { fetchLists } from 'flavours/glitch/actions/lists';
|
||||
import { openModal } from 'flavours/glitch/actions/modal';
|
||||
import Column from 'flavours/glitch/components/column';
|
||||
import { ColumnHeader } from 'flavours/glitch/components/column_header';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import ScrollableList from 'flavours/glitch/components/scrollable_list';
|
||||
import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
|
||||
import { getOrderedLists } from 'flavours/glitch/selectors/lists';
|
||||
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.lists', defaultMessage: 'Lists' },
|
||||
create: { id: 'lists.create_list', defaultMessage: 'Create list' },
|
||||
edit: { id: 'lists.edit', defaultMessage: 'Edit list' },
|
||||
delete: { id: 'lists.delete', defaultMessage: 'Delete list' },
|
||||
more: { id: 'status.more', defaultMessage: 'More' },
|
||||
});
|
||||
|
||||
const ListItem: React.FC<{
|
||||
id: string;
|
||||
title: string;
|
||||
}> = ({ id, title }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
const handleDeleteClick = useCallback(() => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'CONFIRM_DELETE_LIST',
|
||||
modalProps: {
|
||||
listId: id,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}, [dispatch, id]);
|
||||
|
||||
const menu = useMemo(
|
||||
() => [
|
||||
{ text: intl.formatMessage(messages.edit), to: `/lists/${id}/edit` },
|
||||
{ text: intl.formatMessage(messages.delete), action: handleDeleteClick },
|
||||
],
|
||||
[intl, id, handleDeleteClick],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='lists__item'>
|
||||
<Link to={`/lists/${id}`} className='lists__item__title'>
|
||||
<Icon id='list-ul' icon={ListAltIcon} />
|
||||
<span>{title}</span>
|
||||
</Link>
|
||||
|
||||
<DropdownMenuContainer
|
||||
scrollKey='lists'
|
||||
items={menu}
|
||||
icons='ellipsis-h'
|
||||
iconComponent={MoreHorizIcon}
|
||||
direction='right'
|
||||
title={intl.formatMessage(messages.more)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Lists: React.FC<{
|
||||
multiColumn?: boolean;
|
||||
}> = ({ multiColumn }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
const lists = useAppSelector((state) => getOrderedLists(state));
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchLists());
|
||||
}, [dispatch]);
|
||||
|
||||
const emptyMessage = (
|
||||
<>
|
||||
<span>
|
||||
<FormattedMessage
|
||||
id='lists.no_lists_yet'
|
||||
defaultMessage='No lists yet.'
|
||||
/>
|
||||
<br />
|
||||
<FormattedMessage
|
||||
id='lists.create_a_list_to_organize'
|
||||
defaultMessage='Create a new list to organize your Home feed'
|
||||
/>
|
||||
</span>
|
||||
|
||||
<SquigglyArrow className='empty-column-indicator__arrow' />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Column
|
||||
bindToDocument={!multiColumn}
|
||||
label={intl.formatMessage(messages.heading)}
|
||||
>
|
||||
<ColumnHeader
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
icon='list-ul'
|
||||
iconComponent={ListAltIcon}
|
||||
multiColumn={multiColumn}
|
||||
extraButton={
|
||||
<Link
|
||||
to='/lists/new'
|
||||
className='column-header__button'
|
||||
title={intl.formatMessage(messages.create)}
|
||||
aria-label={intl.formatMessage(messages.create)}
|
||||
>
|
||||
<Icon id='plus' icon={AddIcon} />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<ScrollableList
|
||||
scrollKey='lists'
|
||||
emptyMessage={emptyMessage}
|
||||
bindToDocument={!multiColumn}
|
||||
>
|
||||
{lists.map((list) => (
|
||||
<ListItem key={list.id} id={list.id} title={list.title} />
|
||||
))}
|
||||
</ScrollableList>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.heading)}</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default Lists;
|
304
app/javascript/flavours/glitch/features/lists/members.tsx
Normal file
304
app/javascript/flavours/glitch/features/lists/members.tsx
Normal file
|
@ -0,0 +1,304 @@
|
|||
import { useCallback, useState, useEffect, useRef } from 'react';
|
||||
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
|
||||
import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
|
||||
import { importFetchedAccounts } from 'flavours/glitch/actions/importer';
|
||||
import { fetchList } from 'flavours/glitch/actions/lists';
|
||||
import { apiRequest } from 'flavours/glitch/api';
|
||||
import {
|
||||
apiGetAccounts,
|
||||
apiAddAccountToList,
|
||||
apiRemoveAccountFromList,
|
||||
} from 'flavours/glitch/api/lists';
|
||||
import type { ApiAccountJSON } from 'flavours/glitch/api_types/accounts';
|
||||
import { Avatar } from 'flavours/glitch/components/avatar';
|
||||
import { Button } from 'flavours/glitch/components/button';
|
||||
import Column from 'flavours/glitch/components/column';
|
||||
import { ColumnHeader } from 'flavours/glitch/components/column_header';
|
||||
import { ColumnSearchHeader } from 'flavours/glitch/components/column_search_header';
|
||||
import { FollowersCounter } from 'flavours/glitch/components/counters';
|
||||
import { DisplayName } from 'flavours/glitch/components/display_name';
|
||||
import ScrollableList from 'flavours/glitch/components/scrollable_list';
|
||||
import { ShortNumber } from 'flavours/glitch/components/short_number';
|
||||
import { VerifiedBadge } from 'flavours/glitch/components/verified_badge';
|
||||
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.list_members', defaultMessage: 'Manage list members' },
|
||||
placeholder: {
|
||||
id: 'lists.search_placeholder',
|
||||
defaultMessage: 'Search people you follow',
|
||||
},
|
||||
enterSearch: { id: 'lists.add_to_list', defaultMessage: 'Add to list' },
|
||||
add: { id: 'lists.add_member', defaultMessage: 'Add' },
|
||||
remove: { id: 'lists.remove_member', defaultMessage: 'Remove' },
|
||||
back: { id: 'column_back_button.label', defaultMessage: 'Back' },
|
||||
});
|
||||
|
||||
type Mode = 'remove' | 'add';
|
||||
|
||||
const AccountItem: React.FC<{
|
||||
accountId: string;
|
||||
listId: string;
|
||||
partOfList: boolean;
|
||||
onToggle: (accountId: string) => void;
|
||||
}> = ({ accountId, listId, partOfList, onToggle }) => {
|
||||
const intl = useIntl();
|
||||
const account = useAppSelector((state) => state.accounts.get(accountId));
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (partOfList) {
|
||||
void apiRemoveAccountFromList(listId, accountId);
|
||||
} else {
|
||||
void apiAddAccountToList(listId, accountId);
|
||||
}
|
||||
|
||||
onToggle(accountId);
|
||||
}, [accountId, listId, partOfList, onToggle]);
|
||||
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstVerifiedField = account.fields.find((item) => !!item.verified_at);
|
||||
|
||||
return (
|
||||
<div className='account'>
|
||||
<div className='account__wrapper'>
|
||||
<Link
|
||||
key={account.id}
|
||||
className='account__display-name'
|
||||
title={account.acct}
|
||||
to={`/@${account.acct}`}
|
||||
data-hover-card-account={account.id}
|
||||
>
|
||||
<div className='account__avatar-wrapper'>
|
||||
<Avatar account={account} size={36} />
|
||||
</div>
|
||||
|
||||
<div className='account__contents'>
|
||||
<DisplayName account={account} />
|
||||
|
||||
<div className='account__details'>
|
||||
<ShortNumber
|
||||
value={account.followers_count}
|
||||
renderer={FollowersCounter}
|
||||
/>{' '}
|
||||
{firstVerifiedField && (
|
||||
<VerifiedBadge link={firstVerifiedField.value} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className='account__relationship'>
|
||||
<Button
|
||||
text={intl.formatMessage(
|
||||
partOfList ? messages.remove : messages.add,
|
||||
)}
|
||||
secondary={partOfList}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ListMembers: React.FC<{
|
||||
multiColumn?: boolean;
|
||||
}> = ({ multiColumn }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const intl = useIntl();
|
||||
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [accountIds, setAccountIds] = useState<string[]>([]);
|
||||
const [searchAccountIds, setSearchAccountIds] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [mode, setMode] = useState<Mode>('remove');
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
setLoading(true);
|
||||
dispatch(fetchList(id));
|
||||
|
||||
void apiGetAccounts(id)
|
||||
.then((data) => {
|
||||
dispatch(importFetchedAccounts(data));
|
||||
setAccountIds(data.map((a) => a.id));
|
||||
setLoading(false);
|
||||
return '';
|
||||
})
|
||||
.catch(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}, [dispatch, id]);
|
||||
|
||||
const handleSearchClick = useCallback(() => {
|
||||
setMode('add');
|
||||
}, [setMode]);
|
||||
|
||||
const handleDismissSearchClick = useCallback(() => {
|
||||
setMode('remove');
|
||||
setSearching(false);
|
||||
}, [setMode]);
|
||||
|
||||
const handleAccountToggle = useCallback(
|
||||
(accountId: string) => {
|
||||
const partOfList = accountIds.includes(accountId);
|
||||
|
||||
if (partOfList) {
|
||||
setAccountIds(accountIds.filter((id) => id !== accountId));
|
||||
} else {
|
||||
setAccountIds([accountId, ...accountIds]);
|
||||
}
|
||||
},
|
||||
[accountIds, setAccountIds],
|
||||
);
|
||||
|
||||
const searchRequestRef = useRef<AbortController | null>(null);
|
||||
|
||||
const handleSearch = useDebouncedCallback(
|
||||
(value: string) => {
|
||||
if (searchRequestRef.current) {
|
||||
searchRequestRef.current.abort();
|
||||
}
|
||||
|
||||
if (value.trim().length === 0) {
|
||||
setSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
searchRequestRef.current = new AbortController();
|
||||
|
||||
void apiRequest<ApiAccountJSON[]>('GET', 'v1/accounts/search', {
|
||||
signal: searchRequestRef.current.signal,
|
||||
params: {
|
||||
q: value,
|
||||
resolve: false,
|
||||
following: true,
|
||||
},
|
||||
})
|
||||
.then((data) => {
|
||||
dispatch(importFetchedAccounts(data));
|
||||
setSearchAccountIds(data.map((a) => a.id));
|
||||
setLoading(false);
|
||||
setSearching(true);
|
||||
return '';
|
||||
})
|
||||
.catch(() => {
|
||||
setSearching(true);
|
||||
setLoading(false);
|
||||
});
|
||||
},
|
||||
500,
|
||||
{ leading: true, trailing: true },
|
||||
);
|
||||
|
||||
let displayedAccountIds: string[];
|
||||
|
||||
if (mode === 'add' && searching) {
|
||||
displayedAccountIds = searchAccountIds;
|
||||
} else {
|
||||
displayedAccountIds = accountIds;
|
||||
}
|
||||
|
||||
return (
|
||||
<Column
|
||||
bindToDocument={!multiColumn}
|
||||
label={intl.formatMessage(messages.heading)}
|
||||
>
|
||||
<ColumnHeader
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
icon='list-ul'
|
||||
iconComponent={ListAltIcon}
|
||||
multiColumn={multiColumn}
|
||||
showBackButton
|
||||
/>
|
||||
|
||||
<ColumnSearchHeader
|
||||
placeholder={intl.formatMessage(messages.placeholder)}
|
||||
onBack={handleDismissSearchClick}
|
||||
onSubmit={handleSearch}
|
||||
onActivate={handleSearchClick}
|
||||
active={mode === 'add'}
|
||||
/>
|
||||
|
||||
<ScrollableList
|
||||
scrollKey='list_members'
|
||||
trackScroll={!multiColumn}
|
||||
bindToDocument={!multiColumn}
|
||||
isLoading={loading}
|
||||
showLoading={loading && displayedAccountIds.length === 0}
|
||||
hasMore={false}
|
||||
footer={
|
||||
<>
|
||||
{displayedAccountIds.length > 0 && <div className='spacer' />}
|
||||
|
||||
<div className='column-footer'>
|
||||
<Link to={`/lists/${id}`} className='button button--block'>
|
||||
<FormattedMessage id='lists.done' defaultMessage='Done' />
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
emptyMessage={
|
||||
mode === 'remove' ? (
|
||||
<>
|
||||
<span>
|
||||
<FormattedMessage
|
||||
id='lists.no_members_yet'
|
||||
defaultMessage='No members yet.'
|
||||
/>
|
||||
<br />
|
||||
<FormattedMessage
|
||||
id='lists.find_users_to_add'
|
||||
defaultMessage='Find users to add'
|
||||
/>
|
||||
</span>
|
||||
|
||||
<SquigglyArrow className='empty-column-indicator__arrow' />
|
||||
</>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='lists.no_results_found'
|
||||
defaultMessage='No results found.'
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
{displayedAccountIds.map((accountId) => (
|
||||
<AccountItem
|
||||
key={accountId}
|
||||
accountId={accountId}
|
||||
listId={id}
|
||||
partOfList={
|
||||
displayedAccountIds === accountIds ||
|
||||
accountIds.includes(accountId)
|
||||
}
|
||||
onToggle={handleAccountToggle}
|
||||
/>
|
||||
))}
|
||||
</ScrollableList>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.heading)}</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default ListMembers;
|
296
app/javascript/flavours/glitch/features/lists/new.tsx
Normal file
296
app/javascript/flavours/glitch/features/lists/new.tsx
Normal file
|
@ -0,0 +1,296 @@
|
|||
import { useCallback, useState, useEffect } from 'react';
|
||||
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { useParams, useHistory, Link } from 'react-router-dom';
|
||||
|
||||
import { isFulfilled } from '@reduxjs/toolkit';
|
||||
|
||||
import Toggle from 'react-toggle';
|
||||
|
||||
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
|
||||
import { fetchList } from 'flavours/glitch/actions/lists';
|
||||
import { createList, updateList } from 'flavours/glitch/actions/lists_typed';
|
||||
import { apiGetAccounts } from 'flavours/glitch/api/lists';
|
||||
import type { RepliesPolicyType } from 'flavours/glitch/api_types/lists';
|
||||
import Column from 'flavours/glitch/components/column';
|
||||
import { ColumnHeader } from 'flavours/glitch/components/column_header';
|
||||
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
||||
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
edit: { id: 'column.edit_list', defaultMessage: 'Edit list' },
|
||||
create: { id: 'column.create_list', defaultMessage: 'Create list' },
|
||||
});
|
||||
|
||||
const MembersLink: React.FC<{
|
||||
id: string;
|
||||
}> = ({ id }) => {
|
||||
const [count, setCount] = useState(0);
|
||||
const [avatars, setAvatars] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
void apiGetAccounts(id)
|
||||
.then((data) => {
|
||||
setCount(data.length);
|
||||
setAvatars(data.slice(0, 3).map((a) => a.avatar));
|
||||
return '';
|
||||
})
|
||||
.catch(() => {
|
||||
// Nothing
|
||||
});
|
||||
}, [id, setCount, setAvatars]);
|
||||
|
||||
return (
|
||||
<Link to={`/lists/${id}/members`} className='app-form__link'>
|
||||
<div className='app-form__link__text'>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
id='lists.list_members'
|
||||
defaultMessage='List members'
|
||||
/>
|
||||
</strong>
|
||||
<FormattedMessage
|
||||
id='lists.list_members_count'
|
||||
defaultMessage='{count, plural, one {# member} other {# members}}'
|
||||
values={{ count }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='avatar-pile'>
|
||||
{avatars.map((url) => (
|
||||
<img key={url} src={url} alt='' />
|
||||
))}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
const NewList: React.FC<{
|
||||
multiColumn?: boolean;
|
||||
}> = ({ multiColumn }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { id } = useParams<{ id?: string }>();
|
||||
const intl = useIntl();
|
||||
const history = useHistory();
|
||||
|
||||
const list = useAppSelector((state) =>
|
||||
id ? state.lists.get(id) : undefined,
|
||||
);
|
||||
const [title, setTitle] = useState('');
|
||||
const [exclusive, setExclusive] = useState(false);
|
||||
const [repliesPolicy, setRepliesPolicy] = useState<RepliesPolicyType>('list');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
dispatch(fetchList(id));
|
||||
}
|
||||
}, [dispatch, id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (id && list) {
|
||||
setTitle(list.title);
|
||||
setExclusive(list.exclusive);
|
||||
setRepliesPolicy(list.replies_policy);
|
||||
}
|
||||
}, [setTitle, setExclusive, setRepliesPolicy, id, list]);
|
||||
|
||||
const handleTitleChange = useCallback(
|
||||
({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setTitle(value);
|
||||
},
|
||||
[setTitle],
|
||||
);
|
||||
|
||||
const handleExclusiveChange = useCallback(
|
||||
({ target: { checked } }: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setExclusive(checked);
|
||||
},
|
||||
[setExclusive],
|
||||
);
|
||||
|
||||
const handleRepliesPolicyChange = useCallback(
|
||||
({ target: { value } }: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
setRepliesPolicy(value as RepliesPolicyType);
|
||||
},
|
||||
[setRepliesPolicy],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
setSubmitting(true);
|
||||
|
||||
if (id) {
|
||||
void dispatch(
|
||||
updateList({
|
||||
id,
|
||||
title,
|
||||
exclusive,
|
||||
replies_policy: repliesPolicy,
|
||||
}),
|
||||
).then(() => {
|
||||
setSubmitting(false);
|
||||
return '';
|
||||
});
|
||||
} else {
|
||||
void dispatch(
|
||||
createList({
|
||||
title,
|
||||
exclusive,
|
||||
replies_policy: repliesPolicy,
|
||||
}),
|
||||
).then((result) => {
|
||||
setSubmitting(false);
|
||||
|
||||
if (isFulfilled(result)) {
|
||||
history.replace(`/lists/${result.payload.id}/edit`);
|
||||
history.push(`/lists/${result.payload.id}/members`);
|
||||
}
|
||||
|
||||
return '';
|
||||
});
|
||||
}
|
||||
}, [history, dispatch, setSubmitting, id, title, exclusive, repliesPolicy]);
|
||||
|
||||
return (
|
||||
<Column
|
||||
bindToDocument={!multiColumn}
|
||||
label={intl.formatMessage(id ? messages.edit : messages.create)}
|
||||
>
|
||||
<ColumnHeader
|
||||
title={intl.formatMessage(id ? messages.edit : messages.create)}
|
||||
icon='list-ul'
|
||||
iconComponent={ListAltIcon}
|
||||
multiColumn={multiColumn}
|
||||
showBackButton
|
||||
/>
|
||||
|
||||
<div className='scrollable'>
|
||||
<form className='simple_form app-form' onSubmit={handleSubmit}>
|
||||
<div className='fields-group'>
|
||||
<div className='input with_label'>
|
||||
<div className='label_input'>
|
||||
<label htmlFor='list_title'>
|
||||
<FormattedMessage
|
||||
id='lists.list_name'
|
||||
defaultMessage='List name'
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className='label_input__wrapper'>
|
||||
<input
|
||||
id='list_title'
|
||||
type='text'
|
||||
value={title}
|
||||
onChange={handleTitleChange}
|
||||
maxLength={30}
|
||||
required
|
||||
placeholder=' '
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='fields-group'>
|
||||
<div className='input with_label'>
|
||||
<div className='label_input'>
|
||||
<label htmlFor='list_replies_policy'>
|
||||
<FormattedMessage
|
||||
id='lists.show_replies_to'
|
||||
defaultMessage='Include replies from list members to'
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className='label_input__wrapper'>
|
||||
<select
|
||||
id='list_replies_policy'
|
||||
value={repliesPolicy}
|
||||
onChange={handleRepliesPolicyChange}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='lists.replies_policy.none'
|
||||
defaultMessage='No one'
|
||||
>
|
||||
{(msg) => <option value='none'>{msg}</option>}
|
||||
</FormattedMessage>
|
||||
<FormattedMessage
|
||||
id='lists.replies_policy.list'
|
||||
defaultMessage='Members of the list'
|
||||
>
|
||||
{(msg) => <option value='list'>{msg}</option>}
|
||||
</FormattedMessage>
|
||||
<FormattedMessage
|
||||
id='lists.replies_policy.followed'
|
||||
defaultMessage='Any followed user'
|
||||
>
|
||||
{(msg) => <option value='followed'>{msg}</option>}
|
||||
</FormattedMessage>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{id && (
|
||||
<div className='fields-group'>
|
||||
<MembersLink id={id} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='fields-group'>
|
||||
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
|
||||
<label className='app-form__toggle'>
|
||||
<div className='app-form__toggle__label'>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
id='lists.exclusive'
|
||||
defaultMessage='Hide members in Home'
|
||||
/>
|
||||
</strong>
|
||||
<span className='hint'>
|
||||
<FormattedMessage
|
||||
id='lists.exclusive_hint'
|
||||
defaultMessage='If someone is on this list, hide them in your Home feed to avoid seeing their posts twice.'
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='app-form__toggle__toggle'>
|
||||
<div>
|
||||
<Toggle
|
||||
checked={exclusive}
|
||||
onChange={handleExclusiveChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className='actions'>
|
||||
<button className='button' type='submit'>
|
||||
{submitting ? (
|
||||
<LoadingIndicator />
|
||||
) : id ? (
|
||||
<FormattedMessage id='lists.save' defaultMessage='Save' />
|
||||
) : (
|
||||
<FormattedMessage id='lists.create' defaultMessage='Create' />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<Helmet>
|
||||
<title>
|
||||
{intl.formatMessage(id ? messages.edit : messages.create)}
|
||||
</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default NewList;
|
|
@ -1,128 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
|
||||
import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react';
|
||||
import MoodIcon from '@/material-icons/400-24px/mood.svg?react';
|
||||
import PersonAddIcon from '@/material-icons/400-24px/person_add.svg?react';
|
||||
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
|
||||
import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react';
|
||||
import StarIcon from '@/material-icons/400-24px/star.svg?react';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
|
||||
const tooltips = defineMessages({
|
||||
mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' },
|
||||
favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Favorites' },
|
||||
reactions: { id: 'notifications.filter.reactions', defaultMessage: 'Reactions' },
|
||||
boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' },
|
||||
polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' },
|
||||
follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' },
|
||||
statuses: { id: 'notifications.filter.statuses', defaultMessage: 'Updates from people you follow' },
|
||||
});
|
||||
|
||||
class FilterBar extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
selectFilter: PropTypes.func.isRequired,
|
||||
selectedFilter: PropTypes.string.isRequired,
|
||||
advancedMode: PropTypes.bool.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
onClick (notificationType) {
|
||||
return () => this.props.selectFilter(notificationType);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { selectedFilter, advancedMode, intl } = this.props;
|
||||
const renderedElement = !advancedMode ? (
|
||||
<div className='notification__filter-bar'>
|
||||
<button
|
||||
className={selectedFilter === 'all' ? 'active' : ''}
|
||||
onClick={this.onClick('all')}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='notifications.filter.all'
|
||||
defaultMessage='All'
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
className={selectedFilter === 'mention' ? 'active' : ''}
|
||||
onClick={this.onClick('mention')}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='notifications.filter.mentions'
|
||||
defaultMessage='Mentions'
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className='notification__filter-bar'>
|
||||
<button
|
||||
className={selectedFilter === 'all' ? 'active' : ''}
|
||||
onClick={this.onClick('all')}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='notifications.filter.all'
|
||||
defaultMessage='All'
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
className={selectedFilter === 'mention' ? 'active' : ''}
|
||||
onClick={this.onClick('mention')}
|
||||
title={intl.formatMessage(tooltips.mentions)}
|
||||
>
|
||||
<Icon id='reply-all' icon={ReplyAllIcon} />
|
||||
</button>
|
||||
<button
|
||||
className={selectedFilter === 'favourite' ? 'active' : ''}
|
||||
onClick={this.onClick('favourite')}
|
||||
title={intl.formatMessage(tooltips.favourites)}
|
||||
>
|
||||
<Icon id='star' icon={StarIcon} />
|
||||
</button>
|
||||
<button
|
||||
className={selectedFilter === 'reaction' ? 'active' : ''}
|
||||
onClick={this.onClick('reaction')}
|
||||
title={intl.formatMessage(tooltips.reactions)}
|
||||
>
|
||||
<Icon id='mood' icon={MoodIcon} />
|
||||
</button>
|
||||
<button
|
||||
className={selectedFilter === 'reblog' ? 'active' : ''}
|
||||
onClick={this.onClick('reblog')}
|
||||
title={intl.formatMessage(tooltips.boosts)}
|
||||
>
|
||||
<Icon id='retweet' icon={RepeatIcon} />
|
||||
</button>
|
||||
<button
|
||||
className={selectedFilter === 'poll' ? 'active' : ''}
|
||||
onClick={this.onClick('poll')}
|
||||
title={intl.formatMessage(tooltips.polls)}
|
||||
>
|
||||
<Icon id='tasks' icon={InsertChartIcon} />
|
||||
</button>
|
||||
<button
|
||||
className={selectedFilter === 'status' ? 'active' : ''}
|
||||
onClick={this.onClick('status')}
|
||||
title={intl.formatMessage(tooltips.statuses)}
|
||||
>
|
||||
<Icon id='home' icon={HomeIcon} />
|
||||
</button>
|
||||
<button
|
||||
className={selectedFilter === 'follow' ? 'active' : ''}
|
||||
onClick={this.onClick('follow')}
|
||||
title={intl.formatMessage(tooltips.follows)}
|
||||
>
|
||||
<Icon id='user-plus' icon={PersonAddIcon} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
return renderedElement;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default injectIntl(FilterBar);
|
|
@ -1,17 +0,0 @@
|
|||
import { connect } from 'react-redux';
|
||||
|
||||
import { setFilter } from '../../../actions/notifications';
|
||||
import FilterBar from '../components/filter_bar';
|
||||
|
||||
const makeMapStateToProps = state => ({
|
||||
selectedFilter: state.getIn(['settings', 'notifications', 'quickFilter', 'active']),
|
||||
advancedMode: state.getIn(['settings', 'notifications', 'quickFilter', 'advanced']),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
selectFilter (newActiveFilter) {
|
||||
dispatch(setFilter(newActiveFilter));
|
||||
},
|
||||
});
|
||||
|
||||
export default connect(makeMapStateToProps, mapDispatchToProps)(FilterBar);
|
|
@ -1,383 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import DeleteForeverIcon from '@/material-icons/400-24px/delete_forever.svg?react';
|
||||
import DoneAllIcon from '@/material-icons/400-24px/done_all.svg?react';
|
||||
import NotificationsIcon from '@/material-icons/400-24px/notifications-fill.svg?react';
|
||||
import { compareId } from 'flavours/glitch/compare_id';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import { NotSignedInIndicator } from 'flavours/glitch/components/not_signed_in_indicator';
|
||||
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
|
||||
|
||||
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
|
||||
import { submitMarkers } from '../../actions/markers';
|
||||
import {
|
||||
enterNotificationClearingMode,
|
||||
expandNotifications,
|
||||
scrollTopNotifications,
|
||||
loadPending,
|
||||
mountNotifications,
|
||||
unmountNotifications,
|
||||
markNotificationsAsRead,
|
||||
} from '../../actions/notifications';
|
||||
import Column from '../../components/column';
|
||||
import ColumnHeader from '../../components/column_header';
|
||||
import { LoadGap } from '../../components/load_gap';
|
||||
import ScrollableList from '../../components/scrollable_list';
|
||||
import NotificationPurgeButtonsContainer from '../../containers/notification_purge_buttons_container';
|
||||
|
||||
import {
|
||||
FilteredNotificationsBanner,
|
||||
FilteredNotificationsIconButton,
|
||||
} from './components/filtered_notifications_banner';
|
||||
import NotificationsPermissionBanner from './components/notifications_permission_banner';
|
||||
import ColumnSettingsContainer from './containers/column_settings_container';
|
||||
import FilterBarContainer from './containers/filter_bar_container';
|
||||
import NotificationContainer from './containers/notification_container';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'column.notifications', defaultMessage: 'Notifications' },
|
||||
enterNotifCleaning : { id: 'notification_purge.start', defaultMessage: 'Enter notification cleaning mode' },
|
||||
markAsRead : { id: 'notifications.mark_as_read', defaultMessage: 'Mark every notification as read' },
|
||||
});
|
||||
|
||||
const getExcludedTypes = createSelector([
|
||||
state => state.getIn(['settings', 'notifications', 'shows']),
|
||||
], (shows) => {
|
||||
return ImmutableList(shows.filter(item => !item).keys());
|
||||
});
|
||||
|
||||
const getNotifications = createSelector([
|
||||
state => state.getIn(['settings', 'notifications', 'quickFilter', 'show']),
|
||||
state => state.getIn(['settings', 'notifications', 'quickFilter', 'active']),
|
||||
getExcludedTypes,
|
||||
state => state.getIn(['notifications', 'items']),
|
||||
], (showFilterBar, allowedType, excludedTypes, notifications) => {
|
||||
if (!showFilterBar || allowedType === 'all') {
|
||||
// used if user changed the notification settings after loading the notifications from the server
|
||||
// otherwise a list of notifications will come pre-filtered from the backend
|
||||
// we need to turn it off for FilterBar in order not to block ourselves from seeing a specific category
|
||||
return notifications.filterNot(item => item !== null && excludedTypes.includes(item.get('type')));
|
||||
}
|
||||
return notifications.filter(item => item === null || allowedType === item.get('type'));
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
showFilterBar: state.getIn(['settings', 'notifications', 'quickFilter', 'show']),
|
||||
notifications: getNotifications(state),
|
||||
localSettings: state.get('local_settings'),
|
||||
isLoading: state.getIn(['notifications', 'isLoading'], 0) > 0,
|
||||
isUnread: state.getIn(['notifications', 'unread']) > 0 || state.getIn(['notifications', 'pendingItems']).size > 0,
|
||||
hasMore: state.getIn(['notifications', 'hasMore']),
|
||||
numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size,
|
||||
notifCleaningActive: state.getIn(['notifications', 'cleaningMode']),
|
||||
lastReadId: state.getIn(['settings', 'notifications', 'showUnread']) ? state.getIn(['notifications', 'readMarkerId']) : '0',
|
||||
canMarkAsRead: state.getIn(['settings', 'notifications', 'showUnread']) && state.getIn(['notifications', 'readMarkerId']) !== '0' && getNotifications(state).some(item => item !== null && compareId(item.get('id'), state.getIn(['notifications', 'readMarkerId'])) > 0),
|
||||
needsNotificationPermission: state.getIn(['settings', 'notifications', 'alerts']).includes(true) && state.getIn(['notifications', 'browserSupport']) && state.getIn(['notifications', 'browserPermission']) === 'default' && !state.getIn(['settings', 'notifications', 'dismissPermissionBanner']),
|
||||
});
|
||||
|
||||
/* glitch */
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onEnterCleaningMode(yes) {
|
||||
dispatch(enterNotificationClearingMode(yes));
|
||||
},
|
||||
dispatch,
|
||||
});
|
||||
|
||||
class Notifications extends PureComponent {
|
||||
static propTypes = {
|
||||
identity: identityContextPropShape,
|
||||
columnId: PropTypes.string,
|
||||
notifications: ImmutablePropTypes.list.isRequired,
|
||||
showFilterBar: PropTypes.bool.isRequired,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
isLoading: PropTypes.bool,
|
||||
isUnread: PropTypes.bool,
|
||||
multiColumn: PropTypes.bool,
|
||||
hasMore: PropTypes.bool,
|
||||
numPending: PropTypes.number,
|
||||
localSettings: ImmutablePropTypes.map,
|
||||
notifCleaningActive: PropTypes.bool,
|
||||
onEnterCleaningMode: PropTypes.func,
|
||||
lastReadId: PropTypes.string,
|
||||
canMarkAsRead: PropTypes.bool,
|
||||
needsNotificationPermission: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
trackScroll: true,
|
||||
};
|
||||
|
||||
state = {
|
||||
animatingNCD: false,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.props.dispatch(mountNotifications());
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
this.handleLoadOlder.cancel();
|
||||
this.handleScrollToTop.cancel();
|
||||
this.handleScroll.cancel();
|
||||
// this.props.dispatch(scrollTopNotifications(false));
|
||||
this.props.dispatch(unmountNotifications());
|
||||
}
|
||||
|
||||
handleLoadGap = (maxId) => {
|
||||
this.props.dispatch(expandNotifications({ maxId }));
|
||||
};
|
||||
|
||||
handleLoadOlder = debounce(() => {
|
||||
const last = this.props.notifications.last();
|
||||
this.props.dispatch(expandNotifications({ maxId: last && last.get('id') }));
|
||||
}, 300, { leading: true });
|
||||
|
||||
handleLoadPending = () => {
|
||||
this.props.dispatch(loadPending());
|
||||
};
|
||||
|
||||
handleScrollToTop = debounce(() => {
|
||||
this.props.dispatch(scrollTopNotifications(true));
|
||||
}, 100);
|
||||
|
||||
handleScroll = debounce(() => {
|
||||
this.props.dispatch(scrollTopNotifications(false));
|
||||
}, 100);
|
||||
|
||||
handlePin = () => {
|
||||
const { columnId, dispatch } = this.props;
|
||||
|
||||
if (columnId) {
|
||||
dispatch(removeColumn(columnId));
|
||||
} else {
|
||||
dispatch(addColumn('NOTIFICATIONS', {}));
|
||||
}
|
||||
};
|
||||
|
||||
handleMove = (dir) => {
|
||||
const { columnId, dispatch } = this.props;
|
||||
dispatch(moveColumn(columnId, dir));
|
||||
};
|
||||
|
||||
handleHeaderClick = () => {
|
||||
this.column.scrollTop();
|
||||
};
|
||||
|
||||
setColumnRef = c => {
|
||||
this.column = c;
|
||||
};
|
||||
|
||||
handleMoveUp = id => {
|
||||
const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) - 1;
|
||||
this._selectChild(elementIndex, true);
|
||||
};
|
||||
|
||||
handleMoveDown = id => {
|
||||
const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) + 1;
|
||||
this._selectChild(elementIndex, false);
|
||||
};
|
||||
|
||||
_selectChild (index, align_top) {
|
||||
const container = this.column.node;
|
||||
const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
|
||||
|
||||
if (element) {
|
||||
if (align_top && container.scrollTop > element.offsetTop) {
|
||||
element.scrollIntoView(true);
|
||||
} else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
|
||||
element.scrollIntoView(false);
|
||||
}
|
||||
element.focus();
|
||||
}
|
||||
}
|
||||
|
||||
handleTransitionEndNCD = () => {
|
||||
this.setState({ animatingNCD: false });
|
||||
};
|
||||
|
||||
onEnterCleaningMode = () => {
|
||||
this.setState({ animatingNCD: true });
|
||||
this.props.onEnterCleaningMode(!this.props.notifCleaningActive);
|
||||
};
|
||||
|
||||
handleMarkAsRead = () => {
|
||||
this.props.dispatch(markNotificationsAsRead());
|
||||
this.props.dispatch(submitMarkers({ immediate: true }));
|
||||
};
|
||||
|
||||
render () {
|
||||
const { intl, notifications, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar, lastReadId, canMarkAsRead, needsNotificationPermission } = this.props;
|
||||
const { notifCleaningActive } = this.props;
|
||||
const { animatingNCD } = this.state;
|
||||
const pinned = !!columnId;
|
||||
const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. When other people interact with you, you will see it here." />;
|
||||
const { signedIn } = this.props.identity;
|
||||
|
||||
let scrollableContent = null;
|
||||
|
||||
const filterBarContainer = (signedIn && showFilterBar)
|
||||
? (<FilterBarContainer />)
|
||||
: null;
|
||||
|
||||
if (isLoading && this.scrollableContent) {
|
||||
scrollableContent = this.scrollableContent;
|
||||
} else if (notifications.size > 0 || hasMore) {
|
||||
scrollableContent = notifications.map((item, index) => item === null ? (
|
||||
<LoadGap
|
||||
key={'gap:' + notifications.getIn([index + 1, 'id'])}
|
||||
disabled={isLoading}
|
||||
param={index > 0 ? notifications.getIn([index - 1, 'id']) : null}
|
||||
onClick={this.handleLoadGap}
|
||||
/>
|
||||
) : (
|
||||
<NotificationContainer
|
||||
key={item.get('id')}
|
||||
notification={item}
|
||||
accountId={item.get('account')}
|
||||
onMoveUp={this.handleMoveUp}
|
||||
onMoveDown={this.handleMoveDown}
|
||||
unread={lastReadId !== '0' && compareId(item.get('id'), lastReadId) > 0}
|
||||
/>
|
||||
));
|
||||
} else {
|
||||
scrollableContent = null;
|
||||
}
|
||||
|
||||
this.scrollableContent = scrollableContent;
|
||||
|
||||
let scrollContainer;
|
||||
|
||||
const prepend = (
|
||||
<>
|
||||
{needsNotificationPermission && <NotificationsPermissionBanner />}
|
||||
<FilteredNotificationsBanner />
|
||||
</>
|
||||
);
|
||||
|
||||
if (signedIn) {
|
||||
scrollContainer = (
|
||||
<ScrollableList
|
||||
scrollKey={`notifications-${columnId}`}
|
||||
trackScroll={!pinned}
|
||||
isLoading={isLoading}
|
||||
showLoading={isLoading && notifications.size === 0}
|
||||
hasMore={hasMore}
|
||||
numPending={numPending}
|
||||
prepend={prepend}
|
||||
alwaysPrepend
|
||||
emptyMessage={emptyMessage}
|
||||
onLoadMore={this.handleLoadOlder}
|
||||
onLoadPending={this.handleLoadPending}
|
||||
onScrollToTop={this.handleScrollToTop}
|
||||
onScroll={this.handleScroll}
|
||||
bindToDocument={!multiColumn}
|
||||
>
|
||||
{scrollableContent}
|
||||
</ScrollableList>
|
||||
);
|
||||
} else {
|
||||
scrollContainer = <NotSignedInIndicator />;
|
||||
}
|
||||
|
||||
const extraButtons = [
|
||||
<FilteredNotificationsIconButton key='filtered-notifications-icon' className='column-header__button' />,
|
||||
];
|
||||
|
||||
if (canMarkAsRead) {
|
||||
extraButtons.push(
|
||||
<button
|
||||
key='mark-as-read'
|
||||
aria-label={intl.formatMessage(messages.markAsRead)}
|
||||
title={intl.formatMessage(messages.markAsRead)}
|
||||
onClick={this.handleMarkAsRead}
|
||||
className='column-header__button'
|
||||
>
|
||||
<Icon id='done-all' icon={DoneAllIcon} />
|
||||
</button>,
|
||||
);
|
||||
}
|
||||
|
||||
const notifCleaningButtonClassName = classNames('column-header__button', {
|
||||
'active': notifCleaningActive,
|
||||
});
|
||||
|
||||
const notifCleaningDrawerClassName = classNames('ncd column-header__collapsible', {
|
||||
'collapsed': !notifCleaningActive,
|
||||
'animating': animatingNCD,
|
||||
});
|
||||
|
||||
const msgEnterNotifCleaning = intl.formatMessage(messages.enterNotifCleaning);
|
||||
|
||||
extraButtons.push(
|
||||
<button
|
||||
key='notif-cleaning'
|
||||
aria-label={msgEnterNotifCleaning}
|
||||
title={msgEnterNotifCleaning}
|
||||
onClick={this.onEnterCleaningMode}
|
||||
className={notifCleaningButtonClassName}
|
||||
>
|
||||
<Icon id='eraser' icon={DeleteForeverIcon} />
|
||||
</button>,
|
||||
);
|
||||
|
||||
const notifCleaningDrawer = (
|
||||
<div className={notifCleaningDrawerClassName} onTransitionEnd={this.handleTransitionEndNCD}>
|
||||
<div className='column-header__collapsible-inner nopad-drawer'>
|
||||
{(notifCleaningActive || animatingNCD) ? (<NotificationPurgeButtonsContainer />) : null }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Column
|
||||
bindToDocument={!multiColumn}
|
||||
ref={this.setColumnRef}
|
||||
extraClasses={this.props.notifCleaningActive ? 'notif-cleaning' : null}
|
||||
label={intl.formatMessage(messages.title)}
|
||||
>
|
||||
<ColumnHeader
|
||||
icon='bell'
|
||||
iconComponent={NotificationsIcon}
|
||||
active={isUnread}
|
||||
title={intl.formatMessage(messages.title)}
|
||||
onPin={this.handlePin}
|
||||
onMove={this.handleMove}
|
||||
onClick={this.handleHeaderClick}
|
||||
pinned={pinned}
|
||||
multiColumn={multiColumn}
|
||||
localSettings={this.props.localSettings}
|
||||
extraButton={extraButtons}
|
||||
appendContent={notifCleaningDrawer}
|
||||
>
|
||||
<ColumnSettingsContainer />
|
||||
</ColumnHeader>
|
||||
|
||||
{filterBarContainer}
|
||||
|
||||
{scrollContainer}
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.title)}</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(withIdentity(injectIntl(Notifications)));
|
|
@ -1,9 +0,0 @@
|
|||
import Notifications_v2 from 'flavours/glitch/features/notifications_v2';
|
||||
|
||||
export const NotificationsWrapper = (props) => {
|
||||
return (
|
||||
<Notifications_v2 {...props} />
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationsWrapper;
|
|
@ -1,57 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import ArrowRightAltIcon from '@/material-icons/400-24px/arrow_right_alt.svg?react';
|
||||
import CheckIcon from '@/material-icons/400-24px/done.svg?react';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
|
||||
export const Step = ({ label, description, icon, iconComponent, completed, onClick, href, to }) => {
|
||||
const content = (
|
||||
<>
|
||||
<div className='onboarding__steps__item__icon'>
|
||||
<Icon id={icon} icon={iconComponent} />
|
||||
</div>
|
||||
|
||||
<div className='onboarding__steps__item__description'>
|
||||
<h6>{label}</h6>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
|
||||
<div className={completed ? 'onboarding__steps__item__progress' : 'onboarding__steps__item__go'}>
|
||||
{completed ? <Icon icon={CheckIcon} /> : <Icon icon={ArrowRightAltIcon} />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<a href={href} onClick={onClick} target='_blank' rel='noopener' className='onboarding__steps__item'>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
} else if (to) {
|
||||
return (
|
||||
<Link to={to} className='onboarding__steps__item'>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button onClick={onClick} className='onboarding__steps__item'>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
Step.propTypes = {
|
||||
label: PropTypes.node,
|
||||
description: PropTypes.node,
|
||||
icon: PropTypes.string,
|
||||
iconComponent: PropTypes.func,
|
||||
completed: PropTypes.bool,
|
||||
href: PropTypes.string,
|
||||
to: PropTypes.string,
|
||||
onClick: PropTypes.func,
|
||||
};
|
|
@ -1,62 +0,0 @@
|
|||
import { useEffect } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
|
||||
import { fetchSuggestions } from 'flavours/glitch/actions/suggestions';
|
||||
import { markAsPartial } from 'flavours/glitch/actions/timelines';
|
||||
import { ColumnBackButton } from 'flavours/glitch/components/column_back_button';
|
||||
import { EmptyAccount } from 'flavours/glitch/components/empty_account';
|
||||
import Account from 'flavours/glitch/containers/account_container';
|
||||
import { useAppSelector } from 'flavours/glitch/store';
|
||||
|
||||
export const Follows = () => {
|
||||
const dispatch = useDispatch();
|
||||
const isLoading = useAppSelector(state => state.getIn(['suggestions', 'isLoading']));
|
||||
const suggestions = useAppSelector(state => state.getIn(['suggestions', 'items']));
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchSuggestions(true));
|
||||
|
||||
return () => {
|
||||
dispatch(markAsPartial('home'));
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
let loadedContent;
|
||||
|
||||
if (isLoading) {
|
||||
loadedContent = (new Array(8)).fill().map((_, i) => <EmptyAccount key={i} />);
|
||||
} else if (suggestions.isEmpty()) {
|
||||
loadedContent = <div className='follow-recommendations__empty'><FormattedMessage id='onboarding.follows.empty' defaultMessage='Unfortunately, no results can be shown right now. You can try using search or browsing the explore page to find people to follow, or try again later.' /></div>;
|
||||
} else {
|
||||
loadedContent = suggestions.map(suggestion => <Account id={suggestion.get('account')} key={suggestion.get('account')} withBio />);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ColumnBackButton />
|
||||
|
||||
<div className='scrollable privacy-policy'>
|
||||
<div className='column-title'>
|
||||
<h3><FormattedMessage id='onboarding.follows.title' defaultMessage='Popular on Mastodon' /></h3>
|
||||
<p><FormattedMessage id='onboarding.follows.lead' defaultMessage='You curate your own home feed. The more people you follow, the more active and interesting it will be. These profiles may be a good starting point—you can always unfollow them later!' /></p>
|
||||
</div>
|
||||
|
||||
<div className='follow-recommendations'>
|
||||
{loadedContent}
|
||||
</div>
|
||||
|
||||
<p className='onboarding__lead'><FormattedMessage id='onboarding.tips.accounts_from_other_servers' defaultMessage='<strong>Did you know?</strong> Since Mastodon is decentralized, some profiles you come across will be hosted on servers other than yours. And yet you can interact with them seamlessly! Their server is in the second half of their username!' values={{ strong: chunks => <strong>{chunks}</strong> }} /></p>
|
||||
|
||||
<div className='onboarding__footer'>
|
||||
<Link className='link-button' to='/start'><FormattedMessage id='onboarding.actions.back' defaultMessage='Take me back' /></Link>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
191
app/javascript/flavours/glitch/features/onboarding/follows.tsx
Normal file
191
app/javascript/flavours/glitch/features/onboarding/follows.tsx
Normal file
|
@ -0,0 +1,191 @@
|
|||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
|
||||
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
import PersonIcon from '@/material-icons/400-24px/person.svg?react';
|
||||
import { fetchRelationships } from 'flavours/glitch/actions/accounts';
|
||||
import { importFetchedAccounts } from 'flavours/glitch/actions/importer';
|
||||
import { fetchSuggestions } from 'flavours/glitch/actions/suggestions';
|
||||
import { markAsPartial } from 'flavours/glitch/actions/timelines';
|
||||
import { apiRequest } from 'flavours/glitch/api';
|
||||
import type { ApiAccountJSON } from 'flavours/glitch/api_types/accounts';
|
||||
import Column from 'flavours/glitch/components/column';
|
||||
import { ColumnHeader } from 'flavours/glitch/components/column_header';
|
||||
import { ColumnSearchHeader } from 'flavours/glitch/components/column_search_header';
|
||||
import ScrollableList from 'flavours/glitch/components/scrollable_list';
|
||||
import Account from 'flavours/glitch/containers/account_container';
|
||||
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'onboarding.follows.title',
|
||||
defaultMessage: 'Follow people to get started',
|
||||
},
|
||||
search: { id: 'onboarding.follows.search', defaultMessage: 'Search' },
|
||||
back: { id: 'onboarding.follows.back', defaultMessage: 'Back' },
|
||||
});
|
||||
|
||||
type Mode = 'remove' | 'add';
|
||||
|
||||
export const Follows: React.FC<{
|
||||
multiColumn?: boolean;
|
||||
}> = ({ multiColumn }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const isLoading = useAppSelector((state) => state.suggestions.isLoading);
|
||||
const suggestions = useAppSelector((state) => state.suggestions.items);
|
||||
const [searchAccountIds, setSearchAccountIds] = useState<string[]>([]);
|
||||
const [mode, setMode] = useState<Mode>('remove');
|
||||
const [isLoadingSearch, setIsLoadingSearch] = useState(false);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void dispatch(fetchSuggestions());
|
||||
|
||||
return () => {
|
||||
dispatch(markAsPartial('home'));
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
const handleSearchClick = useCallback(() => {
|
||||
setMode('add');
|
||||
}, [setMode]);
|
||||
|
||||
const handleDismissSearchClick = useCallback(() => {
|
||||
setMode('remove');
|
||||
setIsSearching(false);
|
||||
}, [setMode, setIsSearching]);
|
||||
|
||||
const searchRequestRef = useRef<AbortController | null>(null);
|
||||
|
||||
const handleSearch = useDebouncedCallback(
|
||||
(value: string) => {
|
||||
if (searchRequestRef.current) {
|
||||
searchRequestRef.current.abort();
|
||||
}
|
||||
|
||||
if (value.trim().length === 0) {
|
||||
setIsSearching(false);
|
||||
setSearchAccountIds([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSearching(true);
|
||||
setIsLoadingSearch(true);
|
||||
|
||||
searchRequestRef.current = new AbortController();
|
||||
|
||||
void apiRequest<ApiAccountJSON[]>('GET', 'v1/accounts/search', {
|
||||
signal: searchRequestRef.current.signal,
|
||||
params: {
|
||||
q: value,
|
||||
},
|
||||
})
|
||||
.then((data) => {
|
||||
dispatch(importFetchedAccounts(data));
|
||||
dispatch(fetchRelationships(data.map((a) => a.id)));
|
||||
setSearchAccountIds(data.map((a) => a.id));
|
||||
setIsLoadingSearch(false);
|
||||
return '';
|
||||
})
|
||||
.catch(() => {
|
||||
setIsLoadingSearch(false);
|
||||
});
|
||||
},
|
||||
500,
|
||||
{ leading: true, trailing: true },
|
||||
);
|
||||
|
||||
let displayedAccountIds: string[];
|
||||
|
||||
if (mode === 'add' && isSearching) {
|
||||
displayedAccountIds = searchAccountIds;
|
||||
} else {
|
||||
displayedAccountIds = suggestions.map(
|
||||
(suggestion) => suggestion.account_id,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Column
|
||||
bindToDocument={!multiColumn}
|
||||
label={intl.formatMessage(messages.title)}
|
||||
>
|
||||
<ColumnHeader
|
||||
title={intl.formatMessage(messages.title)}
|
||||
icon='person'
|
||||
iconComponent={PersonIcon}
|
||||
multiColumn={multiColumn}
|
||||
showBackButton
|
||||
/>
|
||||
|
||||
<ColumnSearchHeader
|
||||
placeholder={intl.formatMessage(messages.search)}
|
||||
onBack={handleDismissSearchClick}
|
||||
onActivate={handleSearchClick}
|
||||
active={mode === 'add'}
|
||||
onSubmit={handleSearch}
|
||||
/>
|
||||
|
||||
<ScrollableList
|
||||
scrollKey='follow_recommendations'
|
||||
trackScroll={!multiColumn}
|
||||
bindToDocument={!multiColumn}
|
||||
showLoading={
|
||||
(isLoading || isLoadingSearch) && displayedAccountIds.length === 0
|
||||
}
|
||||
hasMore={false}
|
||||
isLoading={isLoading || isLoadingSearch}
|
||||
footer={
|
||||
<>
|
||||
{displayedAccountIds.length > 0 && <div className='spacer' />}
|
||||
|
||||
<div className='column-footer'>
|
||||
<Link className='button button--block' to='/home'>
|
||||
<FormattedMessage
|
||||
id='onboarding.follows.done'
|
||||
defaultMessage='Done'
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
emptyMessage={
|
||||
mode === 'remove' ? (
|
||||
<FormattedMessage
|
||||
id='onboarding.follows.empty'
|
||||
defaultMessage='Unfortunately, no results can be shown right now. You can try using search or browsing the explore page to find people to follow, or try again later.'
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='lists.no_results_found'
|
||||
defaultMessage='No results found.'
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
{displayedAccountIds.map((accountId) => (
|
||||
<Account
|
||||
/* @ts-expect-error inferred props are wrong */
|
||||
id={accountId}
|
||||
key={accountId}
|
||||
withBio={false}
|
||||
/>
|
||||
))}
|
||||
</ScrollableList>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.title)}</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default Follows;
|
|
@ -1,88 +0,0 @@
|
|||
import { useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { Link, Switch, Route } from 'react-router-dom';
|
||||
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
|
||||
import illustration from '@/images/elephant_ui_conversation.svg';
|
||||
import AccountCircleIcon from '@/material-icons/400-24px/account_circle.svg?react';
|
||||
import ArrowRightAltIcon from '@/material-icons/400-24px/arrow_right_alt.svg?react';
|
||||
import ContentCopyIcon from '@/material-icons/400-24px/content_copy.svg?react';
|
||||
import EditNoteIcon from '@/material-icons/400-24px/edit_note.svg?react';
|
||||
import PersonAddIcon from '@/material-icons/400-24px/person_add.svg?react';
|
||||
import { focusCompose } from 'flavours/glitch/actions/compose';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import Column from 'flavours/glitch/features/ui/components/column';
|
||||
import { me } from 'flavours/glitch/initial_state';
|
||||
import { useAppSelector } from 'flavours/glitch/store';
|
||||
import { assetHost } from 'flavours/glitch/utils/config';
|
||||
|
||||
import { Step } from './components/step';
|
||||
import { Follows } from './follows';
|
||||
import { Profile } from './profile';
|
||||
import { Share } from './share';
|
||||
|
||||
const messages = defineMessages({
|
||||
template: { id: 'onboarding.compose.template', defaultMessage: 'Hello #Mastodon!' },
|
||||
});
|
||||
|
||||
const Onboarding = () => {
|
||||
const account = useAppSelector(state => state.getIn(['accounts', me]));
|
||||
const dispatch = useDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
const handleComposeClick = useCallback(() => {
|
||||
dispatch(focusCompose(intl.formatMessage(messages.template)));
|
||||
}, [dispatch, intl]);
|
||||
|
||||
return (
|
||||
<Column>
|
||||
<Switch>
|
||||
<Route path='/start' exact>
|
||||
<div className='scrollable privacy-policy'>
|
||||
<div className='column-title'>
|
||||
<img src={illustration} alt='' className='onboarding__illustration' />
|
||||
<h3><FormattedMessage id='onboarding.start.title' defaultMessage="You've made it!" /></h3>
|
||||
<p><FormattedMessage id='onboarding.start.lead' defaultMessage="Your new Mastodon account is ready to go. Here's how you can make the most of it:" /></p>
|
||||
</div>
|
||||
|
||||
<div className='onboarding__steps'>
|
||||
<Step to='/start/profile' completed={(!account.get('avatar').endsWith('missing.png')) || (account.get('display_name').length > 0 && account.get('note').length > 0)} icon='address-book-o' iconComponent={AccountCircleIcon} label={<FormattedMessage id='onboarding.steps.setup_profile.title' defaultMessage='Customize your profile' />} description={<FormattedMessage id='onboarding.steps.setup_profile.body' defaultMessage='Others are more likely to interact with you with a filled out profile.' />} />
|
||||
<Step to='/start/follows' completed={(account.get('following_count') * 1) >= 1} icon='user-plus' iconComponent={PersonAddIcon} label={<FormattedMessage id='onboarding.steps.follow_people.title' defaultMessage='Find at least {count, plural, one {one person} other {# people}} to follow' values={{ count: 7 }} />} description={<FormattedMessage id='onboarding.steps.follow_people.body' defaultMessage="You curate your own home feed. Let's fill it with interesting people." />} />
|
||||
<Step onClick={handleComposeClick} completed={(account.get('statuses_count') * 1) >= 1} icon='pencil-square-o' iconComponent={EditNoteIcon} label={<FormattedMessage id='onboarding.steps.publish_status.title' defaultMessage='Make your first post' />} description={<FormattedMessage id='onboarding.steps.publish_status.body' defaultMessage='Say hello to the world.' values={{ emoji: <img className='emojione' alt='🐘' src={`${assetHost}/emoji/1f418.svg`} /> }} />} />
|
||||
<Step to='/start/share' icon='copy' iconComponent={ContentCopyIcon} label={<FormattedMessage id='onboarding.steps.share_profile.title' defaultMessage='Share your profile' />} description={<FormattedMessage id='onboarding.steps.share_profile.body' defaultMessage='Let your friends know how to find you on Mastodon!' />} />
|
||||
</div>
|
||||
|
||||
<p className='onboarding__lead'><FormattedMessage id='onboarding.start.skip' defaultMessage="Don't need help getting started?" /></p>
|
||||
|
||||
<div className='onboarding__links'>
|
||||
<Link to='/explore' className='onboarding__link'>
|
||||
<FormattedMessage id='onboarding.actions.go_to_explore' defaultMessage='Take me to trending' />
|
||||
<Icon icon={ArrowRightAltIcon} />
|
||||
</Link>
|
||||
|
||||
<Link to='/home' className='onboarding__link'>
|
||||
<FormattedMessage id='onboarding.actions.go_to_home' defaultMessage='Take me to my home feed' />
|
||||
<Icon icon={ArrowRightAltIcon} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Route>
|
||||
|
||||
<Route path='/start/profile' component={Profile} />
|
||||
<Route path='/start/follows' component={Follows} />
|
||||
<Route path='/start/share' component={Share} />
|
||||
</Switch>
|
||||
|
||||
<Helmet>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default Onboarding;
|
|
@ -1,162 +0,0 @@
|
|||
import { useState, useMemo, useCallback, createRef } from 'react';
|
||||
|
||||
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import Toggle from 'react-toggle';
|
||||
|
||||
import AddPhotoAlternateIcon from '@/material-icons/400-24px/add_photo_alternate.svg?react';
|
||||
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
|
||||
import { updateAccount } from 'flavours/glitch/actions/accounts';
|
||||
import { Button } from 'flavours/glitch/components/button';
|
||||
import { ColumnBackButton } from 'flavours/glitch/components/column_back_button';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
||||
import { me } from 'flavours/glitch/initial_state';
|
||||
import { useAppSelector } from 'flavours/glitch/store';
|
||||
import { unescapeHTML } from 'flavours/glitch/utils/html';
|
||||
|
||||
const messages = defineMessages({
|
||||
uploadHeader: { id: 'onboarding.profile.upload_header', defaultMessage: 'Upload profile header' },
|
||||
uploadAvatar: { id: 'onboarding.profile.upload_avatar', defaultMessage: 'Upload profile picture' },
|
||||
});
|
||||
|
||||
const nullIfMissing = path => path.endsWith('missing.png') ? null : path;
|
||||
|
||||
export const Profile = () => {
|
||||
const account = useAppSelector(state => state.getIn(['accounts', me]));
|
||||
const [displayName, setDisplayName] = useState(account.get('display_name'));
|
||||
const [note, setNote] = useState(unescapeHTML(account.get('note')));
|
||||
const [avatar, setAvatar] = useState(null);
|
||||
const [header, setHeader] = useState(null);
|
||||
const [discoverable, setDiscoverable] = useState(account.get('discoverable'));
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [errors, setErrors] = useState();
|
||||
const avatarFileRef = createRef();
|
||||
const headerFileRef = createRef();
|
||||
const dispatch = useDispatch();
|
||||
const intl = useIntl();
|
||||
const history = useHistory();
|
||||
|
||||
const handleDisplayNameChange = useCallback(e => {
|
||||
setDisplayName(e.target.value);
|
||||
}, [setDisplayName]);
|
||||
|
||||
const handleNoteChange = useCallback(e => {
|
||||
setNote(e.target.value);
|
||||
}, [setNote]);
|
||||
|
||||
const handleDiscoverableChange = useCallback(e => {
|
||||
setDiscoverable(e.target.checked);
|
||||
}, [setDiscoverable]);
|
||||
|
||||
const handleAvatarChange = useCallback(e => {
|
||||
setAvatar(e.target?.files?.[0]);
|
||||
}, [setAvatar]);
|
||||
|
||||
const handleHeaderChange = useCallback(e => {
|
||||
setHeader(e.target?.files?.[0]);
|
||||
}, [setHeader]);
|
||||
|
||||
const avatarPreview = useMemo(() => avatar ? URL.createObjectURL(avatar) : nullIfMissing(account.get('avatar')), [avatar, account]);
|
||||
const headerPreview = useMemo(() => header ? URL.createObjectURL(header) : nullIfMissing(account.get('header')), [header, account]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
setIsSaving(true);
|
||||
|
||||
dispatch(updateAccount({
|
||||
displayName,
|
||||
note,
|
||||
avatar,
|
||||
header,
|
||||
discoverable,
|
||||
indexable: discoverable,
|
||||
})).then(() => history.push('/start/follows')).catch(err => {
|
||||
setIsSaving(false);
|
||||
setErrors(err.response.data.details);
|
||||
});
|
||||
}, [dispatch, displayName, note, avatar, header, discoverable, history]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ColumnBackButton />
|
||||
|
||||
<div className='scrollable privacy-policy'>
|
||||
<div className='column-title'>
|
||||
<h3><FormattedMessage id='onboarding.profile.title' defaultMessage='Profile setup' /></h3>
|
||||
<p><FormattedMessage id='onboarding.profile.lead' defaultMessage='You can always complete this later in the settings, where even more customization options are available.' /></p>
|
||||
</div>
|
||||
|
||||
<div className='simple_form'>
|
||||
<div className='onboarding__profile'>
|
||||
<label className={classNames('app-form__header-input', { selected: !!headerPreview, invalid: !!errors?.header })} title={intl.formatMessage(messages.uploadHeader)}>
|
||||
<input
|
||||
type='file'
|
||||
hidden
|
||||
ref={headerFileRef}
|
||||
accept='image/*'
|
||||
onChange={handleHeaderChange}
|
||||
/>
|
||||
|
||||
{headerPreview && <img src={headerPreview} alt='' />}
|
||||
|
||||
<Icon icon={headerPreview ? EditIcon : AddPhotoAlternateIcon} />
|
||||
</label>
|
||||
|
||||
<label className={classNames('app-form__avatar-input', { selected: !!avatarPreview, invalid: !!errors?.avatar })} title={intl.formatMessage(messages.uploadAvatar)}>
|
||||
<input
|
||||
type='file'
|
||||
hidden
|
||||
ref={avatarFileRef}
|
||||
accept='image/*'
|
||||
onChange={handleAvatarChange}
|
||||
/>
|
||||
|
||||
{avatarPreview && <img src={avatarPreview} alt='' />}
|
||||
|
||||
<Icon icon={avatarPreview ? EditIcon : AddPhotoAlternateIcon} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className={classNames('input with_block_label', { field_with_errors: !!errors?.display_name })}>
|
||||
<label htmlFor='display_name'><FormattedMessage id='onboarding.profile.display_name' defaultMessage='Display name' /></label>
|
||||
<span className='hint'><FormattedMessage id='onboarding.profile.display_name_hint' defaultMessage='Your full name or your fun name…' /></span>
|
||||
<div className='label_input'>
|
||||
<input id='display_name' type='text' value={displayName} onChange={handleDisplayNameChange} maxLength={30} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={classNames('input with_block_label', { field_with_errors: !!errors?.note })}>
|
||||
<label htmlFor='note'><FormattedMessage id='onboarding.profile.note' defaultMessage='Bio' /></label>
|
||||
<span className='hint'><FormattedMessage id='onboarding.profile.note_hint' defaultMessage='You can @mention other people or #hashtags…' /></span>
|
||||
<div className='label_input'>
|
||||
<textarea id='note' value={note} onChange={handleNoteChange} maxLength={500} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className='app-form__toggle'>
|
||||
<div className='app-form__toggle__label'>
|
||||
<strong><FormattedMessage id='onboarding.profile.discoverable' defaultMessage='Make my profile discoverable' /></strong> <span className='recommended'><FormattedMessage id='recommended' defaultMessage='Recommended' /></span>
|
||||
<span className='hint'><FormattedMessage id='onboarding.profile.discoverable_hint' defaultMessage='When you opt in to discoverability on Mastodon, your posts may appear in search results and trending, and your profile may be suggested to people with similar interests to you.' /></span>
|
||||
</div>
|
||||
|
||||
<div className='app-form__toggle__toggle'>
|
||||
<div>
|
||||
<Toggle checked={discoverable} onChange={handleDiscoverableChange} />
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className='onboarding__footer'>
|
||||
<Button block onClick={handleSubmit} disabled={isSaving}>{isSaving ? <LoadingIndicator /> : <FormattedMessage id='onboarding.profile.save_and_continue' defaultMessage='Save and continue' />}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
329
app/javascript/flavours/glitch/features/onboarding/profile.tsx
Normal file
329
app/javascript/flavours/glitch/features/onboarding/profile.tsx
Normal file
|
@ -0,0 +1,329 @@
|
|||
import { useState, useMemo, useCallback, createRef } from 'react';
|
||||
|
||||
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import Toggle from 'react-toggle';
|
||||
|
||||
import AddPhotoAlternateIcon from '@/material-icons/400-24px/add_photo_alternate.svg?react';
|
||||
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
|
||||
import PersonIcon from '@/material-icons/400-24px/person.svg?react';
|
||||
import { updateAccount } from 'flavours/glitch/actions/accounts';
|
||||
import { Button } from 'flavours/glitch/components/button';
|
||||
import Column from 'flavours/glitch/components/column';
|
||||
import { ColumnHeader } from 'flavours/glitch/components/column_header';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
||||
import { me } from 'flavours/glitch/initial_state';
|
||||
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
|
||||
import { unescapeHTML } from 'flavours/glitch/utils/html';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'onboarding.profile.title',
|
||||
defaultMessage: 'Profile setup',
|
||||
},
|
||||
uploadHeader: {
|
||||
id: 'onboarding.profile.upload_header',
|
||||
defaultMessage: 'Upload profile header',
|
||||
},
|
||||
uploadAvatar: {
|
||||
id: 'onboarding.profile.upload_avatar',
|
||||
defaultMessage: 'Upload profile picture',
|
||||
},
|
||||
});
|
||||
|
||||
const nullIfMissing = (path: string) =>
|
||||
path.endsWith('missing.png') ? null : path;
|
||||
|
||||
interface ApiAccountErrors {
|
||||
display_name?: unknown;
|
||||
note?: unknown;
|
||||
avatar?: unknown;
|
||||
header?: unknown;
|
||||
}
|
||||
|
||||
export const Profile: React.FC<{
|
||||
multiColumn?: boolean;
|
||||
}> = ({ multiColumn }) => {
|
||||
const account = useAppSelector((state) =>
|
||||
me ? state.accounts.get(me) : undefined,
|
||||
);
|
||||
const [displayName, setDisplayName] = useState(account?.display_name ?? '');
|
||||
const [note, setNote] = useState(
|
||||
account ? (unescapeHTML(account.note) ?? '') : '',
|
||||
);
|
||||
const [avatar, setAvatar] = useState<File>();
|
||||
const [header, setHeader] = useState<File>();
|
||||
const [discoverable, setDiscoverable] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [errors, setErrors] = useState<ApiAccountErrors>();
|
||||
const avatarFileRef = createRef<HTMLInputElement>();
|
||||
const headerFileRef = createRef<HTMLInputElement>();
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
const history = useHistory();
|
||||
|
||||
const handleDisplayNameChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setDisplayName(e.target.value);
|
||||
},
|
||||
[setDisplayName],
|
||||
);
|
||||
|
||||
const handleNoteChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setNote(e.target.value);
|
||||
},
|
||||
[setNote],
|
||||
);
|
||||
|
||||
const handleDiscoverableChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setDiscoverable(e.target.checked);
|
||||
},
|
||||
[setDiscoverable],
|
||||
);
|
||||
|
||||
const handleAvatarChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setAvatar(e.target.files?.[0]);
|
||||
},
|
||||
[setAvatar],
|
||||
);
|
||||
|
||||
const handleHeaderChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setHeader(e.target.files?.[0]);
|
||||
},
|
||||
[setHeader],
|
||||
);
|
||||
|
||||
const avatarPreview = useMemo(
|
||||
() =>
|
||||
avatar
|
||||
? URL.createObjectURL(avatar)
|
||||
: nullIfMissing(account?.avatar ?? 'missing.png'),
|
||||
[avatar, account],
|
||||
);
|
||||
const headerPreview = useMemo(
|
||||
() =>
|
||||
header
|
||||
? URL.createObjectURL(header)
|
||||
: nullIfMissing(account?.header ?? 'missing.png'),
|
||||
[header, account],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
setIsSaving(true);
|
||||
|
||||
dispatch(
|
||||
updateAccount({
|
||||
displayName,
|
||||
note,
|
||||
avatar,
|
||||
header,
|
||||
discoverable,
|
||||
indexable: discoverable,
|
||||
}),
|
||||
)
|
||||
.then(() => {
|
||||
history.push('/start/follows');
|
||||
return '';
|
||||
})
|
||||
// eslint-disable-next-line @typescript-eslint/use-unknown-in-catch-callback-variable
|
||||
.catch((err) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
if (err.response) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
||||
const { details }: { details: ApiAccountErrors } = err.response.data;
|
||||
setErrors(details);
|
||||
}
|
||||
|
||||
setIsSaving(false);
|
||||
});
|
||||
}, [dispatch, displayName, note, avatar, header, discoverable, history]);
|
||||
|
||||
return (
|
||||
<Column
|
||||
bindToDocument={!multiColumn}
|
||||
label={intl.formatMessage(messages.title)}
|
||||
>
|
||||
<ColumnHeader
|
||||
title={intl.formatMessage(messages.title)}
|
||||
icon='person'
|
||||
iconComponent={PersonIcon}
|
||||
multiColumn={multiColumn}
|
||||
/>
|
||||
|
||||
<div className='scrollable scrollable--flex'>
|
||||
<div className='simple_form app-form'>
|
||||
<div className='onboarding__profile'>
|
||||
<label
|
||||
className={classNames('app-form__header-input', {
|
||||
selected: !!headerPreview,
|
||||
invalid: !!errors?.header,
|
||||
})}
|
||||
title={intl.formatMessage(messages.uploadHeader)}
|
||||
>
|
||||
<input
|
||||
type='file'
|
||||
hidden
|
||||
ref={headerFileRef}
|
||||
accept='image/*'
|
||||
onChange={handleHeaderChange}
|
||||
/>
|
||||
|
||||
{headerPreview && <img src={headerPreview} alt='' />}
|
||||
|
||||
<Icon
|
||||
id=''
|
||||
icon={headerPreview ? EditIcon : AddPhotoAlternateIcon}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label
|
||||
className={classNames('app-form__avatar-input', {
|
||||
selected: !!avatarPreview,
|
||||
invalid: !!errors?.avatar,
|
||||
})}
|
||||
title={intl.formatMessage(messages.uploadAvatar)}
|
||||
>
|
||||
<input
|
||||
type='file'
|
||||
hidden
|
||||
ref={avatarFileRef}
|
||||
accept='image/*'
|
||||
onChange={handleAvatarChange}
|
||||
/>
|
||||
|
||||
{avatarPreview && <img src={avatarPreview} alt='' />}
|
||||
|
||||
<Icon
|
||||
id=''
|
||||
icon={avatarPreview ? EditIcon : AddPhotoAlternateIcon}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className='fields-group'>
|
||||
<div
|
||||
className={classNames('input with_block_label', {
|
||||
field_with_errors: !!errors?.display_name,
|
||||
})}
|
||||
>
|
||||
<label htmlFor='display_name'>
|
||||
<FormattedMessage
|
||||
id='onboarding.profile.display_name'
|
||||
defaultMessage='Display name'
|
||||
/>
|
||||
</label>
|
||||
<span className='hint'>
|
||||
<FormattedMessage
|
||||
id='onboarding.profile.display_name_hint'
|
||||
defaultMessage='Your full name or your fun name…'
|
||||
/>
|
||||
</span>
|
||||
<div className='label_input'>
|
||||
<input
|
||||
id='display_name'
|
||||
type='text'
|
||||
value={displayName}
|
||||
onChange={handleDisplayNameChange}
|
||||
maxLength={30}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='fields-group'>
|
||||
<div
|
||||
className={classNames('input with_block_label', {
|
||||
field_with_errors: !!errors?.note,
|
||||
})}
|
||||
>
|
||||
<label htmlFor='note'>
|
||||
<FormattedMessage
|
||||
id='onboarding.profile.note'
|
||||
defaultMessage='Bio'
|
||||
/>
|
||||
</label>
|
||||
<span className='hint'>
|
||||
<FormattedMessage
|
||||
id='onboarding.profile.note_hint'
|
||||
defaultMessage='You can @mention other people or #hashtags…'
|
||||
/>
|
||||
</span>
|
||||
<div className='label_input'>
|
||||
<textarea
|
||||
id='note'
|
||||
value={note}
|
||||
onChange={handleNoteChange}
|
||||
maxLength={500}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className='app-form__toggle'>
|
||||
<div className='app-form__toggle__label'>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
id='onboarding.profile.discoverable'
|
||||
defaultMessage='Make my profile discoverable'
|
||||
/>
|
||||
</strong>{' '}
|
||||
<span className='recommended'>
|
||||
<FormattedMessage
|
||||
id='recommended'
|
||||
defaultMessage='Recommended'
|
||||
/>
|
||||
</span>
|
||||
<span className='hint'>
|
||||
<FormattedMessage
|
||||
id='onboarding.profile.discoverable_hint'
|
||||
defaultMessage='When you opt in to discoverability on Mastodon, your posts may appear in search results and trending, and your profile may be suggested to people with similar interests to you.'
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='app-form__toggle__toggle'>
|
||||
<div>
|
||||
<Toggle
|
||||
checked={discoverable}
|
||||
onChange={handleDiscoverableChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className='spacer' />
|
||||
|
||||
<div className='column-footer'>
|
||||
<Button block onClick={handleSubmit} disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<LoadingIndicator />
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='onboarding.profile.save_and_continue'
|
||||
defaultMessage='Save and continue'
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.title)}</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default Profile;
|
|
@ -1,120 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
|
||||
import SwipeableViews from 'react-swipeable-views';
|
||||
|
||||
import ArrowRightAltIcon from '@/material-icons/400-24px/arrow_right_alt.svg?react';
|
||||
import { ColumnBackButton } from 'flavours/glitch/components/column_back_button';
|
||||
import { CopyPasteText } from 'flavours/glitch/components/copy_paste_text';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import { me, domain } from 'flavours/glitch/initial_state';
|
||||
import { useAppSelector } from 'flavours/glitch/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
shareableMessage: { id: 'onboarding.share.message', defaultMessage: 'I\'m {username} on #Mastodon! Come follow me at {url}' },
|
||||
});
|
||||
|
||||
class TipCarousel extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
state = {
|
||||
index: 0,
|
||||
};
|
||||
|
||||
handleSwipe = index => {
|
||||
this.setState({ index });
|
||||
};
|
||||
|
||||
handleChangeIndex = e => {
|
||||
this.setState({ index: Number(e.currentTarget.getAttribute('data-index')) });
|
||||
};
|
||||
|
||||
handleKeyDown = e => {
|
||||
switch(e.key) {
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
this.setState(({ index }, { children }) => ({ index: Math.abs(index - 1) % children.length }));
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
this.setState(({ index }, { children }) => ({ index: (index + 1) % children.length }));
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
render () {
|
||||
const { children } = this.props;
|
||||
const { index } = this.state;
|
||||
|
||||
return (
|
||||
<div className='tip-carousel' tabIndex='0' onKeyDown={this.handleKeyDown}>
|
||||
<SwipeableViews onChangeIndex={this.handleSwipe} index={index} enableMouseEvents tabIndex='-1'>
|
||||
{children}
|
||||
</SwipeableViews>
|
||||
|
||||
<div className='media-modal__pagination'>
|
||||
{children.map((_, i) => (
|
||||
<button key={i} className={classNames('media-modal__page-dot', { active: i === index })} data-index={i} onClick={this.handleChangeIndex}>
|
||||
{i + 1}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const Share = () => {
|
||||
const account = useAppSelector(state => state.getIn(['accounts', me]));
|
||||
const intl = useIntl();
|
||||
const url = (new URL(`/@${account.get('username')}`, document.baseURI)).href;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ColumnBackButton />
|
||||
|
||||
<div className='scrollable privacy-policy'>
|
||||
<div className='column-title'>
|
||||
<h3><FormattedMessage id='onboarding.share.title' defaultMessage='Share your profile' /></h3>
|
||||
<p><FormattedMessage id='onboarding.share.lead' defaultMessage='Let people know how they can find you on Mastodon!' /></p>
|
||||
</div>
|
||||
|
||||
<CopyPasteText value={intl.formatMessage(messages.shareableMessage, { username: `@${account.get('username')}@${domain}`, url })} />
|
||||
|
||||
<TipCarousel>
|
||||
<div><p className='onboarding__lead'><FormattedMessage id='onboarding.tips.verification' defaultMessage='<strong>Did you know?</strong> You can verify your account by putting a link to your Mastodon profile on your own website and adding the website to your profile. No fees or documents necessary!' values={{ strong: chunks => <strong>{chunks}</strong> }} /></p></div>
|
||||
<div><p className='onboarding__lead'><FormattedMessage id='onboarding.tips.migration' defaultMessage='<strong>Did you know?</strong> If you feel like {domain} is not a great server choice for you in the future, you can move to another Mastodon server without losing your followers. You can even host your own server!' values={{ domain, strong: chunks => <strong>{chunks}</strong> }} /></p></div>
|
||||
<div><p className='onboarding__lead'><FormattedMessage id='onboarding.tips.2fa' defaultMessage='<strong>Did you know?</strong> You can secure your account by setting up two-factor authentication in your account settings. It works with any TOTP app of your choice, no phone number necessary!' values={{ strong: chunks => <strong>{chunks}</strong> }} /></p></div>
|
||||
</TipCarousel>
|
||||
|
||||
<p className='onboarding__lead'><FormattedMessage id='onboarding.share.next_steps' defaultMessage='Possible next steps:' /></p>
|
||||
|
||||
<div className='onboarding__links'>
|
||||
<Link to='/home' className='onboarding__link'>
|
||||
<FormattedMessage id='onboarding.actions.go_to_home' defaultMessage='Take me to my home feed' />
|
||||
<Icon icon={ArrowRightAltIcon} />
|
||||
</Link>
|
||||
|
||||
<Link to='/explore' className='onboarding__link'>
|
||||
<FormattedMessage id='onboarding.actions.go_to_explore' defaultMessage='Take me to trending' />
|
||||
<Icon icon={ArrowRightAltIcon} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className='onboarding__footer'>
|
||||
<Link className='link-button' to='/start'><FormattedMessage id='onboarding.action.back' defaultMessage='Take me back' /></Link>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,25 +0,0 @@
|
|||
import { injectIntl } from 'react-intl';
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { pinAccount, unpinAccount } from 'flavours/glitch/actions/accounts';
|
||||
import Account from 'flavours/glitch/features/list_editor/components/account';
|
||||
import { makeGetAccount } from 'flavours/glitch/selectors';
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const getAccount = makeGetAccount();
|
||||
|
||||
const mapStateToProps = (state, { accountId, added }) => ({
|
||||
account: getAccount(state, accountId),
|
||||
added: typeof added === 'undefined' ? state.getIn(['pinnedAccountsEditor', 'accounts', 'items']).includes(accountId) : added,
|
||||
});
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
const mapDispatchToProps = (dispatch, { accountId }) => ({
|
||||
onRemove: () => dispatch(unpinAccount(accountId)),
|
||||
onAdd: () => dispatch(pinAccount(accountId)),
|
||||
});
|
||||
|
||||
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Account));
|
|
@ -1,24 +0,0 @@
|
|||
import { injectIntl } from 'react-intl';
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import Search from 'flavours/glitch/features/list_editor/components/search';
|
||||
|
||||
import {
|
||||
fetchPinnedAccountsSuggestions,
|
||||
clearPinnedAccountsSuggestions,
|
||||
changePinnedAccountsSuggestions,
|
||||
} from '../../../actions/accounts';
|
||||
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
value: state.getIn(['pinnedAccountsEditor', 'suggestions', 'value']),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onSubmit: value => dispatch(fetchPinnedAccountsSuggestions(value)),
|
||||
onClear: () => dispatch(clearPinnedAccountsSuggestions()),
|
||||
onChange: value => dispatch(changePinnedAccountsSuggestions(value)),
|
||||
});
|
||||
|
||||
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Search));
|
|
@ -1,82 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
import { injectIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import spring from 'react-motion/lib/spring';
|
||||
|
||||
import { fetchPinnedAccounts, clearPinnedAccountsSuggestions, resetPinnedAccountsEditor } from 'flavours/glitch/actions/accounts';
|
||||
import Motion from 'flavours/glitch/features/ui/util/optional_motion';
|
||||
|
||||
import AccountContainer from './containers/account_container';
|
||||
import SearchContainer from './containers/search_container';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
accountIds: state.getIn(['pinnedAccountsEditor', 'accounts', 'items']),
|
||||
searchAccountIds: state.getIn(['pinnedAccountsEditor', 'suggestions', 'items']),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onInitialize: () => dispatch(fetchPinnedAccounts()),
|
||||
onClear: () => dispatch(clearPinnedAccountsSuggestions()),
|
||||
onReset: () => dispatch(resetPinnedAccountsEditor()),
|
||||
});
|
||||
|
||||
class PinnedAccountsEditor extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
onClose: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
onInitialize: PropTypes.func.isRequired,
|
||||
onClear: PropTypes.func.isRequired,
|
||||
onReset: PropTypes.func.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
accountIds: ImmutablePropTypes.list.isRequired,
|
||||
searchAccountIds: ImmutablePropTypes.list.isRequired,
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
const { onInitialize } = this.props;
|
||||
onInitialize();
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
const { onReset } = this.props;
|
||||
onReset();
|
||||
}
|
||||
|
||||
render () {
|
||||
const { accountIds, searchAccountIds, onClear } = this.props;
|
||||
const showSearch = searchAccountIds.size > 0;
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal list-editor'>
|
||||
<h4><FormattedMessage id='endorsed_accounts_editor.endorsed_accounts' defaultMessage='Featured accounts' /></h4>
|
||||
|
||||
<SearchContainer />
|
||||
|
||||
<div className='drawer__pager'>
|
||||
<div className='drawer__inner list-editor__accounts'>
|
||||
{accountIds.map(accountId => <AccountContainer key={accountId} accountId={accountId} added />)}
|
||||
</div>
|
||||
|
||||
{showSearch && <div role='button' tabIndex={-1} className='drawer__backdrop' onClick={onClear} />}
|
||||
|
||||
<Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
|
||||
{({ x }) =>
|
||||
(<div className='drawer__inner backdrop' style={{ transform: x === 0 ? null : `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
|
||||
{searchAccountIds.map(accountId => <AccountContainer key={accountId} accountId={accountId} />)}
|
||||
</div>)
|
||||
}
|
||||
</Motion>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(PinnedAccountsEditor));
|
|
@ -147,7 +147,7 @@ class PublicTimeline extends PureComponent {
|
|||
</ColumnHeader>
|
||||
|
||||
<StatusListContainer
|
||||
prepend={<DismissableBanner id='public_timeline'><FormattedMessage id='dismissable_banner.public_timeline' defaultMessage='These are the most recent public posts from people on the social web that people on {domain} follow.' values={{ domain }} /></DismissableBanner>}
|
||||
prepend={<DismissableBanner id='public_timeline'><FormattedMessage id='dismissable_banner.public_timeline' defaultMessage='These are the most recent public posts from people on the fediverse that people on {domain} follow.' values={{ domain }} /></DismissableBanner>}
|
||||
timelineId={`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`}
|
||||
onLoadMore={this.handleLoadMore}
|
||||
trackScroll={!pinned}
|
||||
|
|
|
@ -5,7 +5,7 @@ import { FormattedMessage } from 'react-intl';
|
|||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import Immutable from 'immutable';
|
||||
import { is } from 'immutable';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
|
||||
import DescriptionIcon from '@/material-icons/400-24px/description-fill.svg?react';
|
||||
|
@ -62,7 +62,7 @@ export default class Card extends PureComponent {
|
|||
};
|
||||
|
||||
UNSAFE_componentWillReceiveProps (nextProps) {
|
||||
if (!Immutable.is(this.props.card, nextProps.card)) {
|
||||
if (!is(this.props.card, nextProps.card)) {
|
||||
this.setState({ embedded: false, previewLoaded: false });
|
||||
}
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@ import { Helmet } from 'react-helmet';
|
|||
import { withRouter } from 'react-router-dom';
|
||||
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import Immutable from 'immutable';
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
|
@ -84,7 +84,7 @@ const makeMapStateToProps = () => {
|
|||
(_, { id }) => id,
|
||||
state => state.getIn(['contexts', 'inReplyTos']),
|
||||
], (statusId, inReplyTos) => {
|
||||
let ancestorsIds = Immutable.List();
|
||||
let ancestorsIds = ImmutableList();
|
||||
ancestorsIds = ancestorsIds.withMutations(mutable => {
|
||||
let id = statusId;
|
||||
|
||||
|
@ -131,14 +131,14 @@ const makeMapStateToProps = () => {
|
|||
});
|
||||
}
|
||||
|
||||
return Immutable.List(descendantsIds);
|
||||
return ImmutableList(descendantsIds);
|
||||
});
|
||||
|
||||
const mapStateToProps = (state, props) => {
|
||||
const status = getStatus(state, { id: props.params.statusId });
|
||||
|
||||
let ancestorsIds = Immutable.List();
|
||||
let descendantsIds = Immutable.List();
|
||||
let ancestorsIds = ImmutableList();
|
||||
let descendantsIds = ImmutableList();
|
||||
|
||||
if (status) {
|
||||
ancestorsIds = getAncestorsIds(state, { id: status.get('in_reply_to_id') });
|
||||
|
|
|
@ -23,7 +23,7 @@ const getAccountLanguages = createSelector([
|
|||
(state, accountId) => state.getIn(['timelines', `account:${accountId}`, 'items'], ImmutableList()),
|
||||
state => state.get('statuses'),
|
||||
], (statusIds, statuses) =>
|
||||
new ImmutableSet(statusIds.map(statusId => statuses.get(statusId)).filter(status => !status.get('reblog')).map(status => status.get('language'))));
|
||||
ImmutableSet(statusIds.map(statusId => statuses.get(statusId)).filter(status => !status.get('reblog')).map(status => status.get('language'))));
|
||||
|
||||
const mapStateToProps = (state, { accountId }) => ({
|
||||
acct: state.getIn(['accounts', accountId, 'acct']),
|
||||
|
|
|
@ -136,6 +136,8 @@ export const BoostModal: React.FC<{
|
|||
? messages.cancel_reblog
|
||||
: messages.reblog,
|
||||
)}
|
||||
/* eslint-disable-next-line jsx-a11y/no-autofocus -- We are in the modal */
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -9,58 +9,7 @@ import { Link } from 'react-router-dom';
|
|||
|
||||
import { Button } from 'flavours/glitch/components/button';
|
||||
import Column from 'flavours/glitch/components/column';
|
||||
import { autoPlayGif } from 'flavours/glitch/initial_state';
|
||||
|
||||
class GIF extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
src: PropTypes.string.isRequired,
|
||||
staticSrc: PropTypes.string.isRequired,
|
||||
className: PropTypes.string,
|
||||
animate: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
animate: autoPlayGif,
|
||||
};
|
||||
|
||||
state = {
|
||||
hovering: false,
|
||||
};
|
||||
|
||||
handleMouseEnter = () => {
|
||||
const { animate } = this.props;
|
||||
|
||||
if (!animate) {
|
||||
this.setState({ hovering: true });
|
||||
}
|
||||
};
|
||||
|
||||
handleMouseLeave = () => {
|
||||
const { animate } = this.props;
|
||||
|
||||
if (!animate) {
|
||||
this.setState({ hovering: false });
|
||||
}
|
||||
};
|
||||
|
||||
render () {
|
||||
const { src, staticSrc, className, animate } = this.props;
|
||||
const { hovering } = this.state;
|
||||
|
||||
return (
|
||||
<img
|
||||
className={className}
|
||||
src={(hovering || animate) ? src : staticSrc}
|
||||
alt=''
|
||||
role='presentation'
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
import { GIF } from 'flavours/glitch/components/gif';
|
||||
|
||||
class CopyButton extends PureComponent {
|
||||
|
||||
|
|
|
@ -1,56 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import RefreshIcon from '@/material-icons/400-24px/refresh.svg?react';
|
||||
|
||||
import { IconButton } from '../../../components/icon_button';
|
||||
|
||||
const messages = defineMessages({
|
||||
error: { id: 'bundle_modal_error.message', defaultMessage: 'Something went wrong while loading this component.' },
|
||||
retry: { id: 'bundle_modal_error.retry', defaultMessage: 'Try again' },
|
||||
close: { id: 'bundle_modal_error.close', defaultMessage: 'Close' },
|
||||
});
|
||||
|
||||
class BundleModalError extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
onRetry: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
handleRetry = () => {
|
||||
this.props.onRetry();
|
||||
};
|
||||
|
||||
render () {
|
||||
const { onClose, intl: { formatMessage } } = this.props;
|
||||
|
||||
// Keep the markup in sync with <ModalLoading />
|
||||
// (make sure they have the same dimensions)
|
||||
return (
|
||||
<div className='modal-root__modal error-modal'>
|
||||
<div className='error-modal__body'>
|
||||
<IconButton title={formatMessage(messages.retry)} icon='refresh' iconComponent={RefreshIcon} onClick={this.handleRetry} size={64} />
|
||||
{formatMessage(messages.error)}
|
||||
</div>
|
||||
|
||||
<div className='error-modal__footer'>
|
||||
<div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className='error-modal__nav onboarding-modal__skip'
|
||||
>
|
||||
{formatMessage(messages.close)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default injectIntl(BundleModalError);
|
|
@ -8,7 +8,7 @@ import { scrollRight } from '../../../scroll';
|
|||
import BundleContainer from '../containers/bundle_container';
|
||||
import {
|
||||
Compose,
|
||||
NotificationsWrapper,
|
||||
Notifications,
|
||||
HomeTimeline,
|
||||
CommunityTimeline,
|
||||
PublicTimeline,
|
||||
|
@ -30,7 +30,7 @@ import NavigationPanel from './navigation_panel';
|
|||
const componentMap = {
|
||||
'COMPOSE': Compose,
|
||||
'HOME': HomeTimeline,
|
||||
'NOTIFICATIONS': NotificationsWrapper,
|
||||
'NOTIFICATIONS': Notifications,
|
||||
'PUBLIC': PublicTimeline,
|
||||
'REMOTE': PublicTimeline,
|
||||
'COMMUNITY': CommunityTimeline,
|
||||
|
|
|
@ -82,6 +82,8 @@ export const FavouriteModal: React.FC<{
|
|||
<Button
|
||||
onClick={handleFavourite}
|
||||
text={intl.formatMessage(messages.favourite)}
|
||||
/* eslint-disable-next-line jsx-a11y/no-autofocus -- We are in the modal */
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,18 +0,0 @@
|
|||
import { LoadingIndicator } from '../../../components/loading_indicator';
|
||||
|
||||
// Keep the markup in sync with <BundleModalError />
|
||||
// (make sure they have the same dimensions)
|
||||
const ModalLoading = () => (
|
||||
<div className='modal-root__modal error-modal'>
|
||||
<div className='error-modal__body'>
|
||||
<LoadingIndicator />
|
||||
</div>
|
||||
<div className='error-modal__footer'>
|
||||
<div>
|
||||
<button className='error-modal__nav onboarding-modal__skip' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default ModalLoading;
|
|
@ -0,0 +1,61 @@
|
|||
import { useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Button } from 'flavours/glitch/components/button';
|
||||
import { GIF } from 'flavours/glitch/components/gif';
|
||||
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
||||
|
||||
export const ModalPlaceholder: React.FC<{
|
||||
loading: boolean;
|
||||
onClose: (arg0: string | undefined, arg1: boolean) => void;
|
||||
onRetry?: () => void;
|
||||
}> = ({ loading, onClose, onRetry }) => {
|
||||
const handleClose = useCallback(() => {
|
||||
onClose(undefined, false);
|
||||
}, [onClose]);
|
||||
|
||||
const handleRetry = useCallback(() => {
|
||||
if (onRetry) onRetry();
|
||||
}, [onRetry]);
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal modal-placeholder' aria-busy={loading}>
|
||||
{loading ? (
|
||||
<LoadingIndicator />
|
||||
) : (
|
||||
<div className='modal-placeholder__error'>
|
||||
<GIF
|
||||
src='/oops.gif'
|
||||
staticSrc='/oops.png'
|
||||
className='modal-placeholder__error__image'
|
||||
/>
|
||||
|
||||
<div className='modal-placeholder__error__message'>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id='bundle_modal_error.message'
|
||||
defaultMessage='Something went wrong while loading this screen.'
|
||||
/>
|
||||
</p>
|
||||
|
||||
<div className='modal-placeholder__error__message__actions'>
|
||||
<Button onClick={handleRetry}>
|
||||
<FormattedMessage
|
||||
id='bundle_modal_error.retry'
|
||||
defaultMessage='Try again'
|
||||
/>
|
||||
</Button>
|
||||
<Button onClick={handleClose} className='button button-tertiary'>
|
||||
<FormattedMessage
|
||||
id='bundle_modal_error.close'
|
||||
defaultMessage='Close'
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -11,9 +11,7 @@ import {
|
|||
ReportModal,
|
||||
SettingsModal,
|
||||
EmbedModal,
|
||||
ListEditor,
|
||||
ListAdder,
|
||||
PinnedAccountsEditor,
|
||||
CompareHistoryModal,
|
||||
FilterModal,
|
||||
InteractionModal,
|
||||
|
@ -29,7 +27,6 @@ import BundleContainer from '../containers/bundle_container';
|
|||
import ActionsModal from './actions_modal';
|
||||
import AudioModal from './audio_modal';
|
||||
import { BoostModal } from './boost_modal';
|
||||
import BundleModalError from './bundle_modal_error';
|
||||
import {
|
||||
ConfirmationModal,
|
||||
ConfirmDeleteStatusModal,
|
||||
|
@ -46,7 +43,7 @@ import { FavouriteModal } from './favourite_modal';
|
|||
import FocalPointModal from './focal_point_modal';
|
||||
import ImageModal from './image_modal';
|
||||
import MediaModal from './media_modal';
|
||||
import ModalLoading from './modal_loading';
|
||||
import { ModalPlaceholder } from './modal_placeholder';
|
||||
import VideoModal from './video_modal';
|
||||
|
||||
export const MODAL_COMPONENTS = {
|
||||
|
@ -73,10 +70,8 @@ export const MODAL_COMPONENTS = {
|
|||
'DEPRECATED_SETTINGS': () => Promise.resolve({ default: DeprecatedSettingsModal }),
|
||||
'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
|
||||
'EMBED': EmbedModal,
|
||||
'LIST_EDITOR': ListEditor,
|
||||
'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }),
|
||||
'LIST_ADDER': ListAdder,
|
||||
'PINNED_ACCOUNTS_EDITOR': PinnedAccountsEditor,
|
||||
'COMPARE_HISTORY': CompareHistoryModal,
|
||||
'FILTER': FilterModal,
|
||||
'SUBSCRIBED_LANGUAGES': SubscribedLanguagesModal,
|
||||
|
@ -113,14 +108,16 @@ export default class ModalRoot extends PureComponent {
|
|||
this.setState({ backgroundColor: color });
|
||||
};
|
||||
|
||||
renderLoading = modalId => () => {
|
||||
return ['MEDIA', 'VIDEO', 'BOOST', 'FAVOURITE', 'DOODLE', 'CONFIRM', 'ACTIONS'].indexOf(modalId) === -1 ? <ModalLoading /> : null;
|
||||
renderLoading = () => {
|
||||
const { onClose } = this.props;
|
||||
|
||||
return <ModalPlaceholder loading onClose={onClose} />;
|
||||
};
|
||||
|
||||
renderError = (props) => {
|
||||
const { onClose } = this.props;
|
||||
|
||||
return <BundleModalError {...props} onClose={onClose} />;
|
||||
return <ModalPlaceholder {...props} onClose={onClose} />;
|
||||
};
|
||||
|
||||
handleClose = (ignoreFocus = false) => {
|
||||
|
@ -145,7 +142,7 @@ export default class ModalRoot extends PureComponent {
|
|||
<Base backgroundColor={backgroundColor} onClose={props && props.noClose ? this.noop : this.handleClose} noEsc={props ? props.noEsc : false} ignoreFocus={ignoreFocus}>
|
||||
{visible && (
|
||||
<>
|
||||
<BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}>
|
||||
<BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading} error={this.renderError} renderDelay={200}>
|
||||
{(SpecificComponent) => {
|
||||
const ref = typeof SpecificComponent !== 'function' ? this.setModalRef : undefined;
|
||||
return <SpecificComponent {...props} onChangeBackgroundColor={this.setBackgroundColor} onClose={this.handleClose} ref={ref} />;
|
||||
|
|
|
@ -53,7 +53,7 @@ import {
|
|||
Favourites,
|
||||
DirectTimeline,
|
||||
HashtagTimeline,
|
||||
NotificationsWrapper,
|
||||
Notifications,
|
||||
NotificationRequests,
|
||||
NotificationRequest,
|
||||
FollowRequests,
|
||||
|
@ -62,15 +62,18 @@ import {
|
|||
FollowedTags,
|
||||
LinkTimeline,
|
||||
ListTimeline,
|
||||
Lists,
|
||||
ListEdit,
|
||||
ListMembers,
|
||||
Blocks,
|
||||
DomainBlocks,
|
||||
Mutes,
|
||||
PinnedStatuses,
|
||||
Lists,
|
||||
GettingStartedMisc,
|
||||
Directory,
|
||||
OnboardingProfile,
|
||||
OnboardingFollows,
|
||||
Explore,
|
||||
Onboarding,
|
||||
About,
|
||||
PrivacyPolicy,
|
||||
} from './util/async-components';
|
||||
|
@ -216,8 +219,11 @@ class SwitchingColumnsArea extends PureComponent {
|
|||
<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/new' component={ListEdit} content={children} />
|
||||
<WrappedRoute path='/lists/:id/edit' component={ListEdit} content={children} />
|
||||
<WrappedRoute path='/lists/:id/members' component={ListMembers} content={children} />
|
||||
<WrappedRoute path='/lists/:id' component={ListTimeline} content={children} />
|
||||
<WrappedRoute path='/notifications' component={NotificationsWrapper} content={children} exact />
|
||||
<WrappedRoute path='/notifications' component={Notifications} content={children} exact />
|
||||
<WrappedRoute path='/notifications/requests' component={NotificationRequests} content={children} exact />
|
||||
<WrappedRoute path='/notifications/requests/:id' component={NotificationRequest} content={children} exact />
|
||||
<WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
|
||||
|
@ -225,7 +231,8 @@ class SwitchingColumnsArea extends PureComponent {
|
|||
<WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} />
|
||||
<WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
|
||||
|
||||
<WrappedRoute path='/start' component={Onboarding} content={children} />
|
||||
<WrappedRoute path={['/start', '/start/profile']} exact component={OnboardingProfile} content={children} />
|
||||
<WrappedRoute path='/start/follows' component={OnboardingFollows} content={children} />
|
||||
<WrappedRoute path='/directory' component={Directory} content={children} />
|
||||
<WrappedRoute path={['/explore', '/search']} component={Explore} content={children} />
|
||||
<WrappedRoute path={['/publish', '/statuses/new']} component={Compose} content={children} />
|
||||
|
|
|
@ -7,15 +7,7 @@ export function Compose () {
|
|||
}
|
||||
|
||||
export function Notifications () {
|
||||
return import(/* webpackChunkName: "flavours/glitch/async/notifications_v1" */'../../notifications');
|
||||
}
|
||||
|
||||
export function Notifications_v2 () {
|
||||
return import(/* webpackChunkName: "flavours/glitch/async/notifications_v2" */'../../notifications_v2');
|
||||
}
|
||||
|
||||
export function NotificationsWrapper () {
|
||||
return import(/* webpackChunkName: "flavours/glitch/async/notifications" */'../../notifications_wrapper');
|
||||
return import(/* webpackChunkName: "flavours/glitch/async/notifications" */'../../notifications_v2');
|
||||
}
|
||||
|
||||
export function HomeTimeline () {
|
||||
|
@ -46,10 +38,6 @@ export function Lists () {
|
|||
return import(/* webpackChunkName: "flavours/glitch/async/lists" */'../../lists');
|
||||
}
|
||||
|
||||
export function PinnedAccountsEditor () {
|
||||
return import(/* webpackChunkName: "flavours/glitch/async/pinned_accounts_editor" */'../../pinned_accounts_editor');
|
||||
}
|
||||
|
||||
export function DirectTimeline() {
|
||||
return import(/* webpackChunkName: "flavours/glitch/async/direct_timeline" */'../../direct_timeline');
|
||||
}
|
||||
|
@ -162,10 +150,6 @@ export function EmbedModal () {
|
|||
return import(/* webpackChunkName: "flavours/glitch/async/embed_modal" */'../components/embed_modal');
|
||||
}
|
||||
|
||||
export function ListEditor () {
|
||||
return import(/* webpackChunkName: "flavours/glitch/async/list_editor" */'../../list_editor');
|
||||
}
|
||||
|
||||
export function ListAdder () {
|
||||
return import(/* webpackChunkName: "features/glitch/async/list_adder" */'../../list_adder');
|
||||
}
|
||||
|
@ -182,8 +166,12 @@ export function Directory () {
|
|||
return import(/* webpackChunkName: "features/glitch/async/directory" */'../../directory');
|
||||
}
|
||||
|
||||
export function Onboarding () {
|
||||
return import(/* webpackChunkName: "features/glitch/async/onboarding" */'../../onboarding');
|
||||
export function OnboardingProfile () {
|
||||
return import(/* webpackChunkName: "features/glitch/async/onboarding" */'../../onboarding/profile');
|
||||
}
|
||||
|
||||
export function OnboardingFollows () {
|
||||
return import(/* webpackChunkName: "features/glitch/async/onboarding" */'../../onboarding/follows');
|
||||
}
|
||||
|
||||
export function CompareHistoryModal () {
|
||||
|
@ -233,3 +221,11 @@ export function LinkTimeline () {
|
|||
export function AnnualReportModal () {
|
||||
return import(/*webpackChunkName: "flavours/glitch/async/modals/annual_report_modal" */'../components/annual_report_modal');
|
||||
}
|
||||
|
||||
export function ListEdit () {
|
||||
return import(/*webpackChunkName: "flavours/glitch/async/lists" */'../../lists/new');
|
||||
}
|
||||
|
||||
export function ListMembers () {
|
||||
return import(/* webpackChunkName: "flavours/glitch/async/lists" */'../../lists/members');
|
||||
}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue