Merge pull request #2910 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changes up to 75ecc6df06
pull/2911/head
commit
5550f53a7e
|
@ -36,4 +36,4 @@ jobs:
|
||||||
bundler-cache: true
|
bundler-cache: true
|
||||||
|
|
||||||
- name: Run bundler-audit
|
- name: Run bundler-audit
|
||||||
run: bundle exec bundler-audit check --update
|
run: bin/bundler-audit check --update
|
||||||
|
|
|
@ -35,18 +35,18 @@ jobs:
|
||||||
git diff --exit-code
|
git diff --exit-code
|
||||||
|
|
||||||
- name: Check locale file normalization
|
- name: Check locale file normalization
|
||||||
run: bundle exec i18n-tasks check-normalized
|
run: bin/i18n-tasks check-normalized
|
||||||
|
|
||||||
- name: Check for unused strings
|
- name: Check for unused strings
|
||||||
run: bundle exec i18n-tasks unused
|
run: bin/i18n-tasks unused
|
||||||
|
|
||||||
- name: Check for missing strings in English YML
|
- name: Check for missing strings in English YML
|
||||||
run: |
|
run: |
|
||||||
bundle exec i18n-tasks add-missing -l en
|
bin/i18n-tasks add-missing -l en
|
||||||
git diff --exit-code
|
git diff --exit-code
|
||||||
|
|
||||||
- name: Check for wrong string interpolations
|
- 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
|
- 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
|
uses: ./.github/actions/setup-ruby
|
||||||
|
|
||||||
- name: Run i18n normalize task
|
- name: Run i18n normalize task
|
||||||
run: bundle exec i18n-tasks normalize
|
run: bin/i18n-tasks normalize
|
||||||
|
|
||||||
# Create or update the pull request
|
# Create or update the pull request
|
||||||
- name: Create Pull Request
|
- name: Create Pull Request
|
||||||
|
|
|
@ -49,7 +49,7 @@ jobs:
|
||||||
uses: ./.github/actions/setup-ruby
|
uses: ./.github/actions/setup-ruby
|
||||||
|
|
||||||
- name: Run i18n normalize task
|
- name: Run i18n normalize task
|
||||||
run: bundle exec i18n-tasks normalize
|
run: bin/i18n-tasks normalize
|
||||||
|
|
||||||
# Create or update the pull request
|
# Create or update the pull request
|
||||||
- name: Create Pull Request
|
- name: Create Pull Request
|
||||||
|
|
|
@ -43,4 +43,4 @@ jobs:
|
||||||
- name: Run haml-lint
|
- name: Run haml-lint
|
||||||
run: |
|
run: |
|
||||||
echo "::add-matcher::.github/workflows/haml-lint-problem-matcher.json"
|
echo "::add-matcher::.github/workflows/haml-lint-problem-matcher.json"
|
||||||
bundle exec haml-lint --reporter github
|
bin/haml-lint --reporter github
|
||||||
|
|
|
@ -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`: 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`: 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
|
- `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
|
- `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)\
|
- **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.\
|
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
|
# This file is designed for production server deployment, not local development work
|
||||||
# For a containerized local dev environment, see: https://github.com/mastodon/mastodon/blob/main/README.md#docker
|
# For a containerized local dev environment, see: https://github.com/mastodon/mastodon/blob/main/README.md#docker
|
||||||
|
|
43
Gemfile.lock
43
Gemfile.lock
|
@ -93,10 +93,9 @@ GEM
|
||||||
annotaterb (4.13.0)
|
annotaterb (4.13.0)
|
||||||
ast (2.4.2)
|
ast (2.4.2)
|
||||||
attr_required (1.0.2)
|
attr_required (1.0.2)
|
||||||
awrence (1.2.1)
|
|
||||||
aws-eventstream (1.3.0)
|
aws-eventstream (1.3.0)
|
||||||
aws-partitions (1.1012.0)
|
aws-partitions (1.1013.0)
|
||||||
aws-sdk-core (3.213.0)
|
aws-sdk-core (3.214.0)
|
||||||
aws-eventstream (~> 1, >= 1.3.0)
|
aws-eventstream (~> 1, >= 1.3.0)
|
||||||
aws-partitions (~> 1, >= 1.992.0)
|
aws-partitions (~> 1, >= 1.992.0)
|
||||||
aws-sigv4 (~> 1.9)
|
aws-sigv4 (~> 1.9)
|
||||||
|
@ -104,7 +103,7 @@ GEM
|
||||||
aws-sdk-kms (1.96.0)
|
aws-sdk-kms (1.96.0)
|
||||||
aws-sdk-core (~> 3, >= 3.210.0)
|
aws-sdk-core (~> 3, >= 3.210.0)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sdk-s3 (1.173.0)
|
aws-sdk-s3 (1.174.0)
|
||||||
aws-sdk-core (~> 3, >= 3.210.0)
|
aws-sdk-core (~> 3, >= 3.210.0)
|
||||||
aws-sdk-kms (~> 1)
|
aws-sdk-kms (~> 1)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
|
@ -349,7 +348,8 @@ GEM
|
||||||
json-schema (5.1.0)
|
json-schema (5.1.0)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
jsonapi-renderer (0.2.2)
|
jsonapi-renderer (0.2.2)
|
||||||
jwt (2.7.1)
|
jwt (2.9.3)
|
||||||
|
base64
|
||||||
kaminari (1.2.2)
|
kaminari (1.2.2)
|
||||||
activesupport (>= 4.1.0)
|
activesupport (>= 4.1.0)
|
||||||
kaminari-actionview (= 1.2.2)
|
kaminari-actionview (= 1.2.2)
|
||||||
|
@ -411,7 +411,7 @@ GEM
|
||||||
minitest (5.25.1)
|
minitest (5.25.1)
|
||||||
msgpack (1.7.5)
|
msgpack (1.7.5)
|
||||||
multi_json (1.15.0)
|
multi_json (1.15.0)
|
||||||
mutex_m (0.2.0)
|
mutex_m (0.3.0)
|
||||||
net-http (0.5.0)
|
net-http (0.5.0)
|
||||||
uri
|
uri
|
||||||
net-imap (0.5.1)
|
net-imap (0.5.1)
|
||||||
|
@ -424,7 +424,7 @@ GEM
|
||||||
timeout
|
timeout
|
||||||
net-smtp (0.5.0)
|
net-smtp (0.5.0)
|
||||||
net-protocol
|
net-protocol
|
||||||
nio4r (2.7.3)
|
nio4r (2.7.4)
|
||||||
nokogiri (1.16.7)
|
nokogiri (1.16.7)
|
||||||
mini_portile2 (~> 2.8.2)
|
mini_portile2 (~> 2.8.2)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
|
@ -472,7 +472,7 @@ GEM
|
||||||
opentelemetry-common (~> 0.20)
|
opentelemetry-common (~> 0.20)
|
||||||
opentelemetry-sdk (~> 1.2)
|
opentelemetry-sdk (~> 1.2)
|
||||||
opentelemetry-semantic_conventions
|
opentelemetry-semantic_conventions
|
||||||
opentelemetry-helpers-sql-obfuscation (0.2.0)
|
opentelemetry-helpers-sql-obfuscation (0.2.1)
|
||||||
opentelemetry-common (~> 0.21)
|
opentelemetry-common (~> 0.21)
|
||||||
opentelemetry-instrumentation-action_mailer (0.2.0)
|
opentelemetry-instrumentation-action_mailer (0.2.0)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
|
@ -492,7 +492,7 @@ GEM
|
||||||
opentelemetry-instrumentation-active_model_serializers (0.20.2)
|
opentelemetry-instrumentation-active_model_serializers (0.20.2)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
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-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||||
opentelemetry-instrumentation-active_support (0.6.0)
|
opentelemetry-instrumentation-active_support (0.6.0)
|
||||||
|
@ -505,29 +505,29 @@ GEM
|
||||||
opentelemetry-instrumentation-concurrent_ruby (0.21.4)
|
opentelemetry-instrumentation-concurrent_ruby (0.21.4)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||||
opentelemetry-instrumentation-excon (0.22.4)
|
opentelemetry-instrumentation-excon (0.22.5)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||||
opentelemetry-instrumentation-faraday (0.24.6)
|
opentelemetry-instrumentation-faraday (0.24.7)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||||
opentelemetry-instrumentation-http (0.23.4)
|
opentelemetry-instrumentation-http (0.23.5)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||||
opentelemetry-instrumentation-http_client (0.22.7)
|
opentelemetry-instrumentation-http_client (0.22.8)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
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-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||||
opentelemetry-instrumentation-pg (0.29.0)
|
opentelemetry-instrumentation-pg (0.29.1)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-helpers-sql-obfuscation
|
opentelemetry-helpers-sql-obfuscation
|
||||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||||
opentelemetry-instrumentation-rack (0.25.0)
|
opentelemetry-instrumentation-rack (0.25.0)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||||
opentelemetry-instrumentation-rails (0.33.0)
|
opentelemetry-instrumentation-rails (0.33.1)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-action_mailer (~> 0.2.0)
|
opentelemetry-instrumentation-action_mailer (~> 0.2.0)
|
||||||
opentelemetry-instrumentation-action_pack (~> 0.10.0)
|
opentelemetry-instrumentation-action_pack (~> 0.10.0)
|
||||||
|
@ -580,7 +580,7 @@ GEM
|
||||||
psych (5.2.0)
|
psych (5.2.0)
|
||||||
stringio
|
stringio
|
||||||
public_suffix (6.0.1)
|
public_suffix (6.0.1)
|
||||||
puma (6.4.3)
|
puma (6.5.0)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
pundit (2.4.0)
|
pundit (2.4.0)
|
||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
|
@ -752,7 +752,7 @@ GEM
|
||||||
activerecord (>= 4.0.0)
|
activerecord (>= 4.0.0)
|
||||||
railties (>= 4.0.0)
|
railties (>= 4.0.0)
|
||||||
securerandom (0.3.2)
|
securerandom (0.3.2)
|
||||||
selenium-webdriver (4.26.0)
|
selenium-webdriver (4.27.0)
|
||||||
base64 (~> 0.2)
|
base64 (~> 0.2)
|
||||||
logger (~> 1.4)
|
logger (~> 1.4)
|
||||||
rexml (~> 3.2, >= 3.2.5)
|
rexml (~> 3.2, >= 3.2.5)
|
||||||
|
@ -844,9 +844,8 @@ GEM
|
||||||
public_suffix
|
public_suffix
|
||||||
warden (1.2.9)
|
warden (1.2.9)
|
||||||
rack (>= 2.0.9)
|
rack (>= 2.0.9)
|
||||||
webauthn (3.1.0)
|
webauthn (3.2.2)
|
||||||
android_key_attestation (~> 0.3.0)
|
android_key_attestation (~> 0.3.0)
|
||||||
awrence (~> 1.1)
|
|
||||||
bindata (~> 2.4)
|
bindata (~> 2.4)
|
||||||
cbor (~> 0.5.9)
|
cbor (~> 0.5.9)
|
||||||
cose (~> 1.1)
|
cose (~> 1.1)
|
||||||
|
@ -1031,7 +1030,7 @@ DEPENDENCIES
|
||||||
xorcist (~> 1.1)
|
xorcist (~> 1.1)
|
||||||
|
|
||||||
RUBY VERSION
|
RUBY VERSION
|
||||||
ruby 3.3.5p100
|
ruby 3.3.6p108
|
||||||
|
|
||||||
BUNDLED WITH
|
BUNDLED WITH
|
||||||
2.5.22
|
2.5.23
|
||||||
|
|
|
@ -25,7 +25,6 @@ class ApplicationController < ActionController::Base
|
||||||
helper_method :use_seamless_external_login?
|
helper_method :use_seamless_external_login?
|
||||||
helper_method :sso_account_settings
|
helper_method :sso_account_settings
|
||||||
helper_method :limited_federation_mode?
|
helper_method :limited_federation_mode?
|
||||||
helper_method :body_class_string
|
|
||||||
helper_method :skip_csrf_meta_tags?
|
helper_method :skip_csrf_meta_tags?
|
||||||
|
|
||||||
rescue_from ActionController::ParameterMissing, Paperclip::AdapterRegistry::NoHandlerError, with: :bad_request
|
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?
|
@current_session = SessionActivation.find_by(session_id: cookies.signed['_session_id']) if cookies.signed['_session_id'].present?
|
||||||
end
|
end
|
||||||
|
|
||||||
def body_class_string
|
|
||||||
@body_classes || ''
|
|
||||||
end
|
|
||||||
|
|
||||||
def respond_with_error(code)
|
def respond_with_error(code)
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
format.any { render "errors/#{code}", layout: 'error', status: code, formats: [:html] }
|
format.any { render "errors/#{code}", layout: 'error', status: code, formats: [:html] }
|
||||||
|
|
|
@ -28,7 +28,7 @@ module CacheConcern
|
||||||
def render_with_cache(**options)
|
def render_with_cache(**options)
|
||||||
raise ArgumentError, 'Only JSON render calls are supported' unless options.key?(:json) || block_given?
|
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
|
expires_in = options.delete(:expires_in) || 3.minutes
|
||||||
body = Rails.cache.read(key, raw: true)
|
body = Rails.cache.read(key, raw: true)
|
||||||
|
|
||||||
|
|
|
@ -143,7 +143,7 @@ module ApplicationHelper
|
||||||
end
|
end
|
||||||
|
|
||||||
def body_classes
|
def body_classes
|
||||||
output = body_class_string.split
|
output = []
|
||||||
output << content_for(:body_classes)
|
output << content_for(:body_classes)
|
||||||
output << "flavour-#{current_flavour.parameterize}"
|
output << "flavour-#{current_flavour.parameterize}"
|
||||||
output << "skin-#{current_skin.parameterize}"
|
output << "skin-#{current_skin.parameterize}"
|
||||||
|
|
|
@ -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(
|
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() {
|
function main() {
|
||||||
ready(loaded).catch((error: unknown) => {
|
ready(loaded).catch((error: unknown) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
|
@ -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(() => {});
|
|
||||||
};
|
|
|
@ -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),
|
||||||
|
);
|
|
@ -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}`);
|
|
@ -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 MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||||
import { EmptyAccount } from 'flavours/glitch/components/empty_account';
|
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 { ShortNumber } from 'flavours/glitch/components/short_number';
|
||||||
import { VerifiedBadge } from 'flavours/glitch/components/verified_badge';
|
import { VerifiedBadge } from 'flavours/glitch/components/verified_badge';
|
||||||
|
|
||||||
|
@ -23,9 +24,6 @@ import { Permalink } from './permalink';
|
||||||
import { RelativeTimestamp } from './relative_timestamp';
|
import { RelativeTimestamp } from './relative_timestamp';
|
||||||
|
|
||||||
const messages = defineMessages({
|
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' },
|
unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' },
|
||||||
unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' },
|
unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' },
|
||||||
mute_notifications: { id: 'account.mute_notifications_short', defaultMessage: 'Mute notifications' },
|
mute_notifications: { id: 'account.mute_notifications_short', defaultMessage: 'Mute notifications' },
|
||||||
|
@ -35,13 +33,9 @@ const messages = defineMessages({
|
||||||
more: { id: 'status.more', defaultMessage: 'More' },
|
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 intl = useIntl();
|
||||||
|
|
||||||
const handleFollow = useCallback(() => {
|
|
||||||
onFollow(account);
|
|
||||||
}, [onFollow, account]);
|
|
||||||
|
|
||||||
const handleBlock = useCallback(() => {
|
const handleBlock = useCallback(() => {
|
||||||
onBlock(account);
|
onBlock(account);
|
||||||
}, [onBlock, account]);
|
}, [onBlock, account]);
|
||||||
|
@ -74,13 +68,12 @@ const Account = ({ size = 46, account, onFollow, onBlock, onMute, onMuteNotifica
|
||||||
let buttons;
|
let buttons;
|
||||||
|
|
||||||
if (account.get('id') !== me && account.get('relationship', null) !== null) {
|
if (account.get('id') !== me && account.get('relationship', null) !== null) {
|
||||||
const following = account.getIn(['relationship', 'following']);
|
|
||||||
const requested = account.getIn(['relationship', 'requested']);
|
const requested = account.getIn(['relationship', 'requested']);
|
||||||
const blocking = account.getIn(['relationship', 'blocking']);
|
const blocking = account.getIn(['relationship', 'blocking']);
|
||||||
const muting = account.getIn(['relationship', 'muting']);
|
const muting = account.getIn(['relationship', 'muting']);
|
||||||
|
|
||||||
if (requested) {
|
if (requested) {
|
||||||
buttons = <Button text={intl.formatMessage(messages.cancel_follow_request)} onClick={handleFollow} />;
|
buttons = <FollowButton accountId={account.get('id')} />;
|
||||||
} else if (blocking) {
|
} else if (blocking) {
|
||||||
buttons = <Button text={intl.formatMessage(messages.unblock)} onClick={handleBlock} />;
|
buttons = <Button text={intl.formatMessage(messages.unblock)} onClick={handleBlock} />;
|
||||||
} else if (muting) {
|
} 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} />;
|
buttons = <Button text={intl.formatMessage(messages.mute)} onClick={handleMute} />;
|
||||||
} else if (defaultAction === 'block') {
|
} else if (defaultAction === 'block') {
|
||||||
buttons = <Button text={intl.formatMessage(messages.block)} onClick={handleBlock} />;
|
buttons = <Button text={intl.formatMessage(messages.block)} onClick={handleBlock} />;
|
||||||
} else if (!account.get('suspended') && !account.get('moved') || following) {
|
} else {
|
||||||
buttons = <Button text={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={handleFollow} />;
|
buttons = <FollowButton accountId={account.get('id')} />;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
buttons = <FollowButton accountId={account.get('id')} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
let muteTimeRemaining;
|
let muteTimeRemaining;
|
||||||
|
@ -170,7 +165,6 @@ const Account = ({ size = 46, account, onFollow, onBlock, onMute, onMuteNotifica
|
||||||
Account.propTypes = {
|
Account.propTypes = {
|
||||||
size: PropTypes.number,
|
size: PropTypes.number,
|
||||||
account: ImmutablePropTypes.record,
|
account: ImmutablePropTypes.record,
|
||||||
onFollow: PropTypes.func,
|
|
||||||
onBlock: PropTypes.func,
|
onBlock: PropTypes.func,
|
||||||
onMute: PropTypes.func,
|
onMute: PropTypes.func,
|
||||||
onMuteNotifications: PropTypes.func,
|
onMuteNotifications: PropTypes.func,
|
||||||
|
|
|
@ -24,7 +24,7 @@ function useHandleClick(onClick?: OnClickCallback) {
|
||||||
}, [history, onClick]);
|
}, [history, onClick]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ColumnBackButton: React.FC<{ onClick: OnClickCallback }> = ({
|
export const ColumnBackButton: React.FC<{ onClick?: OnClickCallback }> = ({
|
||||||
onClick,
|
onClick,
|
||||||
}) => {
|
}) => {
|
||||||
const handleClick = useHandleClick(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 (
|
return (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
disabled={relationship?.blocked_by || relationship?.blocking}
|
disabled={
|
||||||
|
relationship?.blocked_by ||
|
||||||
|
relationship?.blocking ||
|
||||||
|
(!(relationship?.following || relationship?.requested) &&
|
||||||
|
(account?.suspended || !!account?.moved))
|
||||||
|
}
|
||||||
secondary={following}
|
secondary={following}
|
||||||
className={following ? 'button--destructive' : undefined}
|
className={following ? 'button--destructive' : undefined}
|
||||||
>
|
>
|
||||||
|
|
|
@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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(
|
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() {
|
function main() {
|
||||||
ready(loaded).catch((error: unknown) => {
|
ready(loaded).catch((error: unknown) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
|
@ -45,7 +45,7 @@ class Links extends PureComponent {
|
||||||
|
|
||||||
const banner = (
|
const banner = (
|
||||||
<DismissableBanner id='explore/links'>
|
<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>
|
</DismissableBanner>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -58,7 +58,7 @@ class Statuses extends PureComponent {
|
||||||
return (
|
return (
|
||||||
<StatusList
|
<StatusList
|
||||||
trackScroll
|
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
|
alwaysPrepend
|
||||||
timelineId='explore'
|
timelineId='explore'
|
||||||
statusIds={statusIds}
|
statusIds={statusIds}
|
||||||
|
|
|
@ -5,25 +5,24 @@ import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import { withRouter } from 'react-router-dom';
|
import { withRouter } from 'react-router-dom';
|
||||||
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import { connect } from 'react-redux';
|
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 { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
||||||
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
|
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
|
||||||
|
|
||||||
import { Card } from './components/card';
|
import { Card } from './components/card';
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
suggestions: state.getIn(['suggestions', 'items']),
|
suggestions: state.suggestions.items,
|
||||||
isLoading: state.getIn(['suggestions', 'isLoading']),
|
isLoading: state.suggestions.isLoading,
|
||||||
});
|
});
|
||||||
|
|
||||||
class Suggestions extends PureComponent {
|
class Suggestions extends PureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
isLoading: PropTypes.bool,
|
isLoading: PropTypes.bool,
|
||||||
suggestions: ImmutablePropTypes.list,
|
suggestions: PropTypes.array,
|
||||||
dispatch: PropTypes.func.isRequired,
|
dispatch: PropTypes.func.isRequired,
|
||||||
...WithRouterPropTypes,
|
...WithRouterPropTypes,
|
||||||
};
|
};
|
||||||
|
@ -32,22 +31,17 @@ class Suggestions extends PureComponent {
|
||||||
const { dispatch, suggestions, history } = this.props;
|
const { dispatch, suggestions, history } = this.props;
|
||||||
|
|
||||||
// If we're navigating back to the screen, do not trigger a reload
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch(fetchSuggestions(true));
|
dispatch(fetchSuggestions());
|
||||||
}
|
}
|
||||||
|
|
||||||
handleDismiss = (accountId) => {
|
|
||||||
const { dispatch } = this.props;
|
|
||||||
dispatch(dismissSuggestion(accountId));
|
|
||||||
};
|
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { isLoading, suggestions } = this.props;
|
const { isLoading, suggestions } = this.props;
|
||||||
|
|
||||||
if (!isLoading && suggestions.isEmpty()) {
|
if (!isLoading && suggestions.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className='explore__suggestions scrollable scrollable--flex'>
|
<div className='explore__suggestions scrollable scrollable--flex'>
|
||||||
<div className='empty-column-indicator'>
|
<div className='empty-column-indicator'>
|
||||||
|
@ -61,9 +55,9 @@ class Suggestions extends PureComponent {
|
||||||
<div className='explore__suggestions scrollable' data-nosnippet>
|
<div className='explore__suggestions scrollable' data-nosnippet>
|
||||||
{isLoading ? <LoadingIndicator /> : suggestions.map(suggestion => (
|
{isLoading ? <LoadingIndicator /> : suggestions.map(suggestion => (
|
||||||
<Card
|
<Card
|
||||||
key={suggestion.get('account')}
|
key={suggestion.account_id}
|
||||||
id={suggestion.get('account')}
|
id={suggestion.account_id}
|
||||||
source={suggestion.getIn(['sources', 0])}
|
source={suggestion.sources[0]}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -44,7 +44,7 @@ class Tags extends PureComponent {
|
||||||
|
|
||||||
const banner = (
|
const banner = (
|
||||||
<DismissableBanner id='explore/tags'>
|
<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>
|
</DismissableBanner>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -159,7 +159,7 @@ const Firehose = ({ feedType, multiColumn }) => {
|
||||||
<DismissableBanner id='public_timeline'>
|
<DismissableBanner id='public_timeline'>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='dismissable_banner.public_timeline'
|
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 }}
|
values={{ domain }}
|
||||||
/>
|
/>
|
||||||
</DismissableBanner>
|
</DismissableBanner>
|
||||||
|
|
|
@ -4,12 +4,17 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
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 { Button } from 'flavours/glitch/components/button';
|
||||||
import { ShortNumber } from 'flavours/glitch/components/short_number';
|
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({
|
const messages = defineMessages({
|
||||||
followHashtag: { id: 'hashtag.follow', defaultMessage: 'Follow hashtag' },
|
followHashtag: { id: 'hashtag.follow', defaultMessage: 'Follow hashtag' },
|
||||||
unfollowHashtag: { id: 'hashtag.unfollow', defaultMessage: 'Unfollow hashtag' },
|
unfollowHashtag: { id: 'hashtag.unfollow', defaultMessage: 'Unfollow hashtag' },
|
||||||
|
adminModeration: { id: 'hashtag.admin_moderation', defaultMessage: 'Open moderation interface for #{name}' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const usesRenderer = (displayNumber, pluralReady) => (
|
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) {
|
if (!tag) {
|
||||||
return null;
|
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 [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>;
|
const dividingCircle = <span aria-hidden>{' · '}</span>;
|
||||||
|
|
||||||
|
@ -57,7 +69,10 @@ export const HashtagHeader = injectIntl(({ tag, intl, disabled, onClick }) => {
|
||||||
<div className='hashtag-header'>
|
<div className='hashtag-header'>
|
||||||
<div className='hashtag-header__header'>
|
<div className='hashtag-header__header'>
|
||||||
<h1>#{tag.get('name')}</h1>
|
<h1>#{tag.get('name')}</h1>
|
||||||
<Button onClick={onClick} text={intl.formatMessage(tag.get('following') ? messages.unfollowHashtag : messages.followHashtag)} disabled={disabled} />
|
<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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
@ -69,7 +84,7 @@ export const HashtagHeader = injectIntl(({ tag, intl, disabled, onClick }) => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
}));
|
||||||
|
|
||||||
HashtagHeader.propTypes = {
|
HashtagHeader.propTypes = {
|
||||||
tag: ImmutablePropTypes.map,
|
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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -7,11 +7,8 @@ import { useParams, Link } from 'react-router-dom';
|
||||||
|
|
||||||
import { useDebouncedCallback } from 'use-debounce';
|
import { useDebouncedCallback } from 'use-debounce';
|
||||||
|
|
||||||
import AddIcon from '@/material-icons/400-24px/add.svg?react';
|
|
||||||
import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.svg?react';
|
|
||||||
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
|
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
|
||||||
import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
|
import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
|
||||||
import { fetchFollowing } from 'flavours/glitch/actions/accounts';
|
|
||||||
import { importFetchedAccounts } from 'flavours/glitch/actions/importer';
|
import { importFetchedAccounts } from 'flavours/glitch/actions/importer';
|
||||||
import { fetchList } from 'flavours/glitch/actions/lists';
|
import { fetchList } from 'flavours/glitch/actions/lists';
|
||||||
import { apiRequest } from 'flavours/glitch/api';
|
import { apiRequest } from 'flavours/glitch/api';
|
||||||
|
@ -25,14 +22,12 @@ import { Avatar } from 'flavours/glitch/components/avatar';
|
||||||
import { Button } from 'flavours/glitch/components/button';
|
import { Button } from 'flavours/glitch/components/button';
|
||||||
import Column from 'flavours/glitch/components/column';
|
import Column from 'flavours/glitch/components/column';
|
||||||
import { ColumnHeader } from 'flavours/glitch/components/column_header';
|
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 { FollowersCounter } from 'flavours/glitch/components/counters';
|
||||||
import { DisplayName } from 'flavours/glitch/components/display_name';
|
import { DisplayName } from 'flavours/glitch/components/display_name';
|
||||||
import { Icon } from 'flavours/glitch/components/icon';
|
|
||||||
import ScrollableList from 'flavours/glitch/components/scrollable_list';
|
import ScrollableList from 'flavours/glitch/components/scrollable_list';
|
||||||
import { ShortNumber } from 'flavours/glitch/components/short_number';
|
import { ShortNumber } from 'flavours/glitch/components/short_number';
|
||||||
import { VerifiedBadge } from 'flavours/glitch/components/verified_badge';
|
import { VerifiedBadge } from 'flavours/glitch/components/verified_badge';
|
||||||
import { ButtonInTabsBar } from 'flavours/glitch/features/ui/util/columns_context';
|
|
||||||
import { me } from 'flavours/glitch/initial_state';
|
|
||||||
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
|
@ -49,54 +44,6 @@ const messages = defineMessages({
|
||||||
|
|
||||||
type Mode = 'remove' | 'add';
|
type Mode = 'remove' | 'add';
|
||||||
|
|
||||||
const ColumnSearchHeader: React.FC<{
|
|
||||||
onBack: () => void;
|
|
||||||
onSubmit: (value: string) => void;
|
|
||||||
}> = ({ onBack, onSubmit }) => {
|
|
||||||
const intl = useIntl();
|
|
||||||
const [value, setValue] = useState('');
|
|
||||||
|
|
||||||
const handleChange = useCallback(
|
|
||||||
({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setValue(value);
|
|
||||||
onSubmit(value);
|
|
||||||
},
|
|
||||||
[setValue, onSubmit],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(() => {
|
|
||||||
onSubmit(value);
|
|
||||||
}, [onSubmit, value]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ButtonInTabsBar>
|
|
||||||
<form className='column-search-header' onSubmit={handleSubmit}>
|
|
||||||
<button
|
|
||||||
type='button'
|
|
||||||
className='column-header__back-button compact'
|
|
||||||
onClick={onBack}
|
|
||||||
aria-label={intl.formatMessage(messages.back)}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
id='chevron-left'
|
|
||||||
icon={ArrowBackIcon}
|
|
||||||
className='column-back-button__icon'
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<input
|
|
||||||
type='search'
|
|
||||||
value={value}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder={intl.formatMessage(messages.placeholder)}
|
|
||||||
/* eslint-disable-next-line jsx-a11y/no-autofocus */
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
</ButtonInTabsBar>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const AccountItem: React.FC<{
|
const AccountItem: React.FC<{
|
||||||
accountId: string;
|
accountId: string;
|
||||||
listId: string;
|
listId: string;
|
||||||
|
@ -156,6 +103,7 @@ const AccountItem: React.FC<{
|
||||||
text={intl.formatMessage(
|
text={intl.formatMessage(
|
||||||
partOfList ? messages.remove : messages.add,
|
partOfList ? messages.remove : messages.add,
|
||||||
)}
|
)}
|
||||||
|
secondary={partOfList}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -171,9 +119,6 @@ const ListMembers: React.FC<{
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const followingAccountIds = useAppSelector(
|
|
||||||
(state) => state.user_lists.getIn(['following', me, 'items']) as string[],
|
|
||||||
);
|
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
const [accountIds, setAccountIds] = useState<string[]>([]);
|
const [accountIds, setAccountIds] = useState<string[]>([]);
|
||||||
const [searchAccountIds, setSearchAccountIds] = useState<string[]>([]);
|
const [searchAccountIds, setSearchAccountIds] = useState<string[]>([]);
|
||||||
|
@ -195,8 +140,6 @@ const ListMembers: React.FC<{
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
dispatch(fetchFollowing(me));
|
|
||||||
}
|
}
|
||||||
}, [dispatch, id]);
|
}, [dispatch, id]);
|
||||||
|
|
||||||
|
@ -265,8 +208,8 @@ const ListMembers: React.FC<{
|
||||||
|
|
||||||
let displayedAccountIds: string[];
|
let displayedAccountIds: string[];
|
||||||
|
|
||||||
if (mode === 'add') {
|
if (mode === 'add' && searching) {
|
||||||
displayedAccountIds = searching ? searchAccountIds : followingAccountIds;
|
displayedAccountIds = searchAccountIds;
|
||||||
} else {
|
} else {
|
||||||
displayedAccountIds = accountIds;
|
displayedAccountIds = accountIds;
|
||||||
}
|
}
|
||||||
|
@ -276,31 +219,21 @@ const ListMembers: React.FC<{
|
||||||
bindToDocument={!multiColumn}
|
bindToDocument={!multiColumn}
|
||||||
label={intl.formatMessage(messages.heading)}
|
label={intl.formatMessage(messages.heading)}
|
||||||
>
|
>
|
||||||
{mode === 'remove' ? (
|
<ColumnHeader
|
||||||
<ColumnHeader
|
title={intl.formatMessage(messages.heading)}
|
||||||
title={intl.formatMessage(messages.heading)}
|
icon='list-ul'
|
||||||
icon='list-ul'
|
iconComponent={ListAltIcon}
|
||||||
iconComponent={ListAltIcon}
|
multiColumn={multiColumn}
|
||||||
multiColumn={multiColumn}
|
showBackButton
|
||||||
showBackButton
|
/>
|
||||||
extraButton={
|
|
||||||
<button
|
<ColumnSearchHeader
|
||||||
onClick={handleSearchClick}
|
placeholder={intl.formatMessage(messages.placeholder)}
|
||||||
type='button'
|
onBack={handleDismissSearchClick}
|
||||||
className='column-header__button'
|
onSubmit={handleSearch}
|
||||||
title={intl.formatMessage(messages.enterSearch)}
|
onActivate={handleSearchClick}
|
||||||
aria-label={intl.formatMessage(messages.enterSearch)}
|
active={mode === 'add'}
|
||||||
>
|
/>
|
||||||
<Icon id='plus' icon={AddIcon} />
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ColumnSearchHeader
|
|
||||||
onBack={handleDismissSearchClick}
|
|
||||||
onSubmit={handleSearch}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ScrollableList
|
<ScrollableList
|
||||||
scrollKey='list_members'
|
scrollKey='list_members'
|
||||||
|
@ -310,17 +243,15 @@ const ListMembers: React.FC<{
|
||||||
showLoading={loading && displayedAccountIds.length === 0}
|
showLoading={loading && displayedAccountIds.length === 0}
|
||||||
hasMore={false}
|
hasMore={false}
|
||||||
footer={
|
footer={
|
||||||
mode === 'remove' && (
|
<>
|
||||||
<>
|
{displayedAccountIds.length > 0 && <div className='spacer' />}
|
||||||
<div className='spacer' />
|
|
||||||
|
|
||||||
<div className='column-footer'>
|
<div className='column-footer'>
|
||||||
<Link to={`/lists/${id}`} className='button button--block'>
|
<Link to={`/lists/${id}`} className='button button--block'>
|
||||||
<FormattedMessage id='lists.done' defaultMessage='Done' />
|
<FormattedMessage id='lists.done' defaultMessage='Done' />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
|
||||||
}
|
}
|
||||||
emptyMessage={
|
emptyMessage={
|
||||||
mode === 'remove' ? (
|
mode === 'remove' ? (
|
||||||
|
|
|
@ -1,119 +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 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' },
|
|
||||||
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 === '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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -147,7 +147,7 @@ class PublicTimeline extends PureComponent {
|
||||||
</ColumnHeader>
|
</ColumnHeader>
|
||||||
|
|
||||||
<StatusListContainer
|
<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' : ''}`}
|
timelineId={`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`}
|
||||||
onLoadMore={this.handleLoadMore}
|
onLoadMore={this.handleLoadMore}
|
||||||
trackScroll={!pinned}
|
trackScroll={!pinned}
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import Immutable from 'immutable';
|
import { is } from 'immutable';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
|
||||||
import DescriptionIcon from '@/material-icons/400-24px/description-fill.svg?react';
|
import DescriptionIcon from '@/material-icons/400-24px/description-fill.svg?react';
|
||||||
|
@ -62,7 +62,7 @@ export default class Card extends PureComponent {
|
||||||
};
|
};
|
||||||
|
|
||||||
UNSAFE_componentWillReceiveProps (nextProps) {
|
UNSAFE_componentWillReceiveProps (nextProps) {
|
||||||
if (!Immutable.is(this.props.card, nextProps.card)) {
|
if (!is(this.props.card, nextProps.card)) {
|
||||||
this.setState({ embedded: false, previewLoaded: false });
|
this.setState({ embedded: false, previewLoaded: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -7,7 +7,7 @@ import { Helmet } from 'react-helmet';
|
||||||
import { withRouter } from 'react-router-dom';
|
import { withRouter } from 'react-router-dom';
|
||||||
|
|
||||||
import { createSelector } from '@reduxjs/toolkit';
|
import { createSelector } from '@reduxjs/toolkit';
|
||||||
import Immutable from 'immutable';
|
import { List as ImmutableList } from 'immutable';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
|
@ -82,7 +82,7 @@ const makeMapStateToProps = () => {
|
||||||
(_, { id }) => id,
|
(_, { id }) => id,
|
||||||
state => state.getIn(['contexts', 'inReplyTos']),
|
state => state.getIn(['contexts', 'inReplyTos']),
|
||||||
], (statusId, inReplyTos) => {
|
], (statusId, inReplyTos) => {
|
||||||
let ancestorsIds = Immutable.List();
|
let ancestorsIds = ImmutableList();
|
||||||
ancestorsIds = ancestorsIds.withMutations(mutable => {
|
ancestorsIds = ancestorsIds.withMutations(mutable => {
|
||||||
let id = statusId;
|
let id = statusId;
|
||||||
|
|
||||||
|
@ -129,14 +129,14 @@ const makeMapStateToProps = () => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return Immutable.List(descendantsIds);
|
return ImmutableList(descendantsIds);
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapStateToProps = (state, props) => {
|
const mapStateToProps = (state, props) => {
|
||||||
const status = getStatus(state, { id: props.params.statusId });
|
const status = getStatus(state, { id: props.params.statusId });
|
||||||
|
|
||||||
let ancestorsIds = Immutable.List();
|
let ancestorsIds = ImmutableList();
|
||||||
let descendantsIds = Immutable.List();
|
let descendantsIds = ImmutableList();
|
||||||
|
|
||||||
if (status) {
|
if (status) {
|
||||||
ancestorsIds = getAncestorsIds(state, { id: status.get('in_reply_to_id') });
|
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, accountId) => state.getIn(['timelines', `account:${accountId}`, 'items'], ImmutableList()),
|
||||||
state => state.get('statuses'),
|
state => state.get('statuses'),
|
||||||
], (statusIds, 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 }) => ({
|
const mapStateToProps = (state, { accountId }) => ({
|
||||||
acct: state.getIn(['accounts', accountId, 'acct']),
|
acct: state.getIn(['accounts', accountId, 'acct']),
|
||||||
|
|
|
@ -9,58 +9,7 @@ import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import { Button } from 'flavours/glitch/components/button';
|
import { Button } from 'flavours/glitch/components/button';
|
||||||
import Column from 'flavours/glitch/components/column';
|
import Column from 'flavours/glitch/components/column';
|
||||||
import { autoPlayGif } from 'flavours/glitch/initial_state';
|
import { GIF } from 'flavours/glitch/components/gif';
|
||||||
|
|
||||||
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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
class CopyButton extends PureComponent {
|
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 BundleContainer from '../containers/bundle_container';
|
||||||
import {
|
import {
|
||||||
Compose,
|
Compose,
|
||||||
NotificationsWrapper,
|
Notifications,
|
||||||
HomeTimeline,
|
HomeTimeline,
|
||||||
CommunityTimeline,
|
CommunityTimeline,
|
||||||
PublicTimeline,
|
PublicTimeline,
|
||||||
|
@ -30,7 +30,7 @@ import NavigationPanel from './navigation_panel';
|
||||||
const componentMap = {
|
const componentMap = {
|
||||||
'COMPOSE': Compose,
|
'COMPOSE': Compose,
|
||||||
'HOME': HomeTimeline,
|
'HOME': HomeTimeline,
|
||||||
'NOTIFICATIONS': NotificationsWrapper,
|
'NOTIFICATIONS': Notifications,
|
||||||
'PUBLIC': PublicTimeline,
|
'PUBLIC': PublicTimeline,
|
||||||
'REMOTE': PublicTimeline,
|
'REMOTE': PublicTimeline,
|
||||||
'COMMUNITY': CommunityTimeline,
|
'COMMUNITY': CommunityTimeline,
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -27,7 +27,6 @@ import BundleContainer from '../containers/bundle_container';
|
||||||
import ActionsModal from './actions_modal';
|
import ActionsModal from './actions_modal';
|
||||||
import AudioModal from './audio_modal';
|
import AudioModal from './audio_modal';
|
||||||
import { BoostModal } from './boost_modal';
|
import { BoostModal } from './boost_modal';
|
||||||
import BundleModalError from './bundle_modal_error';
|
|
||||||
import {
|
import {
|
||||||
ConfirmationModal,
|
ConfirmationModal,
|
||||||
ConfirmDeleteStatusModal,
|
ConfirmDeleteStatusModal,
|
||||||
|
@ -44,7 +43,7 @@ import { FavouriteModal } from './favourite_modal';
|
||||||
import FocalPointModal from './focal_point_modal';
|
import FocalPointModal from './focal_point_modal';
|
||||||
import ImageModal from './image_modal';
|
import ImageModal from './image_modal';
|
||||||
import MediaModal from './media_modal';
|
import MediaModal from './media_modal';
|
||||||
import ModalLoading from './modal_loading';
|
import { ModalPlaceholder } from './modal_placeholder';
|
||||||
import VideoModal from './video_modal';
|
import VideoModal from './video_modal';
|
||||||
|
|
||||||
export const MODAL_COMPONENTS = {
|
export const MODAL_COMPONENTS = {
|
||||||
|
@ -109,14 +108,16 @@ export default class ModalRoot extends PureComponent {
|
||||||
this.setState({ backgroundColor: color });
|
this.setState({ backgroundColor: color });
|
||||||
};
|
};
|
||||||
|
|
||||||
renderLoading = modalId => () => {
|
renderLoading = () => {
|
||||||
return ['MEDIA', 'VIDEO', 'BOOST', 'FAVOURITE', 'DOODLE', 'CONFIRM', 'ACTIONS'].indexOf(modalId) === -1 ? <ModalLoading /> : null;
|
const { onClose } = this.props;
|
||||||
|
|
||||||
|
return <ModalPlaceholder loading onClose={onClose} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
renderError = (props) => {
|
renderError = (props) => {
|
||||||
const { onClose } = this.props;
|
const { onClose } = this.props;
|
||||||
|
|
||||||
return <BundleModalError {...props} onClose={onClose} />;
|
return <ModalPlaceholder {...props} onClose={onClose} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
handleClose = (ignoreFocus = false) => {
|
handleClose = (ignoreFocus = false) => {
|
||||||
|
@ -141,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}>
|
<Base backgroundColor={backgroundColor} onClose={props && props.noClose ? this.noop : this.handleClose} noEsc={props ? props.noEsc : false} ignoreFocus={ignoreFocus}>
|
||||||
{visible && (
|
{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) => {
|
{(SpecificComponent) => {
|
||||||
const ref = typeof SpecificComponent !== 'function' ? this.setModalRef : undefined;
|
const ref = typeof SpecificComponent !== 'function' ? this.setModalRef : undefined;
|
||||||
return <SpecificComponent {...props} onChangeBackgroundColor={this.setBackgroundColor} onClose={this.handleClose} ref={ref} />;
|
return <SpecificComponent {...props} onChangeBackgroundColor={this.setBackgroundColor} onClose={this.handleClose} ref={ref} />;
|
||||||
|
|
|
@ -53,7 +53,7 @@ import {
|
||||||
Favourites,
|
Favourites,
|
||||||
DirectTimeline,
|
DirectTimeline,
|
||||||
HashtagTimeline,
|
HashtagTimeline,
|
||||||
NotificationsWrapper,
|
Notifications,
|
||||||
NotificationRequests,
|
NotificationRequests,
|
||||||
NotificationRequest,
|
NotificationRequest,
|
||||||
FollowRequests,
|
FollowRequests,
|
||||||
|
@ -71,8 +71,9 @@ import {
|
||||||
PinnedStatuses,
|
PinnedStatuses,
|
||||||
GettingStartedMisc,
|
GettingStartedMisc,
|
||||||
Directory,
|
Directory,
|
||||||
|
OnboardingProfile,
|
||||||
|
OnboardingFollows,
|
||||||
Explore,
|
Explore,
|
||||||
Onboarding,
|
|
||||||
About,
|
About,
|
||||||
PrivacyPolicy,
|
PrivacyPolicy,
|
||||||
} from './util/async-components';
|
} from './util/async-components';
|
||||||
|
@ -222,7 +223,7 @@ class SwitchingColumnsArea extends PureComponent {
|
||||||
<WrappedRoute path='/lists/:id/edit' 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/members' component={ListMembers} content={children} />
|
||||||
<WrappedRoute path='/lists/:id' component={ListTimeline} 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' component={NotificationRequests} content={children} exact />
|
||||||
<WrappedRoute path='/notifications/requests/:id' component={NotificationRequest} content={children} exact />
|
<WrappedRoute path='/notifications/requests/:id' component={NotificationRequest} content={children} exact />
|
||||||
<WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
|
<WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
|
||||||
|
@ -230,7 +231,8 @@ class SwitchingColumnsArea extends PureComponent {
|
||||||
<WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} />
|
<WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} />
|
||||||
<WrappedRoute path='/pinned' component={PinnedStatuses} 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='/directory' component={Directory} content={children} />
|
||||||
<WrappedRoute path={['/explore', '/search']} component={Explore} content={children} />
|
<WrappedRoute path={['/explore', '/search']} component={Explore} content={children} />
|
||||||
<WrappedRoute path={['/publish', '/statuses/new']} component={Compose} content={children} />
|
<WrappedRoute path={['/publish', '/statuses/new']} component={Compose} content={children} />
|
||||||
|
|
|
@ -7,15 +7,7 @@ export function Compose () {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Notifications () {
|
export function Notifications () {
|
||||||
return import(/* webpackChunkName: "flavours/glitch/async/notifications_v1" */'../../notifications');
|
return import(/* webpackChunkName: "flavours/glitch/async/notifications" */'../../notifications_v2');
|
||||||
}
|
|
||||||
|
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HomeTimeline () {
|
export function HomeTimeline () {
|
||||||
|
@ -174,8 +166,12 @@ export function Directory () {
|
||||||
return import(/* webpackChunkName: "features/glitch/async/directory" */'../../directory');
|
return import(/* webpackChunkName: "features/glitch/async/directory" */'../../directory');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Onboarding () {
|
export function OnboardingProfile () {
|
||||||
return import(/* webpackChunkName: "features/glitch/async/onboarding" */'../../onboarding');
|
return import(/* webpackChunkName: "features/glitch/async/onboarding" */'../../onboarding/profile');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OnboardingFollows () {
|
||||||
|
return import(/* webpackChunkName: "features/glitch/async/onboarding" */'../../onboarding/follows');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CompareHistoryModal () {
|
export function CompareHistoryModal () {
|
||||||
|
|
|
@ -57,7 +57,6 @@
|
||||||
"notification_purge.btn_apply": "Clear\nselected",
|
"notification_purge.btn_apply": "Clear\nselected",
|
||||||
"notification_purge.btn_invert": "Invert\nselection",
|
"notification_purge.btn_invert": "Invert\nselection",
|
||||||
"notification_purge.btn_none": "Select\nnone",
|
"notification_purge.btn_none": "Select\nnone",
|
||||||
"notification_purge.start": "Enter notification cleaning mode",
|
|
||||||
"notifications.column_settings.filter_bar.show_bar": "Show filter bar",
|
"notifications.column_settings.filter_bar.show_bar": "Show filter bar",
|
||||||
"notifications.marked_clear": "Clear selected notifications",
|
"notifications.marked_clear": "Clear selected notifications",
|
||||||
"notifications.marked_clear_confirmation": "Are you sure you want to permanently clear all selected notifications?",
|
"notifications.marked_clear_confirmation": "Are you sure you want to permanently clear all selected notifications?",
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import type { RecordOf } from 'immutable';
|
import type { RecordOf } from 'immutable';
|
||||||
import { List, Record as ImmutableRecord } from 'immutable';
|
import { List as ImmutableList, Record as ImmutableRecord } from 'immutable';
|
||||||
|
|
||||||
import escapeTextContentForBrowser from 'escape-html';
|
import escapeTextContentForBrowser from 'escape-html';
|
||||||
|
|
||||||
|
@ -48,9 +48,9 @@ export interface AccountShape
|
||||||
extends Required<
|
extends Required<
|
||||||
Omit<ApiAccountJSON, 'emojis' | 'fields' | 'roles' | 'moved'>
|
Omit<ApiAccountJSON, 'emojis' | 'fields' | 'roles' | 'moved'>
|
||||||
> {
|
> {
|
||||||
emojis: List<CustomEmoji>;
|
emojis: ImmutableList<CustomEmoji>;
|
||||||
fields: List<AccountField>;
|
fields: ImmutableList<AccountField>;
|
||||||
roles: List<AccountRole>;
|
roles: ImmutableList<AccountRole>;
|
||||||
display_name_html: string;
|
display_name_html: string;
|
||||||
note_emojified: string;
|
note_emojified: string;
|
||||||
note_plain: string | null;
|
note_plain: string | null;
|
||||||
|
@ -70,8 +70,8 @@ export const accountDefaultValues: AccountShape = {
|
||||||
indexable: false,
|
indexable: false,
|
||||||
display_name: '',
|
display_name: '',
|
||||||
display_name_html: '',
|
display_name_html: '',
|
||||||
emojis: List<CustomEmoji>(),
|
emojis: ImmutableList<CustomEmoji>(),
|
||||||
fields: List<AccountField>(),
|
fields: ImmutableList<AccountField>(),
|
||||||
group: false,
|
group: false,
|
||||||
header: '',
|
header: '',
|
||||||
header_static: '',
|
header_static: '',
|
||||||
|
@ -82,7 +82,7 @@ export const accountDefaultValues: AccountShape = {
|
||||||
note: '',
|
note: '',
|
||||||
note_emojified: '',
|
note_emojified: '',
|
||||||
note_plain: 'string',
|
note_plain: 'string',
|
||||||
roles: List<AccountRole>(),
|
roles: ImmutableList<AccountRole>(),
|
||||||
uri: '',
|
uri: '',
|
||||||
url: '',
|
url: '',
|
||||||
username: '',
|
username: '',
|
||||||
|
@ -139,11 +139,15 @@ export function createAccountFromServerJSON(serverJSON: ApiAccountJSON) {
|
||||||
return AccountFactory({
|
return AccountFactory({
|
||||||
...accountJSON,
|
...accountJSON,
|
||||||
moved: moved?.id,
|
moved: moved?.id,
|
||||||
fields: List(
|
fields: ImmutableList(
|
||||||
serverJSON.fields.map((field) => createAccountField(field, emojiMap)),
|
serverJSON.fields.map((field) => createAccountField(field, emojiMap)),
|
||||||
),
|
),
|
||||||
emojis: List(serverJSON.emojis.map((emoji) => CustomEmojiFactory(emoji))),
|
emojis: ImmutableList(
|
||||||
roles: List(serverJSON.roles?.map((role) => AccountRoleFactory(role))),
|
serverJSON.emojis.map((emoji) => CustomEmojiFactory(emoji)),
|
||||||
|
),
|
||||||
|
roles: ImmutableList(
|
||||||
|
serverJSON.roles?.map((role) => AccountRoleFactory(role)),
|
||||||
|
),
|
||||||
display_name_html: emojify(
|
display_name_html: emojify(
|
||||||
escapeTextContentForBrowser(displayName),
|
escapeTextContentForBrowser(displayName),
|
||||||
emojiMap,
|
emojiMap,
|
||||||
|
|
|
@ -0,0 +1,12 @@
|
||||||
|
import type { ApiSuggestionJSON } from 'flavours/glitch/api_types/suggestions';
|
||||||
|
|
||||||
|
export interface Suggestion extends Omit<ApiSuggestionJSON, 'account'> {
|
||||||
|
account_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createSuggestion = (
|
||||||
|
serverJSON: ApiSuggestionJSON,
|
||||||
|
): Suggestion => ({
|
||||||
|
sources: serverJSON.sources,
|
||||||
|
account_id: serverJSON.account.id,
|
||||||
|
});
|
|
@ -1,5 +1,6 @@
|
||||||
export const PERMISSION_INVITE_USERS = 0x0000000000010000;
|
export const PERMISSION_INVITE_USERS = 0x0000000000010000;
|
||||||
export const PERMISSION_MANAGE_USERS = 0x0000000000000400;
|
export const PERMISSION_MANAGE_USERS = 0x0000000000000400;
|
||||||
|
export const PERMISSION_MANAGE_TAXONOMIES = 0x0000000000000100;
|
||||||
export const PERMISSION_MANAGE_FEDERATION = 0x0000000000000020;
|
export const PERMISSION_MANAGE_FEDERATION = 0x0000000000000020;
|
||||||
|
|
||||||
export const PERMISSION_MANAGE_REPORTS = 0x0000000000000010;
|
export const PERMISSION_MANAGE_REPORTS = 0x0000000000000010;
|
||||||
|
|
|
@ -36,7 +36,7 @@ import server from './server';
|
||||||
import settings from './settings';
|
import settings from './settings';
|
||||||
import status_lists from './status_lists';
|
import status_lists from './status_lists';
|
||||||
import statuses from './statuses';
|
import statuses from './statuses';
|
||||||
import suggestions from './suggestions';
|
import { suggestionsReducer } from './suggestions';
|
||||||
import tags from './tags';
|
import tags from './tags';
|
||||||
import timelines from './timelines';
|
import timelines from './timelines';
|
||||||
import trends from './trends';
|
import trends from './trends';
|
||||||
|
@ -72,7 +72,7 @@ const reducers = {
|
||||||
lists: listsReducer,
|
lists: listsReducer,
|
||||||
filters,
|
filters,
|
||||||
conversations,
|
conversations,
|
||||||
suggestions,
|
suggestions: suggestionsReducer,
|
||||||
polls,
|
polls,
|
||||||
trends,
|
trends,
|
||||||
markers: markersReducer,
|
markers: markersReducer,
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import Immutable from 'immutable';
|
import { Map as ImmutableMap } from 'immutable';
|
||||||
|
|
||||||
import { SET_BROWSER_SUPPORT, SET_SUBSCRIPTION, CLEAR_SUBSCRIPTION, SET_ALERTS } from '../actions/push_notifications';
|
import { SET_BROWSER_SUPPORT, SET_SUBSCRIPTION, CLEAR_SUBSCRIPTION, SET_ALERTS } from '../actions/push_notifications';
|
||||||
import { STORE_HYDRATE } from '../actions/store';
|
import { STORE_HYDRATE } from '../actions/store';
|
||||||
|
|
||||||
const initialState = Immutable.Map({
|
const initialState = ImmutableMap({
|
||||||
subscription: null,
|
subscription: null,
|
||||||
alerts: new Immutable.Map({
|
alerts: ImmutableMap({
|
||||||
follow: false,
|
follow: false,
|
||||||
follow_request: false,
|
follow_request: false,
|
||||||
favourite: false,
|
favourite: false,
|
||||||
|
@ -24,7 +24,7 @@ export default function push_subscriptions(state = initialState, action) {
|
||||||
|
|
||||||
if (push_subscription) {
|
if (push_subscription) {
|
||||||
return state
|
return state
|
||||||
.set('subscription', new Immutable.Map({
|
.set('subscription', ImmutableMap({
|
||||||
id: push_subscription.get('id'),
|
id: push_subscription.get('id'),
|
||||||
endpoint: push_subscription.get('endpoint'),
|
endpoint: push_subscription.get('endpoint'),
|
||||||
}))
|
}))
|
||||||
|
@ -36,11 +36,11 @@ export default function push_subscriptions(state = initialState, action) {
|
||||||
}
|
}
|
||||||
case SET_SUBSCRIPTION:
|
case SET_SUBSCRIPTION:
|
||||||
return state
|
return state
|
||||||
.set('subscription', new Immutable.Map({
|
.set('subscription', ImmutableMap({
|
||||||
id: action.subscription.id,
|
id: action.subscription.id,
|
||||||
endpoint: action.subscription.endpoint,
|
endpoint: action.subscription.endpoint,
|
||||||
}))
|
}))
|
||||||
.set('alerts', new Immutable.Map(action.subscription.alerts))
|
.set('alerts', ImmutableMap(action.subscription.alerts))
|
||||||
.set('isSubscribed', true);
|
.set('isSubscribed', true);
|
||||||
case SET_BROWSER_SUPPORT:
|
case SET_BROWSER_SUPPORT:
|
||||||
return state.set('browserSupport', action.value);
|
return state.set('browserSupport', action.value);
|
||||||
|
|
|
@ -1,40 +0,0 @@
|
||||||
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
|
|
||||||
|
|
||||||
import { blockAccountSuccess, muteAccountSuccess } from 'flavours/glitch/actions/accounts';
|
|
||||||
import { blockDomainSuccess } from 'flavours/glitch/actions/domain_blocks';
|
|
||||||
|
|
||||||
import {
|
|
||||||
SUGGESTIONS_FETCH_REQUEST,
|
|
||||||
SUGGESTIONS_FETCH_SUCCESS,
|
|
||||||
SUGGESTIONS_FETCH_FAIL,
|
|
||||||
SUGGESTIONS_DISMISS,
|
|
||||||
} from '../actions/suggestions';
|
|
||||||
|
|
||||||
|
|
||||||
const initialState = ImmutableMap({
|
|
||||||
items: ImmutableList(),
|
|
||||||
isLoading: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function suggestionsReducer(state = initialState, action) {
|
|
||||||
switch(action.type) {
|
|
||||||
case SUGGESTIONS_FETCH_REQUEST:
|
|
||||||
return state.set('isLoading', true);
|
|
||||||
case SUGGESTIONS_FETCH_SUCCESS:
|
|
||||||
return state.withMutations(map => {
|
|
||||||
map.set('items', fromJS(action.suggestions.map(x => ({ ...x, account: x.account.id }))));
|
|
||||||
map.set('isLoading', false);
|
|
||||||
});
|
|
||||||
case SUGGESTIONS_FETCH_FAIL:
|
|
||||||
return state.set('isLoading', false);
|
|
||||||
case SUGGESTIONS_DISMISS:
|
|
||||||
return state.update('items', list => list.filterNot(x => x.get('account') === action.id));
|
|
||||||
case blockAccountSuccess.type:
|
|
||||||
case muteAccountSuccess.type:
|
|
||||||
return state.update('items', list => list.filterNot(x => x.get('account') === action.payload.relationship.id));
|
|
||||||
case blockDomainSuccess.type:
|
|
||||||
return state.update('items', list => list.filterNot(x => action.payload.accounts.includes(x.get('account'))));
|
|
||||||
default:
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { createReducer, isAnyOf } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
import {
|
||||||
|
blockAccountSuccess,
|
||||||
|
muteAccountSuccess,
|
||||||
|
} from 'flavours/glitch/actions/accounts';
|
||||||
|
import { blockDomainSuccess } from 'flavours/glitch/actions/domain_blocks';
|
||||||
|
import {
|
||||||
|
fetchSuggestions,
|
||||||
|
dismissSuggestion,
|
||||||
|
} from 'flavours/glitch/actions/suggestions';
|
||||||
|
import { createSuggestion } from 'flavours/glitch/models/suggestion';
|
||||||
|
import type { Suggestion } from 'flavours/glitch/models/suggestion';
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
items: Suggestion[];
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: State = {
|
||||||
|
items: [],
|
||||||
|
isLoading: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const suggestionsReducer = createReducer(initialState, (builder) => {
|
||||||
|
builder.addCase(fetchSuggestions.pending, (state) => {
|
||||||
|
state.isLoading = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.addCase(fetchSuggestions.fulfilled, (state, action) => {
|
||||||
|
state.items = action.payload.map(createSuggestion);
|
||||||
|
state.isLoading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.addCase(fetchSuggestions.rejected, (state) => {
|
||||||
|
state.isLoading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.addCase(dismissSuggestion.pending, (state, action) => {
|
||||||
|
state.items = state.items.filter(
|
||||||
|
(x) => x.account_id !== action.meta.arg.accountId,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.addCase(blockDomainSuccess, (state, action) => {
|
||||||
|
state.items = state.items.filter(
|
||||||
|
(x) =>
|
||||||
|
!action.payload.accounts.some((account) => account.id === x.account_id),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.addMatcher(
|
||||||
|
isAnyOf(blockAccountSuccess, muteAccountSuccess),
|
||||||
|
(state, action) => {
|
||||||
|
state.items = state.items.filter(
|
||||||
|
(x) => x.account_id !== action.payload.relationship.id,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
|
@ -14,7 +14,7 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
background: $ui-base-color;
|
background: var(--input-background-color);
|
||||||
color: $darker-text-color;
|
color: $darker-text-color;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: 1px solid var(--background-border-color);
|
border: 1px solid var(--background-border-color);
|
||||||
|
|
|
@ -31,13 +31,41 @@ $fluid-breakpoint: $maximum-width + 20px;
|
||||||
padding-inline-start: 3em;
|
padding-inline-start: 3em;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
counter-increment: list-counter;
|
counter-increment: list-counter;
|
||||||
|
min-height: 4ch;
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
text-align: start;
|
||||||
|
font: inherit;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus,
|
||||||
|
&:active {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[aria-expanded='false'] .rules-list__hint {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
|
||||||
|
@supports (-webkit-line-clamp: 2) {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
content: counter(list-counter);
|
content: counter(list-counter);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset-inline-start: 0;
|
inset-inline-start: 0;
|
||||||
top: 50%;
|
top: 1em;
|
||||||
transform: translateY(-50%);
|
|
||||||
background: $highlight-text-color;
|
background: $highlight-text-color;
|
||||||
color: $ui-base-color;
|
color: $ui-base-color;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
|
|
@ -44,6 +44,10 @@
|
||||||
color: $ui-primary-color;
|
color: $ui-primary-color;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: $ui-button-icon-focus-outline;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.button {
|
.button {
|
||||||
|
@ -419,10 +423,10 @@ body > [data-popper-placement] {
|
||||||
|
|
||||||
&__suggestions {
|
&__suggestions {
|
||||||
box-shadow: var(--dropdown-shadow);
|
box-shadow: var(--dropdown-shadow);
|
||||||
background: $ui-base-color;
|
background: var(--input-background-color);
|
||||||
border: 1px solid var(--background-border-color);
|
border: 1px solid var(--background-border-color);
|
||||||
border-radius: 0 0 4px 4px;
|
border-radius: 0 0 4px 4px;
|
||||||
color: $secondary-text-color;
|
color: var(--on-input-color);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
||||||
|
@ -435,7 +439,7 @@ body > [data-popper-placement] {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
letter-spacing: 0.25px;
|
letter-spacing: 0.25px;
|
||||||
color: $secondary-text-color;
|
color: var(--on-input-color);
|
||||||
|
|
||||||
&:last-child {
|
&:last-child {
|
||||||
border-radius: 0 0 4px 4px;
|
border-radius: 0 0 4px 4px;
|
||||||
|
@ -549,7 +553,7 @@ body > [data-popper-placement] {
|
||||||
transition: border-color 300ms linear;
|
transition: border-color 300ms linear;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
background: $ui-base-color;
|
background: var(--input-background-color);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
|
@ -622,7 +626,7 @@ body > [data-popper-placement] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: $secondary-text-color;
|
color: $secondary-text-color;
|
||||||
background: $ui-base-color;
|
background: var(--input-background-color);
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
@ -1120,26 +1124,6 @@ body > [data-popper-placement] {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.status__content__spoiler-link {
|
|
||||||
background: $action-button-color;
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&:focus {
|
|
||||||
background: lighten($action-button-color, 7%);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-moz-focus-inner {
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-moz-focus-inner,
|
|
||||||
&:focus,
|
|
||||||
&:active {
|
|
||||||
outline: 0 !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.status__content__spoiler {
|
.status__content__spoiler {
|
||||||
display: none;
|
display: none;
|
||||||
|
|
||||||
|
@ -1398,21 +1382,6 @@ body > [data-popper-placement] {
|
||||||
color: $dark-text-color;
|
color: $dark-text-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status__content__spoiler-link {
|
|
||||||
display: inline-block;
|
|
||||||
border-radius: 2px;
|
|
||||||
background: transparent;
|
|
||||||
border: 0;
|
|
||||||
color: $inverted-text-color;
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 11px;
|
|
||||||
padding: 0 6px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
line-height: 20px;
|
|
||||||
cursor: pointer;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status__wrapper--filtered {
|
.status__wrapper--filtered {
|
||||||
color: $dark-text-color;
|
color: $dark-text-color;
|
||||||
border: 0;
|
border: 0;
|
||||||
|
@ -1905,11 +1874,6 @@ body > [data-popper-placement] {
|
||||||
height: 24px;
|
height: 24px;
|
||||||
margin: -1px 0 0;
|
margin: -1px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status__content__spoiler-link {
|
|
||||||
line-height: 24px;
|
|
||||||
margin: -1px 0 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-gallery,
|
.media-gallery,
|
||||||
|
@ -2457,17 +2421,6 @@ a.account__display-name {
|
||||||
.status__avatar {
|
.status__avatar {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
a.status__content__spoiler-link {
|
|
||||||
background: $ui-base-lighter-color;
|
|
||||||
color: $inverted-text-color;
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&:focus {
|
|
||||||
background: lighten($ui-base-lighter-color, 7%);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification__report {
|
.notification__report {
|
||||||
|
@ -3420,203 +3373,6 @@ $ui-header-logo-wordmark-width: 99px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.onboarding__footer {
|
|
||||||
margin-top: 30px;
|
|
||||||
color: $dark-text-color;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 14px;
|
|
||||||
|
|
||||||
.link-button {
|
|
||||||
display: inline-block;
|
|
||||||
color: inherit;
|
|
||||||
font-size: inherit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.onboarding__link {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 10px;
|
|
||||||
color: $highlight-text-color;
|
|
||||||
background: lighten($ui-base-color, 4%);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 10px 15px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
height: 56px;
|
|
||||||
text-decoration: none;
|
|
||||||
|
|
||||||
svg {
|
|
||||||
height: 1.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&:focus,
|
|
||||||
&:active {
|
|
||||||
background: lighten($ui-base-color, 8%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.onboarding__illustration {
|
|
||||||
display: block;
|
|
||||||
margin: 0 auto;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
max-height: 200px;
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.onboarding__lead {
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 24px;
|
|
||||||
font-weight: 400;
|
|
||||||
color: $darker-text-color;
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
|
|
||||||
strong {
|
|
||||||
font-weight: 700;
|
|
||||||
color: $secondary-text-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.onboarding__links {
|
|
||||||
margin-bottom: 30px;
|
|
||||||
|
|
||||||
& > * {
|
|
||||||
margin-bottom: 2px;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.onboarding__steps {
|
|
||||||
margin-bottom: 30px;
|
|
||||||
|
|
||||||
&__item {
|
|
||||||
background: lighten($ui-base-color, 4%);
|
|
||||||
border: 0;
|
|
||||||
border-radius: 8px;
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 10px;
|
|
||||||
padding-inline-end: 15px;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
text-decoration: none;
|
|
||||||
text-align: start;
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&:focus,
|
|
||||||
&:active {
|
|
||||||
background: lighten($ui-base-color, 8%);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__icon {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: none;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
color: $highlight-text-color;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
|
|
||||||
@media screen and (width >= 600px) {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__progress {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
background: $valid-value-color;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 21px;
|
|
||||||
height: 21px;
|
|
||||||
color: $primary-text-color;
|
|
||||||
|
|
||||||
svg {
|
|
||||||
height: 14px;
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__go {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 21px;
|
|
||||||
height: 21px;
|
|
||||||
color: $highlight-text-color;
|
|
||||||
font-size: 17px;
|
|
||||||
|
|
||||||
svg {
|
|
||||||
height: 1.5em;
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__description {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
line-height: 20px;
|
|
||||||
|
|
||||||
h6 {
|
|
||||||
color: $highlight-text-color;
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
color: $darker-text-color;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.follow-recommendations {
|
|
||||||
background: darken($ui-base-color, 4%);
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
|
|
||||||
.account:last-child {
|
|
||||||
border-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__empty {
|
|
||||||
text-align: center;
|
|
||||||
color: $darker-text-color;
|
|
||||||
font-weight: 500;
|
|
||||||
padding: 40px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tip-carousel {
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 16px;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
outline: 0;
|
|
||||||
border-color: $highlight-text-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-modal__pagination {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.copy-paste-text {
|
.copy-paste-text {
|
||||||
background: lighten($ui-base-color, 4%);
|
background: lighten($ui-base-color, 4%);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
@ -3663,9 +3419,10 @@ $ui-header-logo-wordmark-width: 99px;
|
||||||
.onboarding__profile {
|
.onboarding__profile {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-bottom: 40px + 20px;
|
margin-bottom: 40px + 20px;
|
||||||
|
margin-top: -20px;
|
||||||
|
|
||||||
.app-form__avatar-input {
|
.app-form__avatar-input {
|
||||||
border: 2px solid $ui-base-color;
|
border: 2px solid var(--background-color);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset-inline-start: -2px;
|
inset-inline-start: -2px;
|
||||||
bottom: -40px;
|
bottom: -40px;
|
||||||
|
@ -6026,7 +5783,7 @@ a.status-card {
|
||||||
inset-inline-start: 0;
|
inset-inline-start: 0;
|
||||||
margin-top: -2px;
|
margin-top: -2px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: $ui-base-color;
|
background: var(--input-background-color);
|
||||||
border: 1px solid var(--background-border-color);
|
border: 1px solid var(--background-border-color);
|
||||||
border-radius: 0 0 4px 4px;
|
border-radius: 0 0 4px 4px;
|
||||||
box-shadow: var(--dropdown-shadow);
|
box-shadow: var(--dropdown-shadow);
|
||||||
|
@ -6498,126 +6255,44 @@ a.status-card {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.onboarding-modal,
|
.modal-placeholder {
|
||||||
.error-modal,
|
width: 588px;
|
||||||
.embed-modal {
|
min-height: 478px;
|
||||||
background: $ui-secondary-color;
|
|
||||||
color: $inverted-text-color;
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
background: var(--modal-background-color);
|
||||||
|
backdrop-filter: var(--background-filter);
|
||||||
|
border: 1px solid var(--modal-border-color);
|
||||||
|
border-radius: 16px;
|
||||||
|
|
||||||
.error-modal__body {
|
&__error {
|
||||||
height: 80vh;
|
padding: 24px;
|
||||||
width: 80vw;
|
|
||||||
max-width: 520px;
|
|
||||||
max-height: 420px;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
& > div {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
inset-inline-start: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
padding: 25px;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
opacity: 0;
|
align-items: center;
|
||||||
user-select: text;
|
flex-direction: column;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-modal__body {
|
&__image {
|
||||||
display: flex;
|
width: 70%;
|
||||||
flex-direction: column;
|
max-width: 350px;
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.onboarding-modal__paginator,
|
|
||||||
.error-modal__footer {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
background: darken($ui-secondary-color, 8%);
|
|
||||||
display: flex;
|
|
||||||
padding: 25px;
|
|
||||||
|
|
||||||
& > div {
|
|
||||||
min-width: 33px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.onboarding-modal__nav,
|
|
||||||
.error-modal__nav {
|
|
||||||
color: $lighter-text-color;
|
|
||||||
border: 0;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
padding: 10px 25px;
|
|
||||||
line-height: inherit;
|
|
||||||
height: auto;
|
|
||||||
margin: -10px;
|
|
||||||
border-radius: 4px;
|
|
||||||
background-color: transparent;
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&:focus,
|
|
||||||
&:active {
|
|
||||||
color: darken($lighter-text-color, 4%);
|
|
||||||
background-color: darken($ui-secondary-color, 16%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.onboarding-modal__done,
|
&__message {
|
||||||
&.onboarding-modal__next {
|
text-align: center;
|
||||||
color: $inverted-text-color;
|
text-wrap: balance;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
letter-spacing: 0.25px;
|
||||||
|
|
||||||
&:hover,
|
&__actions {
|
||||||
&:focus,
|
margin-top: 24px;
|
||||||
&:active {
|
display: flex;
|
||||||
color: lighten($inverted-text-color, 4%);
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-modal__footer {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.display-case {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 15px;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
|
|
||||||
&__label {
|
|
||||||
font-weight: 500;
|
|
||||||
color: $inverted-text-color;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__case {
|
|
||||||
background: $ui-base-color;
|
|
||||||
color: $secondary-text-color;
|
|
||||||
font-weight: 500;
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.onboard-sliders {
|
|
||||||
display: inline-block;
|
|
||||||
max-width: 30px;
|
|
||||||
max-height: auto;
|
|
||||||
margin-inline-start: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.safety-action-modal {
|
.safety-action-modal {
|
||||||
width: 600px;
|
width: 600px;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
@ -7043,15 +6718,6 @@ a.status-card {
|
||||||
color: $primary-text-color;
|
color: $primary-text-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status__content__spoiler-link {
|
|
||||||
color: $primary-text-color;
|
|
||||||
background: $ui-primary-color;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: lighten($ui-primary-color, 8%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-option {
|
.dialog-option {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
@ -8728,7 +8394,7 @@ noscript {
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-button {
|
.icon-button {
|
||||||
border: 1px solid lighten($ui-base-color, 12%);
|
border: 1px solid var(--background-border-color);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
box-sizing: content-box;
|
box-sizing: content-box;
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
|
@ -9268,6 +8934,7 @@ noscript {
|
||||||
&__item {
|
&__item {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
background: lighten($ui-base-color, 12%);
|
background: lighten($ui-base-color, 12%);
|
||||||
|
color: $darker-text-color;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
margin: 2px;
|
margin: 2px;
|
||||||
|
@ -9304,7 +8971,6 @@ noscript {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-inline-start: 6px;
|
margin-inline-start: 6px;
|
||||||
color: $darker-text-color;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
|
@ -9313,10 +8979,7 @@ noscript {
|
||||||
background: lighten($ui-base-color, 16%);
|
background: lighten($ui-base-color, 16%);
|
||||||
transition: all 200ms ease-out;
|
transition: all 200ms ease-out;
|
||||||
transition-property: background-color, color;
|
transition-property: background-color, color;
|
||||||
|
color: lighten($darker-text-color, 4%);
|
||||||
&__count {
|
|
||||||
color: lighten($darker-text-color, 4%);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
|
@ -9327,10 +8990,7 @@ noscript {
|
||||||
$ui-highlight-color,
|
$ui-highlight-color,
|
||||||
80%
|
80%
|
||||||
);
|
);
|
||||||
|
color: lighten($highlight-text-color, 8%);
|
||||||
.reactions-bar__item__count {
|
|
||||||
color: lighten($highlight-text-color, 8%);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -10692,6 +10352,30 @@ noscript {
|
||||||
line-height: 33px;
|
line-height: 33px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__buttons {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.button {
|
||||||
|
flex-shrink: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
border: 1px solid var(--background-border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
box-sizing: content-box;
|
||||||
|
padding: 5px;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -10951,7 +10635,7 @@ noscript {
|
||||||
|
|
||||||
&__text {
|
&__text {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
font-style: 14px;
|
font-size: 14px;
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
|
|
||||||
strong {
|
strong {
|
||||||
|
@ -11009,7 +10693,7 @@ noscript {
|
||||||
&__name {
|
&__name {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
color: $darker-text-color;
|
color: $darker-text-color;
|
||||||
font-style: 14px;
|
font-size: 14px;
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
@ -11665,19 +11349,22 @@ noscript {
|
||||||
|
|
||||||
.column-search-header {
|
.column-search-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
border-radius: 4px 4px 0 0;
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
border: 1px solid var(--background-border-color);
|
border: 1px solid var(--background-border-color);
|
||||||
|
border-top: 0;
|
||||||
.column-header__back-button.compact {
|
border-bottom: 0;
|
||||||
flex: 0 0 auto;
|
padding: 16px;
|
||||||
color: $primary-text-color;
|
padding-bottom: 8px;
|
||||||
}
|
|
||||||
|
|
||||||
input {
|
input {
|
||||||
background: transparent;
|
background: var(--input-background-color);
|
||||||
border: 0;
|
border: 1px solid var(--background-border-color);
|
||||||
color: $primary-text-color;
|
color: var(--on-input-color);
|
||||||
|
padding: 12px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
|
line-height: normal;
|
||||||
|
border-radius: 4px;
|
||||||
display: block;
|
display: block;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
|
||||||
|
|
|
@ -1256,13 +1256,13 @@ code {
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-form {
|
.app-form {
|
||||||
padding: 20px;
|
padding: 16px;
|
||||||
|
|
||||||
&__avatar-input,
|
&__avatar-input,
|
||||||
&__header-input {
|
&__header-input {
|
||||||
display: block;
|
display: block;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--dropdown-background-color);
|
background: var(--surface-variant-background-color);
|
||||||
position: relative;
|
position: relative;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
@ -1310,7 +1310,7 @@ code {
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--dropdown-border-color);
|
background-color: var(--surface-variant-active-background-color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -108,17 +108,6 @@
|
||||||
background: lighten($white, 4%);
|
background: lighten($white, 4%);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Change the background colors of status__content__spoiler-link
|
|
||||||
.reply-indicator__content .status__content__spoiler-link,
|
|
||||||
.status__content .status__content__spoiler-link {
|
|
||||||
background: $ui-base-color;
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&:focus {
|
|
||||||
background: lighten($ui-base-color, 4%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-gallery__item a {
|
.account-gallery__item a {
|
||||||
background-color: $ui-base-color;
|
background-color: $ui-base-color;
|
||||||
}
|
}
|
||||||
|
@ -510,17 +499,26 @@
|
||||||
color: lighten($ui-highlight-color, 8%);
|
color: lighten($ui-highlight-color, 8%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.compose-form .autosuggest-textarea__textarea,
|
|
||||||
.compose-form__highlightable,
|
|
||||||
.autosuggest-textarea__suggestions,
|
|
||||||
.search__input,
|
|
||||||
.search__popout,
|
|
||||||
.emoji-mart-search input,
|
.emoji-mart-search input,
|
||||||
.language-dropdown__dropdown .emoji-mart-search input,
|
.language-dropdown__dropdown .emoji-mart-search input,
|
||||||
.poll__option input[type='text'] {
|
.poll__option input[type='text'] {
|
||||||
background: darken($ui-base-color, 10%);
|
background: darken($ui-base-color, 10%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search__popout__menu__item {
|
||||||
|
&:hover,
|
||||||
|
&:active,
|
||||||
|
&:focus,
|
||||||
|
&.active {
|
||||||
|
color: $white;
|
||||||
|
|
||||||
|
mark,
|
||||||
|
.icon-button {
|
||||||
|
color: $white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.inline-follow-suggestions {
|
.inline-follow-suggestions {
|
||||||
background-color: rgba($ui-highlight-color, 0.1);
|
background-color: rgba($ui-highlight-color, 0.1);
|
||||||
border-bottom-color: rgba($ui-highlight-color, 0.3);
|
border-bottom-color: rgba($ui-highlight-color, 0.3);
|
||||||
|
|
|
@ -80,4 +80,5 @@ body {
|
||||||
--rich-text-text-color: rgba(114, 47, 83, 100%);
|
--rich-text-text-color: rgba(114, 47, 83, 100%);
|
||||||
--rich-text-decorations-color: rgba(255, 175, 212, 100%);
|
--rich-text-decorations-color: rgba(255, 175, 212, 100%);
|
||||||
--input-placeholder-color: #{transparentize($dark-text-color, 0.5)};
|
--input-placeholder-color: #{transparentize($dark-text-color, 0.5)};
|
||||||
|
--input-background-color: #{darken($ui-base-color, 10%)};
|
||||||
}
|
}
|
||||||
|
|
|
@ -44,11 +44,6 @@
|
||||||
background: darken($ui-primary-color, 5%);
|
background: darken($ui-primary-color, 5%);
|
||||||
}
|
}
|
||||||
|
|
||||||
&::-ms-fill {
|
|
||||||
border-radius: 4px;
|
|
||||||
background: darken($ui-primary-color, 5%);
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-progress-value {
|
&::-webkit-progress-value {
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: darken($ui-primary-color, 5%);
|
background: darken($ui-primary-color, 5%);
|
||||||
|
|
|
@ -126,4 +126,6 @@ $dismiss-overlay-width: 4rem;
|
||||||
--rich-text-text-color: rgba(255, 175, 212, 100%);
|
--rich-text-text-color: rgba(255, 175, 212, 100%);
|
||||||
--rich-text-decorations-color: rgba(128, 58, 95, 100%);
|
--rich-text-decorations-color: rgba(128, 58, 95, 100%);
|
||||||
--input-placeholder-color: #{$dark-text-color};
|
--input-placeholder-color: #{$dark-text-color};
|
||||||
|
--input-background-color: var(--surface-variant-background-color);
|
||||||
|
--on-input-color: #{$secondary-text-color};
|
||||||
}
|
}
|
||||||
|
|
|
@ -84,6 +84,7 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
.account {
|
.account {
|
||||||
|
max-width: calc(56px + 30ch);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(() => {});
|
|
||||||
};
|
|
|
@ -0,0 +1,24 @@
|
||||||
|
import {
|
||||||
|
apiGetSuggestions,
|
||||||
|
apiDeleteSuggestion,
|
||||||
|
} from 'mastodon/api/suggestions';
|
||||||
|
import { createDataLoadingThunk } from 'mastodon/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),
|
||||||
|
);
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { apiRequestGet, apiRequestDelete } from 'mastodon/api';
|
||||||
|
import type { ApiSuggestionJSON } from 'mastodon/api_types/suggestions';
|
||||||
|
|
||||||
|
export const apiGetSuggestions = (limit: number) =>
|
||||||
|
apiRequestGet<ApiSuggestionJSON[]>('v2/suggestions', { limit });
|
||||||
|
|
||||||
|
export const apiDeleteSuggestion = (accountId: string) =>
|
||||||
|
apiRequestDelete(`v1/suggestions/${accountId}`);
|
|
@ -0,0 +1,13 @@
|
||||||
|
import type { ApiAccountJSON } from 'mastodon/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;
|
||||||
|
}
|
|
@ -10,6 +10,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
|
||||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||||
import { EmptyAccount } from 'mastodon/components/empty_account';
|
import { EmptyAccount } from 'mastodon/components/empty_account';
|
||||||
|
import { FollowButton } from 'mastodon/components/follow_button';
|
||||||
import { ShortNumber } from 'mastodon/components/short_number';
|
import { ShortNumber } from 'mastodon/components/short_number';
|
||||||
import { VerifiedBadge } from 'mastodon/components/verified_badge';
|
import { VerifiedBadge } from 'mastodon/components/verified_badge';
|
||||||
|
|
||||||
|
@ -23,9 +24,6 @@ import { DisplayName } from './display_name';
|
||||||
import { RelativeTimestamp } from './relative_timestamp';
|
import { RelativeTimestamp } from './relative_timestamp';
|
||||||
|
|
||||||
const messages = defineMessages({
|
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' },
|
unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' },
|
||||||
unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' },
|
unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' },
|
||||||
mute_notifications: { id: 'account.mute_notifications_short', defaultMessage: 'Mute notifications' },
|
mute_notifications: { id: 'account.mute_notifications_short', defaultMessage: 'Mute notifications' },
|
||||||
|
@ -35,13 +33,9 @@ const messages = defineMessages({
|
||||||
more: { id: 'status.more', defaultMessage: 'More' },
|
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 intl = useIntl();
|
||||||
|
|
||||||
const handleFollow = useCallback(() => {
|
|
||||||
onFollow(account);
|
|
||||||
}, [onFollow, account]);
|
|
||||||
|
|
||||||
const handleBlock = useCallback(() => {
|
const handleBlock = useCallback(() => {
|
||||||
onBlock(account);
|
onBlock(account);
|
||||||
}, [onBlock, account]);
|
}, [onBlock, account]);
|
||||||
|
@ -74,13 +68,12 @@ const Account = ({ size = 46, account, onFollow, onBlock, onMute, onMuteNotifica
|
||||||
let buttons;
|
let buttons;
|
||||||
|
|
||||||
if (account.get('id') !== me && account.get('relationship', null) !== null) {
|
if (account.get('id') !== me && account.get('relationship', null) !== null) {
|
||||||
const following = account.getIn(['relationship', 'following']);
|
|
||||||
const requested = account.getIn(['relationship', 'requested']);
|
const requested = account.getIn(['relationship', 'requested']);
|
||||||
const blocking = account.getIn(['relationship', 'blocking']);
|
const blocking = account.getIn(['relationship', 'blocking']);
|
||||||
const muting = account.getIn(['relationship', 'muting']);
|
const muting = account.getIn(['relationship', 'muting']);
|
||||||
|
|
||||||
if (requested) {
|
if (requested) {
|
||||||
buttons = <Button text={intl.formatMessage(messages.cancel_follow_request)} onClick={handleFollow} />;
|
buttons = <FollowButton accountId={account.get('id')} />;
|
||||||
} else if (blocking) {
|
} else if (blocking) {
|
||||||
buttons = <Button text={intl.formatMessage(messages.unblock)} onClick={handleBlock} />;
|
buttons = <Button text={intl.formatMessage(messages.unblock)} onClick={handleBlock} />;
|
||||||
} else if (muting) {
|
} 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} />;
|
buttons = <Button text={intl.formatMessage(messages.mute)} onClick={handleMute} />;
|
||||||
} else if (defaultAction === 'block') {
|
} else if (defaultAction === 'block') {
|
||||||
buttons = <Button text={intl.formatMessage(messages.block)} onClick={handleBlock} />;
|
buttons = <Button text={intl.formatMessage(messages.block)} onClick={handleBlock} />;
|
||||||
} else if (!account.get('suspended') && !account.get('moved') || following) {
|
} else {
|
||||||
buttons = <Button text={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={handleFollow} />;
|
buttons = <FollowButton accountId={account.get('id')} />;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
buttons = <FollowButton accountId={account.get('id')} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
let muteTimeRemaining;
|
let muteTimeRemaining;
|
||||||
|
@ -168,7 +163,6 @@ const Account = ({ size = 46, account, onFollow, onBlock, onMute, onMuteNotifica
|
||||||
Account.propTypes = {
|
Account.propTypes = {
|
||||||
size: PropTypes.number,
|
size: PropTypes.number,
|
||||||
account: ImmutablePropTypes.record,
|
account: ImmutablePropTypes.record,
|
||||||
onFollow: PropTypes.func,
|
|
||||||
onBlock: PropTypes.func,
|
onBlock: PropTypes.func,
|
||||||
onMute: PropTypes.func,
|
onMute: PropTypes.func,
|
||||||
onMuteNotifications: PropTypes.func,
|
onMuteNotifications: PropTypes.func,
|
||||||
|
|
|
@ -24,7 +24,7 @@ function useHandleClick(onClick?: OnClickCallback) {
|
||||||
}, [history, onClick]);
|
}, [history, onClick]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ColumnBackButton: React.FC<{ onClick: OnClickCallback }> = ({
|
export const ColumnBackButton: React.FC<{ onClick?: OnClickCallback }> = ({
|
||||||
onClick,
|
onClick,
|
||||||
}) => {
|
}) => {
|
||||||
const handleClick = useHandleClick(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 (
|
return (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
disabled={relationship?.blocked_by || relationship?.blocking}
|
disabled={
|
||||||
|
relationship?.blocked_by ||
|
||||||
|
relationship?.blocking ||
|
||||||
|
(!(relationship?.following || relationship?.requested) &&
|
||||||
|
(account?.suspended || !!account?.moved))
|
||||||
|
}
|
||||||
secondary={following}
|
secondary={following}
|
||||||
className={following ? 'button--destructive' : undefined}
|
className={following ? 'button--destructive' : undefined}
|
||||||
>
|
>
|
||||||
|
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { useHovering } from '@/hooks/useHovering';
|
||||||
|
import { autoPlayGif } from 'mastodon/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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
|
@ -173,7 +173,7 @@ class Status extends ImmutablePureComponent {
|
||||||
handleMouseUp = e => {
|
handleMouseUp = e => {
|
||||||
// Only handle clicks on the empty space above the content
|
// Only handle clicks on the empty space above the content
|
||||||
|
|
||||||
if (e.target !== e.currentTarget) {
|
if (e.target !== e.currentTarget && e.detail >= 1) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -204,7 +204,7 @@ class StatusContent extends PureComponent {
|
||||||
element = element.parentNode;
|
element = element.parentNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (deltaX + deltaY < 5 && (e.button === 0 || e.button === 1) && this.props.onClick) {
|
if (deltaX + deltaY < 5 && (e.button === 0 || e.button === 1) && e.detail >= 1 && this.props.onClick) {
|
||||||
this.props.onClick(e);
|
this.props.onClick(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -45,7 +45,7 @@ class Links extends PureComponent {
|
||||||
|
|
||||||
const banner = (
|
const banner = (
|
||||||
<DismissableBanner id='explore/links'>
|
<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>
|
</DismissableBanner>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -58,7 +58,7 @@ class Statuses extends PureComponent {
|
||||||
return (
|
return (
|
||||||
<StatusList
|
<StatusList
|
||||||
trackScroll
|
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
|
alwaysPrepend
|
||||||
timelineId='explore'
|
timelineId='explore'
|
||||||
statusIds={statusIds}
|
statusIds={statusIds}
|
||||||
|
|
|
@ -5,7 +5,6 @@ import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import { withRouter } from 'react-router-dom';
|
import { withRouter } from 'react-router-dom';
|
||||||
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
import { fetchSuggestions } from 'mastodon/actions/suggestions';
|
import { fetchSuggestions } from 'mastodon/actions/suggestions';
|
||||||
|
@ -15,15 +14,15 @@ import { WithRouterPropTypes } from 'mastodon/utils/react_router';
|
||||||
import { Card } from './components/card';
|
import { Card } from './components/card';
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
suggestions: state.getIn(['suggestions', 'items']),
|
suggestions: state.suggestions.items,
|
||||||
isLoading: state.getIn(['suggestions', 'isLoading']),
|
isLoading: state.suggestions.isLoading,
|
||||||
});
|
});
|
||||||
|
|
||||||
class Suggestions extends PureComponent {
|
class Suggestions extends PureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
isLoading: PropTypes.bool,
|
isLoading: PropTypes.bool,
|
||||||
suggestions: ImmutablePropTypes.list,
|
suggestions: PropTypes.array,
|
||||||
dispatch: PropTypes.func.isRequired,
|
dispatch: PropTypes.func.isRequired,
|
||||||
...WithRouterPropTypes,
|
...WithRouterPropTypes,
|
||||||
};
|
};
|
||||||
|
@ -32,17 +31,17 @@ class Suggestions extends PureComponent {
|
||||||
const { dispatch, suggestions, history } = this.props;
|
const { dispatch, suggestions, history } = this.props;
|
||||||
|
|
||||||
// If we're navigating back to the screen, do not trigger a reload
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch(fetchSuggestions(true));
|
dispatch(fetchSuggestions());
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { isLoading, suggestions } = this.props;
|
const { isLoading, suggestions } = this.props;
|
||||||
|
|
||||||
if (!isLoading && suggestions.isEmpty()) {
|
if (!isLoading && suggestions.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className='explore__suggestions scrollable scrollable--flex'>
|
<div className='explore__suggestions scrollable scrollable--flex'>
|
||||||
<div className='empty-column-indicator'>
|
<div className='empty-column-indicator'>
|
||||||
|
@ -56,9 +55,9 @@ class Suggestions extends PureComponent {
|
||||||
<div className='explore__suggestions scrollable' data-nosnippet>
|
<div className='explore__suggestions scrollable' data-nosnippet>
|
||||||
{isLoading ? <LoadingIndicator /> : suggestions.map(suggestion => (
|
{isLoading ? <LoadingIndicator /> : suggestions.map(suggestion => (
|
||||||
<Card
|
<Card
|
||||||
key={suggestion.get('account')}
|
key={suggestion.account_id}
|
||||||
id={suggestion.get('account')}
|
id={suggestion.account_id}
|
||||||
source={suggestion.getIn(['sources', 0])}
|
source={suggestion.sources[0]}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -44,7 +44,7 @@ class Tags extends PureComponent {
|
||||||
|
|
||||||
const banner = (
|
const banner = (
|
||||||
<DismissableBanner id='explore/tags'>
|
<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>
|
</DismissableBanner>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -133,7 +133,7 @@ const Firehose = ({ feedType, multiColumn }) => {
|
||||||
<DismissableBanner id='public_timeline'>
|
<DismissableBanner id='public_timeline'>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='dismissable_banner.public_timeline'
|
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 }}
|
values={{ domain }}
|
||||||
/>
|
/>
|
||||||
</DismissableBanner>
|
</DismissableBanner>
|
||||||
|
|
|
@ -4,12 +4,17 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
|
||||||
|
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||||
import { Button } from 'mastodon/components/button';
|
import { Button } from 'mastodon/components/button';
|
||||||
import { ShortNumber } from 'mastodon/components/short_number';
|
import { ShortNumber } from 'mastodon/components/short_number';
|
||||||
|
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
|
||||||
|
import { withIdentity } from 'mastodon/identity_context';
|
||||||
|
import { PERMISSION_MANAGE_TAXONOMIES } from 'mastodon/permissions';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
followHashtag: { id: 'hashtag.follow', defaultMessage: 'Follow hashtag' },
|
followHashtag: { id: 'hashtag.follow', defaultMessage: 'Follow hashtag' },
|
||||||
unfollowHashtag: { id: 'hashtag.unfollow', defaultMessage: 'Unfollow hashtag' },
|
unfollowHashtag: { id: 'hashtag.unfollow', defaultMessage: 'Unfollow hashtag' },
|
||||||
|
adminModeration: { id: 'hashtag.admin_moderation', defaultMessage: 'Open moderation interface for #{name}' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const usesRenderer = (displayNumber, pluralReady) => (
|
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) {
|
if (!tag) {
|
||||||
return null;
|
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 [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>;
|
const dividingCircle = <span aria-hidden>{' · '}</span>;
|
||||||
|
|
||||||
|
@ -57,7 +69,10 @@ export const HashtagHeader = injectIntl(({ tag, intl, disabled, onClick }) => {
|
||||||
<div className='hashtag-header'>
|
<div className='hashtag-header'>
|
||||||
<div className='hashtag-header__header'>
|
<div className='hashtag-header__header'>
|
||||||
<h1>#{tag.get('name')}</h1>
|
<h1>#{tag.get('name')}</h1>
|
||||||
<Button onClick={onClick} text={intl.formatMessage(tag.get('following') ? messages.unfollowHashtag : messages.followHashtag)} disabled={disabled} />
|
<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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
@ -69,7 +84,7 @@ export const HashtagHeader = injectIntl(({ tag, intl, disabled, onClick }) => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
}));
|
||||||
|
|
||||||
HashtagHeader.propTypes = {
|
HashtagHeader.propTypes = {
|
||||||
tag: ImmutablePropTypes.map,
|
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 'mastodon/actions/settings';
|
|
||||||
import { fetchSuggestions, dismissSuggestion } from 'mastodon/actions/suggestions';
|
|
||||||
import { Avatar } from 'mastodon/components/avatar';
|
|
||||||
import { DisplayName } from 'mastodon/components/display_name';
|
|
||||||
import { FollowButton } from 'mastodon/components/follow_button';
|
|
||||||
import { Icon } from 'mastodon/components/icon';
|
|
||||||
import { IconButton } from 'mastodon/components/icon_button';
|
|
||||||
import { VerifiedBadge } from 'mastodon/components/verified_badge';
|
|
||||||
import { domain } from 'mastodon/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 'mastodon/actions/settings';
|
||||||
|
import {
|
||||||
|
fetchSuggestions,
|
||||||
|
dismissSuggestion,
|
||||||
|
} from 'mastodon/actions/suggestions';
|
||||||
|
import type { ApiSuggestionSourceJSON } from 'mastodon/api_types/suggestions';
|
||||||
|
import { Avatar } from 'mastodon/components/avatar';
|
||||||
|
import { DisplayName } from 'mastodon/components/display_name';
|
||||||
|
import { FollowButton } from 'mastodon/components/follow_button';
|
||||||
|
import { Icon } from 'mastodon/components/icon';
|
||||||
|
import { IconButton } from 'mastodon/components/icon_button';
|
||||||
|
import { VerifiedBadge } from 'mastodon/components/verified_badge';
|
||||||
|
import { domain } from 'mastodon/initial_state';
|
||||||
|
import { useAppDispatch, useAppSelector } from 'mastodon/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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -7,11 +7,8 @@ import { useParams, Link } from 'react-router-dom';
|
||||||
|
|
||||||
import { useDebouncedCallback } from 'use-debounce';
|
import { useDebouncedCallback } from 'use-debounce';
|
||||||
|
|
||||||
import AddIcon from '@/material-icons/400-24px/add.svg?react';
|
|
||||||
import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.svg?react';
|
|
||||||
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
|
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
|
||||||
import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
|
import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
|
||||||
import { fetchFollowing } from 'mastodon/actions/accounts';
|
|
||||||
import { importFetchedAccounts } from 'mastodon/actions/importer';
|
import { importFetchedAccounts } from 'mastodon/actions/importer';
|
||||||
import { fetchList } from 'mastodon/actions/lists';
|
import { fetchList } from 'mastodon/actions/lists';
|
||||||
import { apiRequest } from 'mastodon/api';
|
import { apiRequest } from 'mastodon/api';
|
||||||
|
@ -25,14 +22,12 @@ import { Avatar } from 'mastodon/components/avatar';
|
||||||
import { Button } from 'mastodon/components/button';
|
import { Button } from 'mastodon/components/button';
|
||||||
import Column from 'mastodon/components/column';
|
import Column from 'mastodon/components/column';
|
||||||
import { ColumnHeader } from 'mastodon/components/column_header';
|
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||||
|
import { ColumnSearchHeader } from 'mastodon/components/column_search_header';
|
||||||
import { FollowersCounter } from 'mastodon/components/counters';
|
import { FollowersCounter } from 'mastodon/components/counters';
|
||||||
import { DisplayName } from 'mastodon/components/display_name';
|
import { DisplayName } from 'mastodon/components/display_name';
|
||||||
import { Icon } from 'mastodon/components/icon';
|
|
||||||
import ScrollableList from 'mastodon/components/scrollable_list';
|
import ScrollableList from 'mastodon/components/scrollable_list';
|
||||||
import { ShortNumber } from 'mastodon/components/short_number';
|
import { ShortNumber } from 'mastodon/components/short_number';
|
||||||
import { VerifiedBadge } from 'mastodon/components/verified_badge';
|
import { VerifiedBadge } from 'mastodon/components/verified_badge';
|
||||||
import { ButtonInTabsBar } from 'mastodon/features/ui/util/columns_context';
|
|
||||||
import { me } from 'mastodon/initial_state';
|
|
||||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
|
@ -49,54 +44,6 @@ const messages = defineMessages({
|
||||||
|
|
||||||
type Mode = 'remove' | 'add';
|
type Mode = 'remove' | 'add';
|
||||||
|
|
||||||
const ColumnSearchHeader: React.FC<{
|
|
||||||
onBack: () => void;
|
|
||||||
onSubmit: (value: string) => void;
|
|
||||||
}> = ({ onBack, onSubmit }) => {
|
|
||||||
const intl = useIntl();
|
|
||||||
const [value, setValue] = useState('');
|
|
||||||
|
|
||||||
const handleChange = useCallback(
|
|
||||||
({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setValue(value);
|
|
||||||
onSubmit(value);
|
|
||||||
},
|
|
||||||
[setValue, onSubmit],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(() => {
|
|
||||||
onSubmit(value);
|
|
||||||
}, [onSubmit, value]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ButtonInTabsBar>
|
|
||||||
<form className='column-search-header' onSubmit={handleSubmit}>
|
|
||||||
<button
|
|
||||||
type='button'
|
|
||||||
className='column-header__back-button compact'
|
|
||||||
onClick={onBack}
|
|
||||||
aria-label={intl.formatMessage(messages.back)}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
id='chevron-left'
|
|
||||||
icon={ArrowBackIcon}
|
|
||||||
className='column-back-button__icon'
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<input
|
|
||||||
type='search'
|
|
||||||
value={value}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder={intl.formatMessage(messages.placeholder)}
|
|
||||||
/* eslint-disable-next-line jsx-a11y/no-autofocus */
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
</ButtonInTabsBar>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const AccountItem: React.FC<{
|
const AccountItem: React.FC<{
|
||||||
accountId: string;
|
accountId: string;
|
||||||
listId: string;
|
listId: string;
|
||||||
|
@ -156,6 +103,7 @@ const AccountItem: React.FC<{
|
||||||
text={intl.formatMessage(
|
text={intl.formatMessage(
|
||||||
partOfList ? messages.remove : messages.add,
|
partOfList ? messages.remove : messages.add,
|
||||||
)}
|
)}
|
||||||
|
secondary={partOfList}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -171,9 +119,6 @@ const ListMembers: React.FC<{
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const followingAccountIds = useAppSelector(
|
|
||||||
(state) => state.user_lists.getIn(['following', me, 'items']) as string[],
|
|
||||||
);
|
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
const [accountIds, setAccountIds] = useState<string[]>([]);
|
const [accountIds, setAccountIds] = useState<string[]>([]);
|
||||||
const [searchAccountIds, setSearchAccountIds] = useState<string[]>([]);
|
const [searchAccountIds, setSearchAccountIds] = useState<string[]>([]);
|
||||||
|
@ -195,8 +140,6 @@ const ListMembers: React.FC<{
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
dispatch(fetchFollowing(me));
|
|
||||||
}
|
}
|
||||||
}, [dispatch, id]);
|
}, [dispatch, id]);
|
||||||
|
|
||||||
|
@ -265,8 +208,8 @@ const ListMembers: React.FC<{
|
||||||
|
|
||||||
let displayedAccountIds: string[];
|
let displayedAccountIds: string[];
|
||||||
|
|
||||||
if (mode === 'add') {
|
if (mode === 'add' && searching) {
|
||||||
displayedAccountIds = searching ? searchAccountIds : followingAccountIds;
|
displayedAccountIds = searchAccountIds;
|
||||||
} else {
|
} else {
|
||||||
displayedAccountIds = accountIds;
|
displayedAccountIds = accountIds;
|
||||||
}
|
}
|
||||||
|
@ -276,31 +219,21 @@ const ListMembers: React.FC<{
|
||||||
bindToDocument={!multiColumn}
|
bindToDocument={!multiColumn}
|
||||||
label={intl.formatMessage(messages.heading)}
|
label={intl.formatMessage(messages.heading)}
|
||||||
>
|
>
|
||||||
{mode === 'remove' ? (
|
<ColumnHeader
|
||||||
<ColumnHeader
|
title={intl.formatMessage(messages.heading)}
|
||||||
title={intl.formatMessage(messages.heading)}
|
icon='list-ul'
|
||||||
icon='list-ul'
|
iconComponent={ListAltIcon}
|
||||||
iconComponent={ListAltIcon}
|
multiColumn={multiColumn}
|
||||||
multiColumn={multiColumn}
|
showBackButton
|
||||||
showBackButton
|
/>
|
||||||
extraButton={
|
|
||||||
<button
|
<ColumnSearchHeader
|
||||||
onClick={handleSearchClick}
|
placeholder={intl.formatMessage(messages.placeholder)}
|
||||||
type='button'
|
onBack={handleDismissSearchClick}
|
||||||
className='column-header__button'
|
onSubmit={handleSearch}
|
||||||
title={intl.formatMessage(messages.enterSearch)}
|
onActivate={handleSearchClick}
|
||||||
aria-label={intl.formatMessage(messages.enterSearch)}
|
active={mode === 'add'}
|
||||||
>
|
/>
|
||||||
<Icon id='plus' icon={AddIcon} />
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ColumnSearchHeader
|
|
||||||
onBack={handleDismissSearchClick}
|
|
||||||
onSubmit={handleSearch}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ScrollableList
|
<ScrollableList
|
||||||
scrollKey='list_members'
|
scrollKey='list_members'
|
||||||
|
@ -310,17 +243,15 @@ const ListMembers: React.FC<{
|
||||||
showLoading={loading && displayedAccountIds.length === 0}
|
showLoading={loading && displayedAccountIds.length === 0}
|
||||||
hasMore={false}
|
hasMore={false}
|
||||||
footer={
|
footer={
|
||||||
mode === 'remove' && (
|
<>
|
||||||
<>
|
{displayedAccountIds.length > 0 && <div className='spacer' />}
|
||||||
<div className='spacer' />
|
|
||||||
|
|
||||||
<div className='column-footer'>
|
<div className='column-footer'>
|
||||||
<Link to={`/lists/${id}`} className='button button--block'>
|
<Link to={`/lists/${id}`} className='button button--block'>
|
||||||
<FormattedMessage id='lists.done' defaultMessage='Done' />
|
<FormattedMessage id='lists.done' defaultMessage='Done' />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
|
||||||
}
|
}
|
||||||
emptyMessage={
|
emptyMessage={
|
||||||
mode === 'remove' ? (
|
mode === 'remove' ? (
|
||||||
|
|
|
@ -1,119 +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 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 'mastodon/components/icon';
|
|
||||||
|
|
||||||
const tooltips = defineMessages({
|
|
||||||
mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' },
|
|
||||||
favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Favorites' },
|
|
||||||
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 === '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,308 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { PureComponent } from 'react';
|
|
||||||
|
|
||||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
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 DoneAllIcon from '@/material-icons/400-24px/done_all.svg?react';
|
|
||||||
import NotificationsIcon from '@/material-icons/400-24px/notifications-fill.svg?react';
|
|
||||||
import { compareId } from 'mastodon/compare_id';
|
|
||||||
import { Icon } from 'mastodon/components/icon';
|
|
||||||
import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator';
|
|
||||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
|
||||||
|
|
||||||
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
|
|
||||||
import { submitMarkers } from '../../actions/markers';
|
|
||||||
import {
|
|
||||||
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 {
|
|
||||||
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' },
|
|
||||||
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 => ({
|
|
||||||
notifications: getNotifications(state),
|
|
||||||
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,
|
|
||||||
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']),
|
|
||||||
});
|
|
||||||
|
|
||||||
class Notifications extends PureComponent {
|
|
||||||
static propTypes = {
|
|
||||||
identity: identityContextPropShape,
|
|
||||||
columnId: PropTypes.string,
|
|
||||||
notifications: ImmutablePropTypes.list.isRequired,
|
|
||||||
dispatch: PropTypes.func.isRequired,
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
isLoading: PropTypes.bool,
|
|
||||||
isUnread: PropTypes.bool,
|
|
||||||
multiColumn: PropTypes.bool,
|
|
||||||
hasMore: PropTypes.bool,
|
|
||||||
numPending: PropTypes.number,
|
|
||||||
lastReadId: PropTypes.string,
|
|
||||||
canMarkAsRead: PropTypes.bool,
|
|
||||||
needsNotificationPermission: PropTypes.bool,
|
|
||||||
};
|
|
||||||
|
|
||||||
static defaultProps = {
|
|
||||||
trackScroll: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
UNSAFE_componentWillMount() {
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleMarkAsRead = () => {
|
|
||||||
this.props.dispatch(markNotificationsAsRead());
|
|
||||||
this.props.dispatch(submitMarkers({ immediate: true }));
|
|
||||||
};
|
|
||||||
|
|
||||||
render () {
|
|
||||||
const { intl, notifications, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, lastReadId, canMarkAsRead, needsNotificationPermission } = this.props;
|
|
||||||
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
|
|
||||||
? (<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 extraButton = (
|
|
||||||
<>
|
|
||||||
<FilteredNotificationsIconButton className='column-header__button' />
|
|
||||||
{canMarkAsRead && (
|
|
||||||
<button
|
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Column bindToDocument={!multiColumn} ref={this.setColumnRef} 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}
|
|
||||||
extraButton={extraButton}
|
|
||||||
>
|
|
||||||
<ColumnSettingsContainer />
|
|
||||||
</ColumnHeader>
|
|
||||||
|
|
||||||
{filterBarContainer}
|
|
||||||
|
|
||||||
{scrollContainer}
|
|
||||||
|
|
||||||
<Helmet>
|
|
||||||
<title>{intl.formatMessage(messages.title)}</title>
|
|
||||||
<meta name='robots' content='noindex' />
|
|
||||||
</Helmet>
|
|
||||||
</Column>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default connect(mapStateToProps)(withIdentity(injectIntl(Notifications)));
|
|
|
@ -1,9 +0,0 @@
|
||||||
import Notifications_v2 from 'mastodon/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 'mastodon/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 'mastodon/actions/suggestions';
|
|
||||||
import { markAsPartial } from 'mastodon/actions/timelines';
|
|
||||||
import { ColumnBackButton } from 'mastodon/components/column_back_button';
|
|
||||||
import { EmptyAccount } from 'mastodon/components/empty_account';
|
|
||||||
import Account from 'mastodon/containers/account_container';
|
|
||||||
import { useAppSelector } from 'mastodon/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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -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 'mastodon/actions/accounts';
|
||||||
|
import { importFetchedAccounts } from 'mastodon/actions/importer';
|
||||||
|
import { fetchSuggestions } from 'mastodon/actions/suggestions';
|
||||||
|
import { markAsPartial } from 'mastodon/actions/timelines';
|
||||||
|
import { apiRequest } from 'mastodon/api';
|
||||||
|
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
|
||||||
|
import Column from 'mastodon/components/column';
|
||||||
|
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||||
|
import { ColumnSearchHeader } from 'mastodon/components/column_search_header';
|
||||||
|
import ScrollableList from 'mastodon/components/scrollable_list';
|
||||||
|
import Account from 'mastodon/containers/account_container';
|
||||||
|
import { useAppSelector, useAppDispatch } from 'mastodon/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,91 +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 'mastodon/actions/compose';
|
|
||||||
import { Icon } from 'mastodon/components/icon';
|
|
||||||
import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator';
|
|
||||||
import Column from 'mastodon/features/ui/components/column';
|
|
||||||
import { me } from 'mastodon/initial_state';
|
|
||||||
import { useAppSelector } from 'mastodon/store';
|
|
||||||
import { assetHost } from 'mastodon/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>
|
|
||||||
{account ? (
|
|
||||||
<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>
|
|
||||||
) : <NotSignedInIndicator />}
|
|
||||||
|
|
||||||
<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 'mastodon/actions/accounts';
|
|
||||||
import { Button } from 'mastodon/components/button';
|
|
||||||
import { ColumnBackButton } from 'mastodon/components/column_back_button';
|
|
||||||
import { Icon } from 'mastodon/components/icon';
|
|
||||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
|
||||||
import { me } from 'mastodon/initial_state';
|
|
||||||
import { useAppSelector } from 'mastodon/store';
|
|
||||||
import { unescapeHTML } from 'mastodon/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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue