mirror of
https://git.kescher.at/CatCatNya/catstodon.git
synced 2025-01-18 12:44:04 +01:00
Merge branch 'upstream-main' into develop
This commit is contained in:
commit
b9c08026fd
1071 changed files with 20635 additions and 13356 deletions
|
@ -109,7 +109,7 @@ module.exports = defineConfig({
|
|||
'react/jsx-equals-spacing': 'error',
|
||||
'react/jsx-no-bind': 'error',
|
||||
'react/jsx-no-useless-fragment': 'error',
|
||||
'react/jsx-no-target-blank': 'off',
|
||||
'react/jsx-no-target-blank': ['error', { allowReferrer: true }],
|
||||
'react/jsx-tag-spacing': 'error',
|
||||
'react/jsx-uses-react': 'off', // not needed with new JSX transform
|
||||
'react/jsx-wrap-multilines': 'error',
|
||||
|
|
5
.github/renovate.json5
vendored
5
.github/renovate.json5
vendored
|
@ -14,6 +14,11 @@
|
|||
// If we do not want a package to be grouped with others, we need to set its groupName
|
||||
// to `null` after any other rule set it to something.
|
||||
dependencyDashboardHeader: 'This issue lists Renovate updates and detected dependencies. Read the [Dependency Dashboard](https://docs.renovatebot.com/key-concepts/dashboard/) docs to learn more. Before approving any upgrade: read the description and comments in the [`renovate.json5` file](https://github.com/mastodon/mastodon/blob/main/.github/renovate.json5).',
|
||||
constraints: {
|
||||
// Mastodon should work on Ruby 3.4, but its test dependencies are currently uninstallable on Ruby 3.4.
|
||||
// TODO: remove this once https://github.com/briandunn/flatware/issues/103 is fixed
|
||||
ruby: '3.3',
|
||||
},
|
||||
postUpdateOptions: ['yarnDedupeHighest'],
|
||||
packageRules: [
|
||||
{
|
||||
|
|
2
.github/workflows/lint-css.yml
vendored
2
.github/workflows/lint-css.yml
vendored
|
@ -40,4 +40,4 @@ jobs:
|
|||
uses: ./.github/actions/setup-javascript
|
||||
|
||||
- name: Stylelint
|
||||
run: yarn lint:css -f github
|
||||
run: yarn lint:css --custom-formatter @csstools/stylelint-formatter-github
|
||||
|
|
2
.github/workflows/lint-ruby.yml
vendored
2
.github/workflows/lint-ruby.yml
vendored
|
@ -9,6 +9,7 @@ on:
|
|||
- 'Gemfile*'
|
||||
- '.rubocop*.yml'
|
||||
- '.ruby-version'
|
||||
- 'bin/rubocop'
|
||||
- 'config/brakeman.ignore'
|
||||
- '**/*.rb'
|
||||
- '**/*.rake'
|
||||
|
@ -19,6 +20,7 @@ on:
|
|||
- 'Gemfile*'
|
||||
- '.rubocop*.yml'
|
||||
- '.ruby-version'
|
||||
- 'bin/rubocop'
|
||||
- 'config/brakeman.ignore'
|
||||
- '**/*.rb'
|
||||
- '**/*.rake'
|
||||
|
|
6
.github/workflows/test-migrations.yml
vendored
6
.github/workflows/test-migrations.yml
vendored
|
@ -12,6 +12,7 @@ on:
|
|||
- '**/*.rb'
|
||||
- '.github/workflows/test-migrations.yml'
|
||||
- 'lib/tasks/tests.rake'
|
||||
- 'lib/tasks/db.rake'
|
||||
|
||||
pull_request:
|
||||
paths:
|
||||
|
@ -90,6 +91,11 @@ jobs:
|
|||
bin/rails db:drop
|
||||
bin/rails db:create
|
||||
SKIP_POST_DEPLOYMENT_MIGRATIONS=true bin/rails tests:migrations:prepare_database
|
||||
|
||||
# Migrate up to v4.2.0 breakpoint
|
||||
bin/rails db:migrate VERSION=20230907150100
|
||||
|
||||
# Migrate the rest
|
||||
SKIP_POST_DEPLOYMENT_MIGRATIONS=true bin/rails db:migrate
|
||||
bin/rails db:migrate
|
||||
bin/rails tests:migrations:check_database
|
||||
|
|
4
.github/workflows/test-ruby.yml
vendored
4
.github/workflows/test-ruby.yml
vendored
|
@ -166,7 +166,7 @@ jobs:
|
|||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: matrix.ruby-version == '.ruby-version'
|
||||
uses: codecov/codecov-action@v4
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
files: coverage/lcov/*.lcov
|
||||
env:
|
||||
|
@ -252,7 +252,7 @@ jobs:
|
|||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: matrix.ruby-version == '.ruby-version'
|
||||
uses: codecov/codecov-action@v4
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
files: coverage/lcov/mastodon.lcov
|
||||
env:
|
||||
|
|
2
.nvmrc
2
.nvmrc
|
@ -1 +1 @@
|
|||
22.11
|
||||
22.12
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
---
|
||||
Style/ArrayIntersect:
|
||||
Enabled: false
|
||||
|
||||
Style/ClassAndModuleChildren:
|
||||
Enabled: false
|
||||
|
||||
|
@ -19,6 +22,16 @@ Style/HashSyntax:
|
|||
EnforcedShorthandSyntax: either
|
||||
EnforcedStyle: ruby19_no_mixed_keys
|
||||
|
||||
Style/IfUnlessModifier:
|
||||
Exclude:
|
||||
- '**/*.haml'
|
||||
|
||||
Style/KeywordArgumentsMerging:
|
||||
Enabled: false
|
||||
|
||||
Style/MultipleComparison:
|
||||
Enabled: false
|
||||
|
||||
Style/NumericLiterals:
|
||||
AllowedPatterns:
|
||||
- \d{4}_\d{2}_\d{2}_\d{6}
|
||||
|
@ -37,6 +50,9 @@ Style/RedundantFetchBlock:
|
|||
Style/RescueStandardError:
|
||||
EnforcedStyle: implicit
|
||||
|
||||
Style/SafeNavigationChainLength:
|
||||
Enabled: false
|
||||
|
||||
Style/SymbolArray:
|
||||
Enabled: false
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
# This configuration was generated by
|
||||
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp`
|
||||
# using RuboCop version 1.66.1.
|
||||
# using RuboCop version 1.69.1.
|
||||
# The point is for the user to remove these configuration records
|
||||
# one by one as the offenses are removed from the code base.
|
||||
# Note that changes in the inspected code, or installation of new
|
||||
|
@ -35,7 +35,6 @@ Rails/OutputSafety:
|
|||
# Configuration parameters: AllowedVars.
|
||||
Style/FetchEnvVar:
|
||||
Exclude:
|
||||
- 'app/lib/translation_service.rb'
|
||||
- 'config/environments/production.rb'
|
||||
- 'config/initializers/2_limited_federation_mode.rb'
|
||||
- 'config/initializers/3_omniauth.rb'
|
||||
|
|
|
@ -2,6 +2,48 @@
|
|||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [4.3.2] - 2024-12-03
|
||||
|
||||
### Added
|
||||
|
||||
- Add `tootctl feeds vacuum` (#33065 by @ClearlyClaire)
|
||||
- Add error message when user tries to follow their own account (#31910 by @lenikadali)
|
||||
- Add client_secret_expires_at to OAuth Applications (#30317 by @ThisIsMissEm)
|
||||
|
||||
### Changed
|
||||
|
||||
- Change design of Content Warnings and filters (#32543 by @ClearlyClaire)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix processing incoming post edits with mentions to unresolvable accounts (#33129 by @ClearlyClaire)
|
||||
- Fix error when including multiple instances of `embed.js` (#33107 by @YKWeyer)
|
||||
- Fix inactive users' timelines being backfilled on follow and unsuspend (#33094 by @ClearlyClaire)
|
||||
- Fix direct inbox delivery pushing posts into inactive followers' timelines (#33067 by @ClearlyClaire)
|
||||
- Fix `TagFollow` records not being correctly handled in account operations (#33063 by @ClearlyClaire)
|
||||
- Fix pushing hashtag-followed posts to feeds of inactive users (#33018 by @Gargron)
|
||||
- Fix duplicate notifications in notification groups when using slow mode (#33014 by @ClearlyClaire)
|
||||
- Fix posts made in the future being allowed to trend (#32996 by @ClearlyClaire)
|
||||
- Fix uploading higher-than-wide GIF profile picture with libvips enabled (#32911 by @ClearlyClaire)
|
||||
- Fix domain attribution field having autocorrect and autocapitalize enabled (#32903 by @ClearlyClaire)
|
||||
- Fix titles being escaped twice (#32889 by @ClearlyClaire)
|
||||
- Fix list creation limit check (#32869 by @ClearlyClaire)
|
||||
- Fix error in `tootctl email_domain_blocks` when supplying `--with-dns-records` (#32863 by @mjankowski)
|
||||
- Fix `min_id` and `max_id` causing error in search API (#32857 by @Gargron)
|
||||
- Fix inefficiencies when processing removal of posts that use featured tags (#32787 by @ClearlyClaire)
|
||||
- Fix alt-text pop-in not using the translated description (#32766 by @ClearlyClaire)
|
||||
- Fix preview cards with long titles erroneously causing layout changes (#32678 by @ClearlyClaire)
|
||||
- Fix embed modal layout on mobile (#32641 by @DismalShadowX)
|
||||
- Fix and improve batch attachment deletion handling when using OpenStack Swift (#32637 by @hugogameiro)
|
||||
- Fix blocks not being applied on link timeline (#32625 by @tribela)
|
||||
- Fix follow counters being incorrectly changed (#32622 by @oneiros)
|
||||
- Fix 'unknown' media attachment type rendering (#32613 and #32713 by @ThisIsMissEm and @renatolond)
|
||||
- Fix tl language native name (#32606 by @seav)
|
||||
|
||||
### Security
|
||||
|
||||
- Update dependencies
|
||||
|
||||
## [4.3.1] - 2024-10-21
|
||||
|
||||
### Added
|
||||
|
@ -399,7 +441,7 @@ The following changelog entries focus on changes visible to users, administrator
|
|||
- Fix empty environment variables not using default nil value (#27400 by @renchap)
|
||||
- Fix language sorting in settings (#27158 by @gunchleoc)
|
||||
|
||||
## |4.2.11] - 2024-08-16
|
||||
## [4.2.11] - 2024-08-16
|
||||
|
||||
### Added
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# syntax=docker/dockerfile:1.11
|
||||
# syntax=docker/dockerfile:1.12
|
||||
|
||||
# This file is designed for production server deployment, not local development work
|
||||
# For a containerized local dev environment, see: https://github.com/mastodon/mastodon/blob/main/README.md#docker
|
||||
|
|
12
Gemfile
12
Gemfile
|
@ -1,7 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
source 'https://rubygems.org'
|
||||
ruby '>= 3.2.0'
|
||||
ruby '>= 3.2.0', '< 3.5'
|
||||
|
||||
gem 'propshaft'
|
||||
gem 'puma', '~> 6.3'
|
||||
|
@ -79,7 +79,7 @@ gem 'redis', '~> 4.5', require: ['redis', 'redis/connection/hiredis']
|
|||
gem 'redis-namespace', '~> 1.10'
|
||||
gem 'rqrcode', '~> 2.2'
|
||||
gem 'ruby-progressbar', '~> 1.13'
|
||||
gem 'sanitize', '~> 6.0'
|
||||
gem 'sanitize', '~> 7.0'
|
||||
gem 'scenic', '~> 1.7'
|
||||
gem 'sidekiq', '~> 6.5'
|
||||
gem 'sidekiq-bulk', '~> 0.2.0'
|
||||
|
@ -105,7 +105,7 @@ gem 'opentelemetry-api', '~> 1.4.0'
|
|||
group :opentelemetry do
|
||||
gem 'opentelemetry-exporter-otlp', '~> 0.29.0', require: false
|
||||
gem 'opentelemetry-instrumentation-active_job', '~> 0.7.1', require: false
|
||||
gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.20.1', require: false
|
||||
gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.21.0', require: false
|
||||
gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.21.2', require: false
|
||||
gem 'opentelemetry-instrumentation-excon', '~> 0.22.0', require: false
|
||||
gem 'opentelemetry-instrumentation-faraday', '~> 0.24.1', require: false
|
||||
|
@ -114,7 +114,7 @@ group :opentelemetry do
|
|||
gem 'opentelemetry-instrumentation-net_http', '~> 0.22.4', require: false
|
||||
gem 'opentelemetry-instrumentation-pg', '~> 0.29.0', require: false
|
||||
gem 'opentelemetry-instrumentation-rack', '~> 0.25.0', require: false
|
||||
gem 'opentelemetry-instrumentation-rails', '~> 0.33.0', require: false
|
||||
gem 'opentelemetry-instrumentation-rails', '~> 0.34.0', require: false
|
||||
gem 'opentelemetry-instrumentation-redis', '~> 0.25.3', require: false
|
||||
gem 'opentelemetry-instrumentation-sidekiq', '~> 0.25.2', require: false
|
||||
gem 'opentelemetry-sdk', '~> 1.4', require: false
|
||||
|
@ -183,7 +183,7 @@ group :development do
|
|||
gem 'letter_opener_web', '~> 3.0'
|
||||
|
||||
# Security analysis CLI tools
|
||||
gem 'brakeman', '~> 6.0', require: false
|
||||
gem 'brakeman', '~> 7.0', require: false
|
||||
gem 'bundler-audit', '~> 0.9', require: false
|
||||
|
||||
# Linter CLI for HAML files
|
||||
|
@ -222,7 +222,7 @@ gem 'concurrent-ruby', require: false
|
|||
gem 'connection_pool', require: false
|
||||
gem 'xorcist', '~> 1.1'
|
||||
|
||||
gem 'net-http', '~> 0.5.0'
|
||||
gem 'net-http', '~> 0.6.0'
|
||||
gem 'rubyzip', '~> 2.3'
|
||||
|
||||
gem 'hcaptcha', '~> 7.1'
|
||||
|
|
284
Gemfile.lock
284
Gemfile.lock
|
@ -10,29 +10,29 @@ GIT
|
|||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (7.2.2)
|
||||
actionpack (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
actioncable (7.2.2.1)
|
||||
actionpack (= 7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (7.2.2)
|
||||
actionpack (= 7.2.2)
|
||||
activejob (= 7.2.2)
|
||||
activerecord (= 7.2.2)
|
||||
activestorage (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
actionmailbox (7.2.2.1)
|
||||
actionpack (= 7.2.2.1)
|
||||
activejob (= 7.2.2.1)
|
||||
activerecord (= 7.2.2.1)
|
||||
activestorage (= 7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
mail (>= 2.8.0)
|
||||
actionmailer (7.2.2)
|
||||
actionpack (= 7.2.2)
|
||||
actionview (= 7.2.2)
|
||||
activejob (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
actionmailer (7.2.2.1)
|
||||
actionpack (= 7.2.2.1)
|
||||
actionview (= 7.2.2.1)
|
||||
activejob (= 7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
mail (>= 2.8.0)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (7.2.2)
|
||||
actionview (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
actionpack (7.2.2.1)
|
||||
actionview (= 7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
nokogiri (>= 1.8.5)
|
||||
racc
|
||||
rack (>= 2.2.4, < 3.2)
|
||||
|
@ -41,40 +41,40 @@ GEM
|
|||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
useragent (~> 0.16)
|
||||
actiontext (7.2.2)
|
||||
actionpack (= 7.2.2)
|
||||
activerecord (= 7.2.2)
|
||||
activestorage (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
actiontext (7.2.2.1)
|
||||
actionpack (= 7.2.2.1)
|
||||
activerecord (= 7.2.2.1)
|
||||
activestorage (= 7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
actionview (7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
active_model_serializers (0.10.14)
|
||||
active_model_serializers (0.10.15)
|
||||
actionpack (>= 4.1)
|
||||
activemodel (>= 4.1)
|
||||
case_transform (>= 0.2)
|
||||
jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
|
||||
activejob (7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
activejob (7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
activerecord (7.2.2)
|
||||
activemodel (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
activemodel (7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
activerecord (7.2.2.1)
|
||||
activemodel (= 7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
timeout (>= 0.4.0)
|
||||
activestorage (7.2.2)
|
||||
actionpack (= 7.2.2)
|
||||
activejob (= 7.2.2)
|
||||
activerecord (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
activestorage (7.2.2.1)
|
||||
actionpack (= 7.2.2.1)
|
||||
activejob (= 7.2.2.1)
|
||||
activerecord (= 7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
marcel (~> 1.0)
|
||||
activesupport (7.2.2)
|
||||
activesupport (7.2.2.1)
|
||||
base64
|
||||
benchmark (>= 0.3)
|
||||
bigdecimal
|
||||
|
@ -94,8 +94,8 @@ GEM
|
|||
ast (2.4.2)
|
||||
attr_required (1.0.2)
|
||||
aws-eventstream (1.3.0)
|
||||
aws-partitions (1.1013.0)
|
||||
aws-sdk-core (3.214.0)
|
||||
aws-partitions (1.1029.0)
|
||||
aws-sdk-core (3.214.1)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
|
@ -103,13 +103,13 @@ GEM
|
|||
aws-sdk-kms (1.96.0)
|
||||
aws-sdk-core (~> 3, >= 3.210.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.174.0)
|
||||
aws-sdk-s3 (1.176.1)
|
||||
aws-sdk-core (~> 3, >= 3.210.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.10.1)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
azure-blob (0.5.3)
|
||||
azure-blob (0.5.4)
|
||||
rexml
|
||||
base64 (0.2.0)
|
||||
bcp47_spec (0.2.1)
|
||||
|
@ -119,16 +119,16 @@ GEM
|
|||
erubi (>= 1.0.0)
|
||||
rack (>= 0.9.0)
|
||||
rouge (>= 1.0.0)
|
||||
bigdecimal (3.1.8)
|
||||
bigdecimal (3.1.9)
|
||||
bindata (2.5.0)
|
||||
binding_of_caller (1.0.1)
|
||||
debug_inspector (>= 1.2.0)
|
||||
blurhash (0.1.8)
|
||||
bootsnap (1.18.4)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (6.2.2)
|
||||
brakeman (7.0.0)
|
||||
racc
|
||||
browser (6.1.0)
|
||||
browser (6.2.0)
|
||||
brpoplpush-redis_script (0.1.3)
|
||||
concurrent-ruby (~> 1.0, >= 1.0.5)
|
||||
redis (>= 1.0, < 6)
|
||||
|
@ -168,15 +168,15 @@ GEM
|
|||
bigdecimal
|
||||
rexml
|
||||
crass (1.0.6)
|
||||
css_parser (1.19.1)
|
||||
css_parser (1.21.0)
|
||||
addressable
|
||||
csv (3.3.0)
|
||||
csv (3.3.2)
|
||||
database_cleaner-active_record (2.2.0)
|
||||
activerecord (>= 5.a)
|
||||
database_cleaner-core (~> 2.0.0)
|
||||
database_cleaner-core (2.0.1)
|
||||
date (3.4.0)
|
||||
debug (1.9.2)
|
||||
date (3.4.1)
|
||||
debug (1.10.0)
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
debug_inspector (1.2.0)
|
||||
|
@ -199,9 +199,9 @@ GEM
|
|||
activerecord (>= 4.2, < 9.0)
|
||||
docile (1.4.1)
|
||||
domain_name (0.6.20240107)
|
||||
doorkeeper (5.8.0)
|
||||
doorkeeper (5.8.1)
|
||||
railties (>= 5)
|
||||
dotenv (3.1.4)
|
||||
dotenv (3.1.7)
|
||||
drb (2.2.1)
|
||||
elasticsearch (7.17.11)
|
||||
elasticsearch-api (= 7.17.11)
|
||||
|
@ -217,24 +217,24 @@ GEM
|
|||
htmlentities (~> 4.3.3)
|
||||
launchy (>= 2.1, < 4.0)
|
||||
mail (~> 2.7)
|
||||
erubi (1.13.0)
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.2.11)
|
||||
tzinfo
|
||||
excon (0.112.0)
|
||||
fabrication (2.31.0)
|
||||
faker (3.5.1)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.12.0)
|
||||
faraday-net_http (>= 2.0, < 3.4)
|
||||
faraday (2.12.2)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
faraday-httpclient (2.0.1)
|
||||
httpclient (>= 2.2)
|
||||
faraday-net_http (3.3.0)
|
||||
net-http
|
||||
faraday-net_http (3.4.0)
|
||||
net-http (>= 0.5.0)
|
||||
fast_blank (1.0.1)
|
||||
fastimage (2.3.1)
|
||||
ffi (1.17.0)
|
||||
ffi (1.17.1)
|
||||
ffi-compiler (1.3.2)
|
||||
ffi (>= 1.15.5)
|
||||
rake
|
||||
|
@ -279,7 +279,7 @@ GEM
|
|||
rainbow
|
||||
rubocop (>= 1.0)
|
||||
sysexits (~> 1.1)
|
||||
hashdiff (1.1.1)
|
||||
hashdiff (1.1.2)
|
||||
hashie (5.0.0)
|
||||
hcaptcha (7.1.0)
|
||||
json
|
||||
|
@ -294,7 +294,7 @@ GEM
|
|||
http-cookie (~> 1.0)
|
||||
http-form_data (~> 2.2)
|
||||
llhttp-ffi (~> 0.5.0)
|
||||
http-cookie (1.0.5)
|
||||
http-cookie (1.0.8)
|
||||
domain_name (~> 0.5)
|
||||
http-form_data (2.3.0)
|
||||
http_accept_language (2.1.1)
|
||||
|
@ -318,8 +318,8 @@ GEM
|
|||
inline_svg (1.10.0)
|
||||
activesupport (>= 3.0)
|
||||
nokogiri (>= 1.6)
|
||||
io-console (0.7.2)
|
||||
irb (1.14.1)
|
||||
io-console (0.8.0)
|
||||
irb (1.14.3)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jd-paperclip-azure (3.0.0)
|
||||
|
@ -327,7 +327,7 @@ GEM
|
|||
azure-blob (~> 0.5.2)
|
||||
hashie (~> 5.0)
|
||||
jmespath (1.6.2)
|
||||
json (2.8.1)
|
||||
json (2.9.1)
|
||||
json-canonicalization (1.0.0)
|
||||
json-jwt (1.15.3.1)
|
||||
activesupport (>= 4.2)
|
||||
|
@ -345,8 +345,9 @@ GEM
|
|||
json-ld-preloaded (3.3.1)
|
||||
json-ld (~> 3.3)
|
||||
rdf (~> 3.3)
|
||||
json-schema (5.1.0)
|
||||
json-schema (5.1.1)
|
||||
addressable (~> 2.8)
|
||||
bigdecimal (~> 3.1)
|
||||
jsonapi-renderer (0.2.2)
|
||||
jwt (2.9.3)
|
||||
base64
|
||||
|
@ -383,13 +384,13 @@ GEM
|
|||
llhttp-ffi (0.5.0)
|
||||
ffi-compiler (~> 1.0)
|
||||
rake (~> 13.0)
|
||||
logger (1.6.1)
|
||||
logger (1.6.4)
|
||||
lograge (0.14.0)
|
||||
actionpack (>= 4)
|
||||
activesupport (>= 4)
|
||||
railties (>= 4)
|
||||
request_store (~> 1.0)
|
||||
loofah (2.23.1)
|
||||
loofah (2.24.0)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
mail (2.8.1)
|
||||
|
@ -405,16 +406,16 @@ GEM
|
|||
mime-types (3.6.0)
|
||||
logger
|
||||
mime-types-data (~> 3.2015)
|
||||
mime-types-data (3.2024.1105)
|
||||
mime-types-data (3.2024.1203)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.7)
|
||||
minitest (5.25.1)
|
||||
mini_portile2 (2.8.8)
|
||||
minitest (5.25.4)
|
||||
msgpack (1.7.5)
|
||||
multi_json (1.15.0)
|
||||
mutex_m (0.3.0)
|
||||
net-http (0.5.0)
|
||||
net-http (0.6.0)
|
||||
uri
|
||||
net-imap (0.5.1)
|
||||
net-imap (0.5.4)
|
||||
date
|
||||
net-protocol
|
||||
net-ldap (0.19.0)
|
||||
|
@ -425,10 +426,10 @@ GEM
|
|||
net-smtp (0.5.0)
|
||||
net-protocol
|
||||
nio4r (2.7.4)
|
||||
nokogiri (1.16.7)
|
||||
nokogiri (1.18.1)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
oj (3.16.7)
|
||||
oj (3.16.9)
|
||||
bigdecimal (>= 3.0)
|
||||
ostruct (>= 0.2)
|
||||
omniauth (2.1.2)
|
||||
|
@ -459,13 +460,13 @@ GEM
|
|||
validate_email
|
||||
validate_url
|
||||
webfinger (~> 1.2)
|
||||
openssl (3.2.0)
|
||||
openssl (3.2.1)
|
||||
openssl-signature_algorithm (1.3.0)
|
||||
openssl (> 2.0)
|
||||
opentelemetry-api (1.4.0)
|
||||
opentelemetry-common (0.21.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-exporter-otlp (0.29.0)
|
||||
opentelemetry-exporter-otlp (0.29.1)
|
||||
google-protobuf (>= 3.18)
|
||||
googleapis-common-protos-types (~> 1.3)
|
||||
opentelemetry-api (~> 1.1)
|
||||
|
@ -474,28 +475,29 @@ GEM
|
|||
opentelemetry-semantic_conventions
|
||||
opentelemetry-helpers-sql-obfuscation (0.2.1)
|
||||
opentelemetry-common (~> 0.21)
|
||||
opentelemetry-instrumentation-action_mailer (0.2.0)
|
||||
opentelemetry-instrumentation-action_mailer (0.3.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-active_support (~> 0.1)
|
||||
opentelemetry-instrumentation-active_support (~> 0.7)
|
||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||
opentelemetry-instrumentation-action_pack (0.10.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||
opentelemetry-instrumentation-rack (~> 0.21)
|
||||
opentelemetry-instrumentation-action_view (0.7.3)
|
||||
opentelemetry-instrumentation-action_view (0.8.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-active_support (~> 0.6)
|
||||
opentelemetry-instrumentation-active_support (~> 0.7)
|
||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||
opentelemetry-instrumentation-active_job (0.7.8)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||
opentelemetry-instrumentation-active_model_serializers (0.20.2)
|
||||
opentelemetry-instrumentation-active_model_serializers (0.21.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-active_support (>= 0.7.0)
|
||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||
opentelemetry-instrumentation-active_record (0.8.1)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||
opentelemetry-instrumentation-active_support (0.6.0)
|
||||
opentelemetry-instrumentation-active_support (0.7.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||
opentelemetry-instrumentation-base (0.22.6)
|
||||
|
@ -508,7 +510,7 @@ GEM
|
|||
opentelemetry-instrumentation-excon (0.22.5)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||
opentelemetry-instrumentation-faraday (0.24.7)
|
||||
opentelemetry-instrumentation-faraday (0.24.8)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||
opentelemetry-instrumentation-http (0.23.5)
|
||||
|
@ -527,14 +529,14 @@ GEM
|
|||
opentelemetry-instrumentation-rack (0.25.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||
opentelemetry-instrumentation-rails (0.33.1)
|
||||
opentelemetry-instrumentation-rails (0.34.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-action_mailer (~> 0.2.0)
|
||||
opentelemetry-instrumentation-action_mailer (~> 0.3.0)
|
||||
opentelemetry-instrumentation-action_pack (~> 0.10.0)
|
||||
opentelemetry-instrumentation-action_view (~> 0.7.0)
|
||||
opentelemetry-instrumentation-action_view (~> 0.8.0)
|
||||
opentelemetry-instrumentation-active_job (~> 0.7.0)
|
||||
opentelemetry-instrumentation-active_record (~> 0.8.0)
|
||||
opentelemetry-instrumentation-active_support (~> 0.6.0)
|
||||
opentelemetry-instrumentation-active_support (~> 0.7.0)
|
||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||
opentelemetry-instrumentation-redis (0.25.7)
|
||||
opentelemetry-api (~> 1.0)
|
||||
|
@ -544,7 +546,7 @@ GEM
|
|||
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||
opentelemetry-registry (0.3.1)
|
||||
opentelemetry-api (~> 1.1)
|
||||
opentelemetry-sdk (1.5.0)
|
||||
opentelemetry-sdk (1.6.0)
|
||||
opentelemetry-api (~> 1.1)
|
||||
opentelemetry-common (~> 0.20)
|
||||
opentelemetry-registry (~> 0.2)
|
||||
|
@ -553,7 +555,8 @@ GEM
|
|||
opentelemetry-api (~> 1.0)
|
||||
orm_adapter (0.5.0)
|
||||
ostruct (0.6.1)
|
||||
ox (2.14.18)
|
||||
ox (2.14.19)
|
||||
bigdecimal (>= 3.0)
|
||||
parallel (1.26.3)
|
||||
parser (3.3.6.0)
|
||||
ast (~> 2.4.1)
|
||||
|
@ -577,7 +580,8 @@ GEM
|
|||
activesupport (>= 7.0.0)
|
||||
rack
|
||||
railties (>= 7.0.0)
|
||||
psych (5.2.0)
|
||||
psych (5.2.2)
|
||||
date
|
||||
stringio
|
||||
public_suffix (6.0.1)
|
||||
puma (6.5.0)
|
||||
|
@ -604,25 +608,25 @@ GEM
|
|||
rack
|
||||
rack-session (1.0.2)
|
||||
rack (< 3)
|
||||
rack-test (2.1.0)
|
||||
rack-test (2.2.0)
|
||||
rack (>= 1.3)
|
||||
rackup (1.0.0)
|
||||
rackup (1.0.1)
|
||||
rack (< 3)
|
||||
webrick
|
||||
rails (7.2.2)
|
||||
actioncable (= 7.2.2)
|
||||
actionmailbox (= 7.2.2)
|
||||
actionmailer (= 7.2.2)
|
||||
actionpack (= 7.2.2)
|
||||
actiontext (= 7.2.2)
|
||||
actionview (= 7.2.2)
|
||||
activejob (= 7.2.2)
|
||||
activemodel (= 7.2.2)
|
||||
activerecord (= 7.2.2)
|
||||
activestorage (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
rails (7.2.2.1)
|
||||
actioncable (= 7.2.2.1)
|
||||
actionmailbox (= 7.2.2.1)
|
||||
actionmailer (= 7.2.2.1)
|
||||
actionpack (= 7.2.2.1)
|
||||
actiontext (= 7.2.2.1)
|
||||
actionview (= 7.2.2.1)
|
||||
activejob (= 7.2.2.1)
|
||||
activemodel (= 7.2.2.1)
|
||||
activerecord (= 7.2.2.1)
|
||||
activestorage (= 7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 7.2.2)
|
||||
railties (= 7.2.2.1)
|
||||
rails-controller-testing (1.0.5)
|
||||
actionpack (>= 5.0.1.rc1)
|
||||
actionview (>= 5.0.1.rc1)
|
||||
|
@ -631,15 +635,15 @@ GEM
|
|||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
nokogiri (>= 1.6)
|
||||
rails-html-sanitizer (1.6.0)
|
||||
rails-html-sanitizer (1.6.2)
|
||||
loofah (~> 2.21)
|
||||
nokogiri (~> 1.14)
|
||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||
rails-i18n (7.0.10)
|
||||
i18n (>= 0.7, < 2)
|
||||
railties (>= 6.0.0, < 8)
|
||||
railties (7.2.2)
|
||||
actionpack (= 7.2.2)
|
||||
activesupport (= 7.2.2)
|
||||
railties (7.2.2.1)
|
||||
actionpack (= 7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
|
@ -653,7 +657,7 @@ GEM
|
|||
link_header (~> 0.0, >= 0.0.8)
|
||||
rdf-normalize (0.7.0)
|
||||
rdf (~> 3.3)
|
||||
rdoc (6.7.0)
|
||||
rdoc (6.10.0)
|
||||
psych (>= 4.0.0)
|
||||
redcarpet (3.6.0)
|
||||
redis (4.8.1)
|
||||
|
@ -661,15 +665,15 @@ GEM
|
|||
redis (>= 4)
|
||||
redlock (1.3.2)
|
||||
redis (>= 3.0.0, < 6.0)
|
||||
regexp_parser (2.9.2)
|
||||
reline (0.5.11)
|
||||
regexp_parser (2.10.0)
|
||||
reline (0.6.0)
|
||||
io-console (~> 0.5)
|
||||
request_store (1.6.0)
|
||||
request_store (1.7.0)
|
||||
rack (>= 1.4)
|
||||
responders (3.1.1)
|
||||
actionpack (>= 5.2)
|
||||
railties (>= 5.2)
|
||||
rexml (3.3.9)
|
||||
rexml (3.4.0)
|
||||
rotp (6.3.0)
|
||||
rouge (4.5.1)
|
||||
rpam2 (4.0.2)
|
||||
|
@ -704,30 +708,30 @@ GEM
|
|||
rspec-expectations (~> 3.0)
|
||||
rspec-mocks (~> 3.0)
|
||||
sidekiq (>= 5, < 8)
|
||||
rspec-support (3.13.1)
|
||||
rubocop (1.66.1)
|
||||
rspec-support (3.13.2)
|
||||
rubocop (1.69.2)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (>= 3.17.0)
|
||||
parallel (~> 1.10)
|
||||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.4, < 3.0)
|
||||
rubocop-ast (>= 1.32.2, < 2.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.36.2, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 3.0)
|
||||
rubocop-ast (1.32.3)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.37.0)
|
||||
parser (>= 3.3.1.0)
|
||||
rubocop-capybara (2.21.0)
|
||||
rubocop (~> 1.41)
|
||||
rubocop-performance (1.22.1)
|
||||
rubocop-performance (1.23.0)
|
||||
rubocop (>= 1.48.1, < 2.0)
|
||||
rubocop-ast (>= 1.31.1, < 2.0)
|
||||
rubocop-rails (2.27.0)
|
||||
rubocop-rails (2.28.0)
|
||||
activesupport (>= 4.2.0)
|
||||
rack (>= 1.1)
|
||||
rubocop (>= 1.52.0, < 2.0)
|
||||
rubocop-ast (>= 1.31.1, < 2.0)
|
||||
rubocop-rspec (3.2.0)
|
||||
rubocop-rspec (3.3.0)
|
||||
rubocop (~> 1.61)
|
||||
rubocop-rspec_rails (2.30.0)
|
||||
rubocop (~> 1.61)
|
||||
|
@ -741,24 +745,24 @@ GEM
|
|||
ffi (~> 1.12)
|
||||
logger
|
||||
rubyzip (2.3.2)
|
||||
rufus-scheduler (3.9.1)
|
||||
fugit (~> 1.1, >= 1.1.6)
|
||||
rufus-scheduler (3.9.2)
|
||||
fugit (~> 1.1, >= 1.11.1)
|
||||
safety_net_attestation (0.4.0)
|
||||
jwt (~> 2.0)
|
||||
sanitize (6.1.3)
|
||||
sanitize (7.0.0)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
nokogiri (>= 1.16.8)
|
||||
scenic (1.8.0)
|
||||
activerecord (>= 4.0.0)
|
||||
railties (>= 4.0.0)
|
||||
securerandom (0.3.2)
|
||||
securerandom (0.4.1)
|
||||
selenium-webdriver (4.27.0)
|
||||
base64 (~> 0.2)
|
||||
logger (~> 1.4)
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
rubyzip (>= 1.2.2, < 3.0)
|
||||
websocket (~> 1.0)
|
||||
semantic_range (3.0.0)
|
||||
semantic_range (3.1.0)
|
||||
shoulda-matchers (6.4.0)
|
||||
activesupport (>= 5.2.0)
|
||||
sidekiq (6.5.12)
|
||||
|
@ -805,10 +809,10 @@ GEM
|
|||
unicode-display_width (>= 1.1.1, < 3)
|
||||
terrapin (1.0.1)
|
||||
climate_control
|
||||
test-prof (1.4.2)
|
||||
test-prof (1.4.3)
|
||||
thor (1.3.2)
|
||||
tilt (2.4.0)
|
||||
timeout (0.4.2)
|
||||
tilt (2.5.0)
|
||||
timeout (0.4.3)
|
||||
tpm-key_attestation (0.12.1)
|
||||
bindata (~> 2.4)
|
||||
openssl (> 2.0)
|
||||
|
@ -834,8 +838,8 @@ GEM
|
|||
unf_ext
|
||||
unf_ext (0.0.9.1)
|
||||
unicode-display_width (2.6.0)
|
||||
uri (0.13.1)
|
||||
useragent (0.16.10)
|
||||
uri (1.0.2)
|
||||
useragent (0.16.11)
|
||||
validate_email (0.1.6)
|
||||
activemodel (>= 3.0)
|
||||
mail (>= 2.2.5)
|
||||
|
@ -864,7 +868,7 @@ GEM
|
|||
rack-proxy (>= 0.6.1)
|
||||
railties (>= 5.2)
|
||||
semantic_range (>= 2.3.0)
|
||||
webrick (1.9.0)
|
||||
webrick (1.9.1)
|
||||
websocket (1.2.11)
|
||||
websocket-driver (0.7.6)
|
||||
websocket-extensions (>= 0.1.0)
|
||||
|
@ -887,7 +891,7 @@ DEPENDENCIES
|
|||
binding_of_caller (~> 1.0)
|
||||
blurhash (~> 0.1)
|
||||
bootsnap (~> 1.18.0)
|
||||
brakeman (~> 6.0)
|
||||
brakeman (~> 7.0)
|
||||
browser
|
||||
bundler-audit (~> 0.9)
|
||||
capybara (~> 3.39)
|
||||
|
@ -944,7 +948,7 @@ DEPENDENCIES
|
|||
memory_profiler
|
||||
mime-types (~> 3.6.0)
|
||||
mutex_m
|
||||
net-http (~> 0.5.0)
|
||||
net-http (~> 0.6.0)
|
||||
net-ldap (~> 0.18)
|
||||
nokogiri (~> 1.15)
|
||||
oj (~> 3.14)
|
||||
|
@ -956,7 +960,7 @@ DEPENDENCIES
|
|||
opentelemetry-api (~> 1.4.0)
|
||||
opentelemetry-exporter-otlp (~> 0.29.0)
|
||||
opentelemetry-instrumentation-active_job (~> 0.7.1)
|
||||
opentelemetry-instrumentation-active_model_serializers (~> 0.20.1)
|
||||
opentelemetry-instrumentation-active_model_serializers (~> 0.21.0)
|
||||
opentelemetry-instrumentation-concurrent_ruby (~> 0.21.2)
|
||||
opentelemetry-instrumentation-excon (~> 0.22.0)
|
||||
opentelemetry-instrumentation-faraday (~> 0.24.1)
|
||||
|
@ -965,7 +969,7 @@ DEPENDENCIES
|
|||
opentelemetry-instrumentation-net_http (~> 0.22.4)
|
||||
opentelemetry-instrumentation-pg (~> 0.29.0)
|
||||
opentelemetry-instrumentation-rack (~> 0.25.0)
|
||||
opentelemetry-instrumentation-rails (~> 0.33.0)
|
||||
opentelemetry-instrumentation-rails (~> 0.34.0)
|
||||
opentelemetry-instrumentation-redis (~> 0.25.3)
|
||||
opentelemetry-instrumentation-sidekiq (~> 0.25.2)
|
||||
opentelemetry-sdk (~> 1.4)
|
||||
|
@ -1003,7 +1007,7 @@ DEPENDENCIES
|
|||
ruby-progressbar (~> 1.13)
|
||||
ruby-vips (~> 2.2)
|
||||
rubyzip (~> 2.3)
|
||||
sanitize (~> 6.0)
|
||||
sanitize (~> 7.0)
|
||||
scenic (~> 1.7)
|
||||
selenium-webdriver
|
||||
shoulda-matchers
|
||||
|
@ -1033,4 +1037,4 @@ RUBY VERSION
|
|||
ruby 3.3.6p108
|
||||
|
||||
BUNDLED WITH
|
||||
2.5.23
|
||||
2.6.2
|
||||
|
|
2
Vagrantfile
vendored
2
Vagrantfile
vendored
|
@ -174,7 +174,7 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
|
|||
if config.vm.networks.any? { |type, options| type == :private_network }
|
||||
config.vm.synced_folder ".", "/vagrant", type: "nfs", mount_options: ['rw', 'actimeo=1']
|
||||
else
|
||||
config.vm.synced_folder ".", "/vagrant"
|
||||
config.vm.synced_folder ".", "/vagrant", type: "rsync", create: true, rsync__args: ["--verbose", "--archive", "--delete", "-z"]
|
||||
end
|
||||
|
||||
# Otherwise, you can access the site at http://localhost:3000 and http://localhost:4000 , http://localhost:8080
|
||||
|
|
|
@ -8,6 +8,7 @@ module Admin
|
|||
layout 'admin'
|
||||
|
||||
before_action :set_cache_headers
|
||||
before_action :set_referrer_policy_header
|
||||
|
||||
after_action :verify_authorized
|
||||
|
||||
|
@ -17,6 +18,10 @@ module Admin
|
|||
response.cache_control.replace(private: true, no_store: true)
|
||||
end
|
||||
|
||||
def set_referrer_policy_header
|
||||
response.headers['Referrer-Policy'] = 'same-origin'
|
||||
end
|
||||
|
||||
def set_user
|
||||
@user = Account.find(params[:account_id]).user || raise(ActiveRecord::RecordNotFound)
|
||||
end
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Admin::TermsOfService::DistributionsController < Admin::BaseController
|
||||
before_action :set_terms_of_service
|
||||
|
||||
def create
|
||||
authorize @terms_of_service, :distribute?
|
||||
@terms_of_service.touch(:notification_sent_at)
|
||||
Admin::DistributeTermsOfServiceNotificationWorker.perform_async(@terms_of_service.id)
|
||||
redirect_to admin_terms_of_service_index_path
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_terms_of_service
|
||||
@terms_of_service = TermsOfService.find(params[:terms_of_service_id])
|
||||
end
|
||||
end
|
36
app/controllers/admin/terms_of_service/drafts_controller.rb
Normal file
36
app/controllers/admin/terms_of_service/drafts_controller.rb
Normal file
|
@ -0,0 +1,36 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Admin::TermsOfService::DraftsController < Admin::BaseController
|
||||
before_action :set_terms_of_service
|
||||
|
||||
def show
|
||||
authorize :terms_of_service, :create?
|
||||
end
|
||||
|
||||
def update
|
||||
authorize @terms_of_service, :update?
|
||||
|
||||
@terms_of_service.published_at = Time.now.utc if params[:action_type] == 'publish'
|
||||
|
||||
if @terms_of_service.update(resource_params)
|
||||
log_action(:publish, @terms_of_service) if @terms_of_service.published?
|
||||
redirect_to @terms_of_service.published? ? admin_terms_of_service_index_path : admin_terms_of_service_draft_path
|
||||
else
|
||||
render :show
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_terms_of_service
|
||||
@terms_of_service = TermsOfService.draft.first || TermsOfService.new(text: current_terms_of_service&.text)
|
||||
end
|
||||
|
||||
def current_terms_of_service
|
||||
TermsOfService.live.first
|
||||
end
|
||||
|
||||
def resource_params
|
||||
params.require(:terms_of_service).permit(:text, :changelog)
|
||||
end
|
||||
end
|
|
@ -0,0 +1,37 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Admin::TermsOfService::GeneratesController < Admin::BaseController
|
||||
before_action :set_instance_presenter
|
||||
|
||||
def show
|
||||
authorize :terms_of_service, :create?
|
||||
|
||||
@generator = TermsOfService::Generator.new(
|
||||
domain: @instance_presenter.domain,
|
||||
admin_email: @instance_presenter.contact.email
|
||||
)
|
||||
end
|
||||
|
||||
def create
|
||||
authorize :terms_of_service, :create?
|
||||
|
||||
@generator = TermsOfService::Generator.new(resource_params)
|
||||
|
||||
if @generator.valid?
|
||||
TermsOfService.create!(text: @generator.render)
|
||||
redirect_to admin_terms_of_service_draft_path
|
||||
else
|
||||
render :show
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_instance_presenter
|
||||
@instance_presenter = InstancePresenter.new
|
||||
end
|
||||
|
||||
def resource_params
|
||||
params.require(:terms_of_service_generator).permit(*TermsOfService::Generator::VARIABLES)
|
||||
end
|
||||
end
|
|
@ -0,0 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Admin::TermsOfService::HistoriesController < Admin::BaseController
|
||||
def show
|
||||
authorize :terms_of_service, :index?
|
||||
@terms_of_service = TermsOfService.published.all
|
||||
end
|
||||
end
|
|
@ -0,0 +1,16 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Admin::TermsOfService::PreviewsController < Admin::BaseController
|
||||
before_action :set_terms_of_service
|
||||
|
||||
def show
|
||||
authorize @terms_of_service, :distribute?
|
||||
@user_count = @terms_of_service.scope_for_notification.count
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_terms_of_service
|
||||
@terms_of_service = TermsOfService.find(params[:terms_of_service_id])
|
||||
end
|
||||
end
|
17
app/controllers/admin/terms_of_service/tests_controller.rb
Normal file
17
app/controllers/admin/terms_of_service/tests_controller.rb
Normal file
|
@ -0,0 +1,17 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Admin::TermsOfService::TestsController < Admin::BaseController
|
||||
before_action :set_terms_of_service
|
||||
|
||||
def create
|
||||
authorize @terms_of_service, :distribute?
|
||||
UserMailer.terms_of_service_changed(current_user, @terms_of_service).deliver_later!
|
||||
redirect_to admin_terms_of_service_preview_path(@terms_of_service)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_terms_of_service
|
||||
@terms_of_service = TermsOfService.find(params[:terms_of_service_id])
|
||||
end
|
||||
end
|
8
app/controllers/admin/terms_of_service_controller.rb
Normal file
8
app/controllers/admin/terms_of_service_controller.rb
Normal file
|
@ -0,0 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Admin::TermsOfServiceController < Admin::BaseController
|
||||
def index
|
||||
authorize :terms_of_service, :index?
|
||||
@terms_of_service = TermsOfService.live.first
|
||||
end
|
||||
end
|
|
@ -0,0 +1,16 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Instances::TermsOfServicesController < Api::V1::Instances::BaseController
|
||||
before_action :set_terms_of_service
|
||||
|
||||
def show
|
||||
cache_even_if_authenticated!
|
||||
render json: @terms_of_service, serializer: REST::PrivacyPolicySerializer
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_terms_of_service
|
||||
@terms_of_service = TermsOfService.live.first!
|
||||
end
|
||||
end
|
|
@ -15,7 +15,7 @@ class Api::V1::Polls::VotesController < Api::BaseController
|
|||
private
|
||||
|
||||
def set_poll
|
||||
@poll = Poll.attached.find(params[:poll_id])
|
||||
@poll = Poll.find(params[:poll_id])
|
||||
authorize @poll.status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
not_found
|
||||
|
|
|
@ -15,7 +15,7 @@ class Api::V1::PollsController < Api::BaseController
|
|||
private
|
||||
|
||||
def set_poll
|
||||
@poll = Poll.attached.find(params[:id])
|
||||
@poll = Poll.find(params[:id])
|
||||
authorize @poll.status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
not_found
|
||||
|
|
|
@ -27,7 +27,9 @@ class Api::V1::Trends::TagsController < Api::BaseController
|
|||
end
|
||||
|
||||
def tags_from_trends
|
||||
Trends.tags.query.allowed
|
||||
scope = Trends.tags.query.allowed.in_locale(content_locale)
|
||||
scope = scope.filtered_for(current_account) if user_signed_in?
|
||||
scope
|
||||
end
|
||||
|
||||
def next_path
|
||||
|
|
|
@ -73,7 +73,13 @@ class ApplicationController < ActionController::Base
|
|||
end
|
||||
|
||||
def require_functional!
|
||||
redirect_to edit_user_registration_path unless current_user.functional?
|
||||
return if current_user.functional?
|
||||
|
||||
if current_user.confirmed?
|
||||
redirect_to edit_user_registration_path
|
||||
else
|
||||
redirect_to auth_setup_path
|
||||
end
|
||||
end
|
||||
|
||||
def skip_csrf_meta_tags?
|
||||
|
|
|
@ -142,4 +142,12 @@ class Auth::RegistrationsController < Devise::RegistrationsController
|
|||
def set_cache_headers
|
||||
response.cache_control.replace(private: true, no_store: true)
|
||||
end
|
||||
|
||||
def is_flashing_format? # rubocop:disable Naming/PredicateName
|
||||
if params[:action] == 'create'
|
||||
false # Disable flash messages for sign-up
|
||||
else
|
||||
super
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -7,6 +7,7 @@ module WebAppControllerConcern
|
|||
vary_by 'Accept, Accept-Language, Cookie'
|
||||
|
||||
before_action :redirect_unauthenticated_to_permalinks!
|
||||
before_action :set_referer_header
|
||||
|
||||
content_security_policy do |p|
|
||||
policy = ContentSecurityPolicy.new
|
||||
|
@ -41,4 +42,10 @@ module WebAppControllerConcern
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def set_referer_header
|
||||
response.set_header('Referrer-Policy', Setting.allow_referrer_origin ? 'origin' : 'same-origin')
|
||||
end
|
||||
end
|
||||
|
|
11
app/controllers/terms_of_service_controller.rb
Normal file
11
app/controllers/terms_of_service_controller.rb
Normal file
|
@ -0,0 +1,11 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class TermsOfServiceController < ApplicationController
|
||||
include WebAppControllerConcern
|
||||
|
||||
skip_before_action :require_functional!
|
||||
|
||||
def show
|
||||
expires_in(15.seconds, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.day) unless user_signed_in?
|
||||
end
|
||||
end
|
|
@ -148,6 +148,7 @@ module ApplicationHelper
|
|||
output << "flavour-#{current_flavour.parameterize}"
|
||||
output << "skin-#{current_skin.parameterize}"
|
||||
output << 'system-font' if current_account&.user&.setting_system_font_ui
|
||||
output << 'custom-scrollbars' unless current_account&.user&.setting_system_scrollbars_ui
|
||||
output << (current_account&.user&.setting_reduce_motion ? 'reduce-motion' : 'no-reduce-motion')
|
||||
output << 'rtl' if locale_direction == 'rtl'
|
||||
output.compact_blank.join(' ')
|
||||
|
|
|
@ -64,6 +64,10 @@ module FormattingHelper
|
|||
end
|
||||
end
|
||||
|
||||
def markdown(text)
|
||||
Redcarpet::Markdown.new(Redcarpet::Render::HTML, escape_html: true, no_images: true).render(text).html_safe # rubocop:disable Rails/OutputSafety
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def wrapped_status_content_format(status)
|
||||
|
|
|
@ -60,6 +60,10 @@ window.addEventListener('message', (e) => {
|
|||
|
||||
const data = e.data;
|
||||
|
||||
// Only set overflow to `hidden` once we got the expected `message` so the post can still be scrolled if
|
||||
// embedded without parent Javascript support
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// We use a timeout to allow for the React page to render before calculating the height
|
||||
afterInitialRender(() => {
|
||||
window.parent.postMessage(
|
||||
|
|
|
@ -1,66 +0,0 @@
|
|||
import { defineMessages } from 'react-intl';
|
||||
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
const messages = defineMessages({
|
||||
unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' },
|
||||
unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' },
|
||||
rateLimitedTitle: { id: 'alert.rate_limited.title', defaultMessage: 'Rate limited' },
|
||||
rateLimitedMessage: { id: 'alert.rate_limited.message', defaultMessage: 'Please retry after {retry_time, time, medium}.' },
|
||||
});
|
||||
|
||||
export const ALERT_SHOW = 'ALERT_SHOW';
|
||||
export const ALERT_DISMISS = 'ALERT_DISMISS';
|
||||
export const ALERT_CLEAR = 'ALERT_CLEAR';
|
||||
export const ALERT_NOOP = 'ALERT_NOOP';
|
||||
|
||||
export const dismissAlert = alert => ({
|
||||
type: ALERT_DISMISS,
|
||||
alert,
|
||||
});
|
||||
|
||||
export const clearAlert = () => ({
|
||||
type: ALERT_CLEAR,
|
||||
});
|
||||
|
||||
export const showAlert = alert => ({
|
||||
type: ALERT_SHOW,
|
||||
alert,
|
||||
});
|
||||
|
||||
export const showAlertForError = (error, skipNotFound = false) => {
|
||||
if (error.response) {
|
||||
const { data, status, statusText, headers } = error.response;
|
||||
|
||||
// Skip these errors as they are reflected in the UI
|
||||
if (skipNotFound && (status === 404 || status === 410)) {
|
||||
return { type: ALERT_NOOP };
|
||||
}
|
||||
|
||||
// Rate limit errors
|
||||
if (status === 429 && headers['x-ratelimit-reset']) {
|
||||
return showAlert({
|
||||
title: messages.rateLimitedTitle,
|
||||
message: messages.rateLimitedMessage,
|
||||
values: { 'retry_time': new Date(headers['x-ratelimit-reset']) },
|
||||
});
|
||||
}
|
||||
|
||||
return showAlert({
|
||||
title: `${status}`,
|
||||
message: data.error || statusText,
|
||||
});
|
||||
}
|
||||
|
||||
// An aborted request, e.g. due to reloading the browser window, it not really error
|
||||
if (error.code === AxiosError.ECONNABORTED) {
|
||||
return { type: ALERT_NOOP };
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
|
||||
return showAlert({
|
||||
title: messages.unexpectedTitle,
|
||||
message: messages.unexpectedMessage,
|
||||
});
|
||||
};
|
90
app/javascript/flavours/glitch/actions/alerts.ts
Normal file
90
app/javascript/flavours/glitch/actions/alerts.ts
Normal file
|
@ -0,0 +1,90 @@
|
|||
import { defineMessages } from 'react-intl';
|
||||
import type { MessageDescriptor } from 'react-intl';
|
||||
|
||||
import { AxiosError } from 'axios';
|
||||
import type { AxiosResponse } from 'axios';
|
||||
|
||||
interface Alert {
|
||||
title: string | MessageDescriptor;
|
||||
message: string | MessageDescriptor;
|
||||
values?: Record<string, string | number | Date>;
|
||||
}
|
||||
|
||||
interface ApiErrorResponse {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' },
|
||||
unexpectedMessage: {
|
||||
id: 'alert.unexpected.message',
|
||||
defaultMessage: 'An unexpected error occurred.',
|
||||
},
|
||||
rateLimitedTitle: {
|
||||
id: 'alert.rate_limited.title',
|
||||
defaultMessage: 'Rate limited',
|
||||
},
|
||||
rateLimitedMessage: {
|
||||
id: 'alert.rate_limited.message',
|
||||
defaultMessage: 'Please retry after {retry_time, time, medium}.',
|
||||
},
|
||||
});
|
||||
|
||||
export const ALERT_SHOW = 'ALERT_SHOW';
|
||||
export const ALERT_DISMISS = 'ALERT_DISMISS';
|
||||
export const ALERT_CLEAR = 'ALERT_CLEAR';
|
||||
export const ALERT_NOOP = 'ALERT_NOOP';
|
||||
|
||||
export const dismissAlert = (alert: Alert) => ({
|
||||
type: ALERT_DISMISS,
|
||||
alert,
|
||||
});
|
||||
|
||||
export const clearAlert = () => ({
|
||||
type: ALERT_CLEAR,
|
||||
});
|
||||
|
||||
export const showAlert = (alert: Alert) => ({
|
||||
type: ALERT_SHOW,
|
||||
alert,
|
||||
});
|
||||
|
||||
export const showAlertForError = (error: unknown, skipNotFound = false) => {
|
||||
if (error instanceof AxiosError && error.response) {
|
||||
const { status, statusText, headers } = error.response;
|
||||
const { data } = error.response as AxiosResponse<ApiErrorResponse>;
|
||||
|
||||
// Skip these errors as they are reflected in the UI
|
||||
if (skipNotFound && (status === 404 || status === 410)) {
|
||||
return { type: ALERT_NOOP };
|
||||
}
|
||||
|
||||
// Rate limit errors
|
||||
if (status === 429 && headers['x-ratelimit-reset']) {
|
||||
return showAlert({
|
||||
title: messages.rateLimitedTitle,
|
||||
message: messages.rateLimitedMessage,
|
||||
values: {
|
||||
retry_time: new Date(headers['x-ratelimit-reset'] as string),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return showAlert({
|
||||
title: `${status}`,
|
||||
message: data.error ?? statusText,
|
||||
});
|
||||
}
|
||||
|
||||
// An aborted request, e.g. due to reloading the browser window, it not really error
|
||||
if (error instanceof AxiosError && error.code === AxiosError.ECONNABORTED) {
|
||||
return { type: ALERT_NOOP };
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
|
||||
return showAlert({
|
||||
title: messages.unexpectedTitle,
|
||||
message: messages.unexpectedMessage,
|
||||
});
|
||||
};
|
|
@ -1,10 +1,12 @@
|
|||
import { createPollFromServerJSON } from 'flavours/glitch/models/poll';
|
||||
|
||||
import { importAccounts } from '../accounts_typed';
|
||||
|
||||
import { normalizeStatus, normalizePoll } from './normalizer';
|
||||
import { normalizeStatus } from './normalizer';
|
||||
import { importPolls } from './polls';
|
||||
|
||||
export const STATUS_IMPORT = 'STATUS_IMPORT';
|
||||
export const STATUSES_IMPORT = 'STATUSES_IMPORT';
|
||||
export const POLLS_IMPORT = 'POLLS_IMPORT';
|
||||
export const FILTERS_IMPORT = 'FILTERS_IMPORT';
|
||||
|
||||
function pushUnique(array, object) {
|
||||
|
@ -25,10 +27,6 @@ export function importFilters(filters) {
|
|||
return { type: FILTERS_IMPORT, filters };
|
||||
}
|
||||
|
||||
export function importPolls(polls) {
|
||||
return { type: POLLS_IMPORT, polls };
|
||||
}
|
||||
|
||||
export function importFetchedAccount(account) {
|
||||
return importFetchedAccounts([account]);
|
||||
}
|
||||
|
@ -73,7 +71,7 @@ export function importFetchedStatuses(statuses) {
|
|||
}
|
||||
|
||||
if (status.poll?.id) {
|
||||
pushUnique(polls, normalizePoll(status.poll, getState().getIn(['polls', status.poll.id])));
|
||||
pushUnique(polls, createPollFromServerJSON(status.poll, getState().polls.get(status.poll.id)));
|
||||
}
|
||||
|
||||
if (status.card) {
|
||||
|
@ -83,15 +81,9 @@ export function importFetchedStatuses(statuses) {
|
|||
|
||||
statuses.forEach(processStatus);
|
||||
|
||||
dispatch(importPolls(polls));
|
||||
dispatch(importPolls({ polls }));
|
||||
dispatch(importFetchedAccounts(accounts));
|
||||
dispatch(importStatuses(normalStatuses));
|
||||
dispatch(importFilters(filters));
|
||||
};
|
||||
}
|
||||
|
||||
export function importFetchedPoll(poll) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(importPolls([normalizePoll(poll, getState().getIn(['polls', poll.id]))]));
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,15 +1,12 @@
|
|||
import escapeTextContentForBrowser from 'escape-html';
|
||||
|
||||
import { makeEmojiMap } from 'flavours/glitch/models/custom_emoji';
|
||||
|
||||
import emojify from '../../features/emoji/emoji';
|
||||
import { autoHideCW } from '../../utils/content_warning';
|
||||
|
||||
const domParser = new DOMParser();
|
||||
|
||||
const makeEmojiMap = emojis => emojis.reduce((obj, emoji) => {
|
||||
obj[`:${emoji.shortcode}:`] = emoji;
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
export function searchTextFromRawStatus (status) {
|
||||
const spoilerText = status.spoiler_text || '';
|
||||
const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
|
||||
|
@ -104,38 +101,6 @@ export function normalizeStatusTranslation(translation, status) {
|
|||
return normalTranslation;
|
||||
}
|
||||
|
||||
export function normalizePoll(poll, normalOldPoll) {
|
||||
const normalPoll = { ...poll };
|
||||
const emojiMap = makeEmojiMap(poll.emojis);
|
||||
|
||||
normalPoll.options = poll.options.map((option, index) => {
|
||||
const normalOption = {
|
||||
...option,
|
||||
voted: poll.own_votes && poll.own_votes.includes(index),
|
||||
titleHtml: emojify(escapeTextContentForBrowser(option.title), emojiMap),
|
||||
};
|
||||
|
||||
if (normalOldPoll && normalOldPoll.getIn(['options', index, 'title']) === option.title) {
|
||||
normalOption.translation = normalOldPoll.getIn(['options', index, 'translation']);
|
||||
}
|
||||
|
||||
return normalOption;
|
||||
});
|
||||
|
||||
return normalPoll;
|
||||
}
|
||||
|
||||
export function normalizePollOptionTranslation(translation, poll) {
|
||||
const emojiMap = makeEmojiMap(poll.get('emojis').toJS());
|
||||
|
||||
const normalTranslation = {
|
||||
...translation,
|
||||
titleHtml: emojify(escapeTextContentForBrowser(translation.title), emojiMap),
|
||||
};
|
||||
|
||||
return normalTranslation;
|
||||
}
|
||||
|
||||
export function normalizeAnnouncement(announcement) {
|
||||
const normalAnnouncement = { ...announcement };
|
||||
const emojiMap = makeEmojiMap(normalAnnouncement.emojis);
|
||||
|
|
7
app/javascript/flavours/glitch/actions/importer/polls.ts
Normal file
7
app/javascript/flavours/glitch/actions/importer/polls.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { createAction } from '@reduxjs/toolkit';
|
||||
|
||||
import type { Poll } from 'flavours/glitch/models/poll';
|
||||
|
||||
export const importPolls = createAction<{ polls: Poll[] }>(
|
||||
'poll/importMultiple',
|
||||
);
|
|
@ -155,7 +155,7 @@ export const processNewNotificationForGroups = createAppAsyncThunk(
|
|||
|
||||
const showInColumn =
|
||||
activeFilter === 'all'
|
||||
? notificationShows[notification.type]
|
||||
? notificationShows[notification.type] !== false
|
||||
: activeFilter === notification.type;
|
||||
|
||||
if (!showInColumn) return;
|
||||
|
|
|
@ -1,61 +0,0 @@
|
|||
import api from '../api';
|
||||
|
||||
import { importFetchedPoll } from './importer';
|
||||
|
||||
export const POLL_VOTE_REQUEST = 'POLL_VOTE_REQUEST';
|
||||
export const POLL_VOTE_SUCCESS = 'POLL_VOTE_SUCCESS';
|
||||
export const POLL_VOTE_FAIL = 'POLL_VOTE_FAIL';
|
||||
|
||||
export const POLL_FETCH_REQUEST = 'POLL_FETCH_REQUEST';
|
||||
export const POLL_FETCH_SUCCESS = 'POLL_FETCH_SUCCESS';
|
||||
export const POLL_FETCH_FAIL = 'POLL_FETCH_FAIL';
|
||||
|
||||
export const vote = (pollId, choices) => (dispatch) => {
|
||||
dispatch(voteRequest());
|
||||
|
||||
api().post(`/api/v1/polls/${pollId}/votes`, { choices })
|
||||
.then(({ data }) => {
|
||||
dispatch(importFetchedPoll(data));
|
||||
dispatch(voteSuccess(data));
|
||||
})
|
||||
.catch(err => dispatch(voteFail(err)));
|
||||
};
|
||||
|
||||
export const fetchPoll = pollId => (dispatch) => {
|
||||
dispatch(fetchPollRequest());
|
||||
|
||||
api().get(`/api/v1/polls/${pollId}`)
|
||||
.then(({ data }) => {
|
||||
dispatch(importFetchedPoll(data));
|
||||
dispatch(fetchPollSuccess(data));
|
||||
})
|
||||
.catch(err => dispatch(fetchPollFail(err)));
|
||||
};
|
||||
|
||||
export const voteRequest = () => ({
|
||||
type: POLL_VOTE_REQUEST,
|
||||
});
|
||||
|
||||
export const voteSuccess = poll => ({
|
||||
type: POLL_VOTE_SUCCESS,
|
||||
poll,
|
||||
});
|
||||
|
||||
export const voteFail = error => ({
|
||||
type: POLL_VOTE_FAIL,
|
||||
error,
|
||||
});
|
||||
|
||||
export const fetchPollRequest = () => ({
|
||||
type: POLL_FETCH_REQUEST,
|
||||
});
|
||||
|
||||
export const fetchPollSuccess = poll => ({
|
||||
type: POLL_FETCH_SUCCESS,
|
||||
poll,
|
||||
});
|
||||
|
||||
export const fetchPollFail = error => ({
|
||||
type: POLL_FETCH_FAIL,
|
||||
error,
|
||||
});
|
40
app/javascript/flavours/glitch/actions/polls.ts
Normal file
40
app/javascript/flavours/glitch/actions/polls.ts
Normal file
|
@ -0,0 +1,40 @@
|
|||
import { apiGetPoll, apiPollVote } from 'flavours/glitch/api/polls';
|
||||
import type { ApiPollJSON } from 'flavours/glitch/api_types/polls';
|
||||
import { createPollFromServerJSON } from 'flavours/glitch/models/poll';
|
||||
import {
|
||||
createAppAsyncThunk,
|
||||
createDataLoadingThunk,
|
||||
} from 'flavours/glitch/store/typed_functions';
|
||||
|
||||
import { importPolls } from './importer/polls';
|
||||
|
||||
export const importFetchedPoll = createAppAsyncThunk(
|
||||
'poll/importFetched',
|
||||
(args: { poll: ApiPollJSON }, { dispatch, getState }) => {
|
||||
const { poll } = args;
|
||||
|
||||
dispatch(
|
||||
importPolls({
|
||||
polls: [createPollFromServerJSON(poll, getState().polls.get(poll.id))],
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const vote = createDataLoadingThunk(
|
||||
'poll/vote',
|
||||
({ pollId, choices }: { pollId: string; choices: string[] }) =>
|
||||
apiPollVote(pollId, choices),
|
||||
async (poll, { dispatch, discardLoadData }) => {
|
||||
await dispatch(importFetchedPoll({ poll }));
|
||||
return discardLoadData;
|
||||
},
|
||||
);
|
||||
|
||||
export const fetchPoll = createDataLoadingThunk(
|
||||
'poll/fetch',
|
||||
({ pollId }: { pollId: string }) => apiGetPoll(pollId),
|
||||
async (poll, { dispatch }) => {
|
||||
await dispatch(importFetchedPoll({ poll }));
|
||||
},
|
||||
);
|
|
@ -1,215 +0,0 @@
|
|||
import { fromJS } from 'immutable';
|
||||
|
||||
import { searchHistory } from 'flavours/glitch/settings';
|
||||
|
||||
import api from '../api';
|
||||
|
||||
import { fetchRelationships } from './accounts';
|
||||
import { importFetchedAccounts, importFetchedStatuses } from './importer';
|
||||
|
||||
export const SEARCH_CHANGE = 'SEARCH_CHANGE';
|
||||
export const SEARCH_CLEAR = 'SEARCH_CLEAR';
|
||||
export const SEARCH_SHOW = 'SEARCH_SHOW';
|
||||
|
||||
export const SEARCH_FETCH_REQUEST = 'SEARCH_FETCH_REQUEST';
|
||||
export const SEARCH_FETCH_SUCCESS = 'SEARCH_FETCH_SUCCESS';
|
||||
export const SEARCH_FETCH_FAIL = 'SEARCH_FETCH_FAIL';
|
||||
|
||||
export const SEARCH_EXPAND_REQUEST = 'SEARCH_EXPAND_REQUEST';
|
||||
export const SEARCH_EXPAND_SUCCESS = 'SEARCH_EXPAND_SUCCESS';
|
||||
export const SEARCH_EXPAND_FAIL = 'SEARCH_EXPAND_FAIL';
|
||||
|
||||
export const SEARCH_HISTORY_UPDATE = 'SEARCH_HISTORY_UPDATE';
|
||||
|
||||
export function changeSearch(value) {
|
||||
return {
|
||||
type: SEARCH_CHANGE,
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
export function clearSearch() {
|
||||
return {
|
||||
type: SEARCH_CLEAR,
|
||||
};
|
||||
}
|
||||
|
||||
export function submitSearch(type) {
|
||||
return (dispatch, getState) => {
|
||||
const value = getState().getIn(['search', 'value']);
|
||||
const signedIn = !!getState().getIn(['meta', 'me']);
|
||||
|
||||
if (value.length === 0) {
|
||||
dispatch(fetchSearchSuccess({ accounts: [], statuses: [], hashtags: [] }, '', type));
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(fetchSearchRequest(type));
|
||||
|
||||
api().get('/api/v2/search', {
|
||||
params: {
|
||||
q: value,
|
||||
resolve: signedIn,
|
||||
limit: 11,
|
||||
type,
|
||||
},
|
||||
}).then(response => {
|
||||
if (response.data.accounts) {
|
||||
dispatch(importFetchedAccounts(response.data.accounts));
|
||||
}
|
||||
|
||||
if (response.data.statuses) {
|
||||
dispatch(importFetchedStatuses(response.data.statuses));
|
||||
}
|
||||
|
||||
dispatch(fetchSearchSuccess(response.data, value, type));
|
||||
dispatch(fetchRelationships(response.data.accounts.map(item => item.id)));
|
||||
}).catch(error => {
|
||||
dispatch(fetchSearchFail(error));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchSearchRequest(searchType) {
|
||||
return {
|
||||
type: SEARCH_FETCH_REQUEST,
|
||||
searchType,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchSearchSuccess(results, searchTerm, searchType) {
|
||||
return {
|
||||
type: SEARCH_FETCH_SUCCESS,
|
||||
results,
|
||||
searchType,
|
||||
searchTerm,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchSearchFail(error) {
|
||||
return {
|
||||
type: SEARCH_FETCH_FAIL,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export const expandSearch = type => (dispatch, getState) => {
|
||||
const value = getState().getIn(['search', 'value']);
|
||||
const offset = getState().getIn(['search', 'results', type]).size - 1;
|
||||
|
||||
dispatch(expandSearchRequest(type));
|
||||
|
||||
api().get('/api/v2/search', {
|
||||
params: {
|
||||
q: value,
|
||||
type,
|
||||
offset,
|
||||
limit: 11,
|
||||
},
|
||||
}).then(({ data }) => {
|
||||
if (data.accounts) {
|
||||
dispatch(importFetchedAccounts(data.accounts));
|
||||
}
|
||||
|
||||
if (data.statuses) {
|
||||
dispatch(importFetchedStatuses(data.statuses));
|
||||
}
|
||||
|
||||
dispatch(expandSearchSuccess(data, value, type));
|
||||
dispatch(fetchRelationships(data.accounts.map(item => item.id)));
|
||||
}).catch(error => {
|
||||
dispatch(expandSearchFail(error));
|
||||
});
|
||||
};
|
||||
|
||||
export const expandSearchRequest = (searchType) => ({
|
||||
type: SEARCH_EXPAND_REQUEST,
|
||||
searchType,
|
||||
});
|
||||
|
||||
export const expandSearchSuccess = (results, searchTerm, searchType) => ({
|
||||
type: SEARCH_EXPAND_SUCCESS,
|
||||
results,
|
||||
searchTerm,
|
||||
searchType,
|
||||
});
|
||||
|
||||
export const expandSearchFail = error => ({
|
||||
type: SEARCH_EXPAND_FAIL,
|
||||
error,
|
||||
});
|
||||
|
||||
export const showSearch = () => ({
|
||||
type: SEARCH_SHOW,
|
||||
});
|
||||
|
||||
export const openURL = (value, history, onFailure) => (dispatch, getState) => {
|
||||
const signedIn = !!getState().getIn(['meta', 'me']);
|
||||
|
||||
if (!signedIn) {
|
||||
if (onFailure) {
|
||||
onFailure();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(fetchSearchRequest());
|
||||
|
||||
api().get('/api/v2/search', { params: { q: value, resolve: true } }).then(response => {
|
||||
if (response.data.accounts?.length > 0) {
|
||||
dispatch(importFetchedAccounts(response.data.accounts));
|
||||
history.push(`/@${response.data.accounts[0].acct}`);
|
||||
} else if (response.data.statuses?.length > 0) {
|
||||
dispatch(importFetchedStatuses(response.data.statuses));
|
||||
history.push(`/@${response.data.statuses[0].account.acct}/${response.data.statuses[0].id}`);
|
||||
} else if (onFailure) {
|
||||
onFailure();
|
||||
}
|
||||
|
||||
dispatch(fetchSearchSuccess(response.data, value));
|
||||
}).catch(err => {
|
||||
dispatch(fetchSearchFail(err));
|
||||
|
||||
if (onFailure) {
|
||||
onFailure();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const clickSearchResult = (q, type) => (dispatch, getState) => {
|
||||
const previous = getState().getIn(['search', 'recent']);
|
||||
|
||||
if (previous.some(x => x.get('q') === q && x.get('type') === type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const me = getState().getIn(['meta', 'me']);
|
||||
const current = previous.add(fromJS({ type, q })).takeLast(4);
|
||||
|
||||
searchHistory.set(me, current.toJS());
|
||||
dispatch(updateSearchHistory(current));
|
||||
};
|
||||
|
||||
export const forgetSearchResult = q => (dispatch, getState) => {
|
||||
const previous = getState().getIn(['search', 'recent']);
|
||||
const me = getState().getIn(['meta', 'me']);
|
||||
const current = previous.filterNot(result => result.get('q') === q);
|
||||
|
||||
searchHistory.set(me, current.toJS());
|
||||
dispatch(updateSearchHistory(current));
|
||||
};
|
||||
|
||||
export const updateSearchHistory = recent => ({
|
||||
type: SEARCH_HISTORY_UPDATE,
|
||||
recent,
|
||||
});
|
||||
|
||||
export const hydrateSearch = () => (dispatch, getState) => {
|
||||
const me = getState().getIn(['meta', 'me']);
|
||||
const history = searchHistory.get(me);
|
||||
|
||||
if (history !== null) {
|
||||
dispatch(updateSearchHistory(history));
|
||||
}
|
||||
};
|
151
app/javascript/flavours/glitch/actions/search.ts
Normal file
151
app/javascript/flavours/glitch/actions/search.ts
Normal file
|
@ -0,0 +1,151 @@
|
|||
import { createAction } from '@reduxjs/toolkit';
|
||||
|
||||
import { apiGetSearch } from 'flavours/glitch/api/search';
|
||||
import type { ApiSearchType } from 'flavours/glitch/api_types/search';
|
||||
import type {
|
||||
RecentSearch,
|
||||
SearchType as RecentSearchType,
|
||||
} from 'flavours/glitch/models/search';
|
||||
import { searchHistory } from 'flavours/glitch/settings';
|
||||
import {
|
||||
createDataLoadingThunk,
|
||||
createAppAsyncThunk,
|
||||
} from 'flavours/glitch/store/typed_functions';
|
||||
|
||||
import { fetchRelationships } from './accounts';
|
||||
import { importFetchedAccounts, importFetchedStatuses } from './importer';
|
||||
|
||||
export const SEARCH_HISTORY_UPDATE = 'SEARCH_HISTORY_UPDATE';
|
||||
|
||||
export const submitSearch = createDataLoadingThunk(
|
||||
'search/submit',
|
||||
async ({ q, type }: { q: string; type?: ApiSearchType }, { getState }) => {
|
||||
const signedIn = !!getState().meta.get('me');
|
||||
|
||||
return apiGetSearch({
|
||||
q,
|
||||
type,
|
||||
resolve: signedIn,
|
||||
limit: 11,
|
||||
});
|
||||
},
|
||||
(data, { dispatch }) => {
|
||||
if (data.accounts.length > 0) {
|
||||
dispatch(importFetchedAccounts(data.accounts));
|
||||
dispatch(fetchRelationships(data.accounts.map((account) => account.id)));
|
||||
}
|
||||
|
||||
if (data.statuses.length > 0) {
|
||||
dispatch(importFetchedStatuses(data.statuses));
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
{
|
||||
useLoadingBar: false,
|
||||
},
|
||||
);
|
||||
|
||||
export const expandSearch = createDataLoadingThunk(
|
||||
'search/expand',
|
||||
async ({ type }: { type: ApiSearchType }, { getState }) => {
|
||||
const q = getState().search.q;
|
||||
const results = getState().search.results;
|
||||
const offset = results?.[type].length;
|
||||
|
||||
return apiGetSearch({
|
||||
q,
|
||||
type,
|
||||
limit: 10,
|
||||
offset,
|
||||
});
|
||||
},
|
||||
(data, { dispatch }) => {
|
||||
if (data.accounts.length > 0) {
|
||||
dispatch(importFetchedAccounts(data.accounts));
|
||||
dispatch(fetchRelationships(data.accounts.map((account) => account.id)));
|
||||
}
|
||||
|
||||
if (data.statuses.length > 0) {
|
||||
dispatch(importFetchedStatuses(data.statuses));
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
{
|
||||
useLoadingBar: true,
|
||||
},
|
||||
);
|
||||
|
||||
export const openURL = createDataLoadingThunk(
|
||||
'search/openURL',
|
||||
({ url }: { url: string }, { getState }) => {
|
||||
const signedIn = !!getState().meta.get('me');
|
||||
|
||||
return apiGetSearch({
|
||||
q: url,
|
||||
resolve: signedIn,
|
||||
limit: 1,
|
||||
});
|
||||
},
|
||||
(data, { dispatch }) => {
|
||||
if (data.accounts.length > 0) {
|
||||
dispatch(importFetchedAccounts(data.accounts));
|
||||
} else if (data.statuses.length > 0) {
|
||||
dispatch(importFetchedStatuses(data.statuses));
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
{
|
||||
useLoadingBar: true,
|
||||
},
|
||||
);
|
||||
|
||||
export const clickSearchResult = createAppAsyncThunk(
|
||||
'search/clickResult',
|
||||
(
|
||||
{ q, type }: { q: string; type?: RecentSearchType },
|
||||
{ dispatch, getState },
|
||||
) => {
|
||||
const previous = getState().search.recent;
|
||||
|
||||
if (previous.some((x) => x.q === q && x.type === type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const me = getState().meta.get('me') as string;
|
||||
const current = [{ type, q }, ...previous].slice(0, 4);
|
||||
|
||||
searchHistory.set(me, current);
|
||||
dispatch(updateSearchHistory(current));
|
||||
},
|
||||
);
|
||||
|
||||
export const forgetSearchResult = createAppAsyncThunk(
|
||||
'search/forgetResult',
|
||||
(q: string, { dispatch, getState }) => {
|
||||
const previous = getState().search.recent;
|
||||
const me = getState().meta.get('me') as string;
|
||||
const current = previous.filter((result) => result.q !== q);
|
||||
|
||||
searchHistory.set(me, current);
|
||||
dispatch(updateSearchHistory(current));
|
||||
},
|
||||
);
|
||||
|
||||
export const updateSearchHistory = createAction<RecentSearch[]>(
|
||||
'search/updateHistory',
|
||||
);
|
||||
|
||||
export const hydrateSearch = createAppAsyncThunk(
|
||||
'search/hydrate',
|
||||
(_args, { dispatch, getState }) => {
|
||||
const me = getState().meta.get('me') as string;
|
||||
const history = searchHistory.get(me) as RecentSearch[] | null;
|
||||
|
||||
if (history !== null) {
|
||||
dispatch(updateSearchHistory(history));
|
||||
}
|
||||
},
|
||||
);
|
|
@ -1,9 +1,5 @@
|
|||
import api, { getLinks } from '../api';
|
||||
|
||||
export const HASHTAG_FETCH_REQUEST = 'HASHTAG_FETCH_REQUEST';
|
||||
export const HASHTAG_FETCH_SUCCESS = 'HASHTAG_FETCH_SUCCESS';
|
||||
export const HASHTAG_FETCH_FAIL = 'HASHTAG_FETCH_FAIL';
|
||||
|
||||
export const FOLLOWED_HASHTAGS_FETCH_REQUEST = 'FOLLOWED_HASHTAGS_FETCH_REQUEST';
|
||||
export const FOLLOWED_HASHTAGS_FETCH_SUCCESS = 'FOLLOWED_HASHTAGS_FETCH_SUCCESS';
|
||||
export const FOLLOWED_HASHTAGS_FETCH_FAIL = 'FOLLOWED_HASHTAGS_FETCH_FAIL';
|
||||
|
@ -12,39 +8,6 @@ export const FOLLOWED_HASHTAGS_EXPAND_REQUEST = 'FOLLOWED_HASHTAGS_EXPAND_REQUES
|
|||
export const FOLLOWED_HASHTAGS_EXPAND_SUCCESS = 'FOLLOWED_HASHTAGS_EXPAND_SUCCESS';
|
||||
export const FOLLOWED_HASHTAGS_EXPAND_FAIL = 'FOLLOWED_HASHTAGS_EXPAND_FAIL';
|
||||
|
||||
export const HASHTAG_FOLLOW_REQUEST = 'HASHTAG_FOLLOW_REQUEST';
|
||||
export const HASHTAG_FOLLOW_SUCCESS = 'HASHTAG_FOLLOW_SUCCESS';
|
||||
export const HASHTAG_FOLLOW_FAIL = 'HASHTAG_FOLLOW_FAIL';
|
||||
|
||||
export const HASHTAG_UNFOLLOW_REQUEST = 'HASHTAG_UNFOLLOW_REQUEST';
|
||||
export const HASHTAG_UNFOLLOW_SUCCESS = 'HASHTAG_UNFOLLOW_SUCCESS';
|
||||
export const HASHTAG_UNFOLLOW_FAIL = 'HASHTAG_UNFOLLOW_FAIL';
|
||||
|
||||
export const fetchHashtag = name => (dispatch) => {
|
||||
dispatch(fetchHashtagRequest());
|
||||
|
||||
api().get(`/api/v1/tags/${name}`).then(({ data }) => {
|
||||
dispatch(fetchHashtagSuccess(name, data));
|
||||
}).catch(err => {
|
||||
dispatch(fetchHashtagFail(err));
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchHashtagRequest = () => ({
|
||||
type: HASHTAG_FETCH_REQUEST,
|
||||
});
|
||||
|
||||
export const fetchHashtagSuccess = (name, tag) => ({
|
||||
type: HASHTAG_FETCH_SUCCESS,
|
||||
name,
|
||||
tag,
|
||||
});
|
||||
|
||||
export const fetchHashtagFail = error => ({
|
||||
type: HASHTAG_FETCH_FAIL,
|
||||
error,
|
||||
});
|
||||
|
||||
export const fetchFollowedHashtags = () => (dispatch) => {
|
||||
dispatch(fetchFollowedHashtagsRequest());
|
||||
|
||||
|
@ -116,57 +79,3 @@ export function expandFollowedHashtagsFail(error) {
|
|||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export const followHashtag = name => (dispatch) => {
|
||||
dispatch(followHashtagRequest(name));
|
||||
|
||||
api().post(`/api/v1/tags/${name}/follow`).then(({ data }) => {
|
||||
dispatch(followHashtagSuccess(name, data));
|
||||
}).catch(err => {
|
||||
dispatch(followHashtagFail(name, err));
|
||||
});
|
||||
};
|
||||
|
||||
export const followHashtagRequest = name => ({
|
||||
type: HASHTAG_FOLLOW_REQUEST,
|
||||
name,
|
||||
});
|
||||
|
||||
export const followHashtagSuccess = (name, tag) => ({
|
||||
type: HASHTAG_FOLLOW_SUCCESS,
|
||||
name,
|
||||
tag,
|
||||
});
|
||||
|
||||
export const followHashtagFail = (name, error) => ({
|
||||
type: HASHTAG_FOLLOW_FAIL,
|
||||
name,
|
||||
error,
|
||||
});
|
||||
|
||||
export const unfollowHashtag = name => (dispatch) => {
|
||||
dispatch(unfollowHashtagRequest(name));
|
||||
|
||||
api().post(`/api/v1/tags/${name}/unfollow`).then(({ data }) => {
|
||||
dispatch(unfollowHashtagSuccess(name, data));
|
||||
}).catch(err => {
|
||||
dispatch(unfollowHashtagFail(name, err));
|
||||
});
|
||||
};
|
||||
|
||||
export const unfollowHashtagRequest = name => ({
|
||||
type: HASHTAG_UNFOLLOW_REQUEST,
|
||||
name,
|
||||
});
|
||||
|
||||
export const unfollowHashtagSuccess = (name, tag) => ({
|
||||
type: HASHTAG_UNFOLLOW_SUCCESS,
|
||||
name,
|
||||
tag,
|
||||
});
|
||||
|
||||
export const unfollowHashtagFail = (name, error) => ({
|
||||
type: HASHTAG_UNFOLLOW_FAIL,
|
||||
name,
|
||||
error,
|
||||
});
|
||||
|
|
21
app/javascript/flavours/glitch/actions/tags_typed.ts
Normal file
21
app/javascript/flavours/glitch/actions/tags_typed.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
import {
|
||||
apiGetTag,
|
||||
apiFollowTag,
|
||||
apiUnfollowTag,
|
||||
} from 'flavours/glitch/api/tags';
|
||||
import { createDataLoadingThunk } from 'flavours/glitch/store/typed_functions';
|
||||
|
||||
export const fetchHashtag = createDataLoadingThunk(
|
||||
'tags/fetch',
|
||||
({ tagId }: { tagId: string }) => apiGetTag(tagId),
|
||||
);
|
||||
|
||||
export const followHashtag = createDataLoadingThunk(
|
||||
'tags/follow',
|
||||
({ tagId }: { tagId: string }) => apiFollowTag(tagId),
|
||||
);
|
||||
|
||||
export const unfollowHashtag = createDataLoadingThunk(
|
||||
'tags/unfollow',
|
||||
({ tagId }: { tagId: string }) => apiUnfollowTag(tagId),
|
||||
);
|
|
@ -5,3 +5,16 @@ export const apiSubmitAccountNote = (id: string, value: string) =>
|
|||
apiRequestPost<ApiRelationshipJSON>(`v1/accounts/${id}/note`, {
|
||||
comment: value,
|
||||
});
|
||||
|
||||
export const apiFollowAccount = (
|
||||
id: string,
|
||||
params?: {
|
||||
reblogs: boolean;
|
||||
},
|
||||
) =>
|
||||
apiRequestPost<ApiRelationshipJSON>(`v1/accounts/${id}/follow`, {
|
||||
...params,
|
||||
});
|
||||
|
||||
export const apiUnfollowAccount = (id: string) =>
|
||||
apiRequestPost<ApiRelationshipJSON>(`v1/accounts/${id}/unfollow`);
|
||||
|
|
11
app/javascript/flavours/glitch/api/instance.ts
Normal file
11
app/javascript/flavours/glitch/api/instance.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
import { apiRequestGet } from 'flavours/glitch/api';
|
||||
import type {
|
||||
ApiTermsOfServiceJSON,
|
||||
ApiPrivacyPolicyJSON,
|
||||
} from 'flavours/glitch/api_types/instance';
|
||||
|
||||
export const apiGetTermsOfService = () =>
|
||||
apiRequestGet<ApiTermsOfServiceJSON>('v1/instance/terms_of_service');
|
||||
|
||||
export const apiGetPrivacyPolicy = () =>
|
||||
apiRequestGet<ApiPrivacyPolicyJSON>('v1/instance/privacy_policy');
|
10
app/javascript/flavours/glitch/api/polls.ts
Normal file
10
app/javascript/flavours/glitch/api/polls.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
import { apiRequestGet, apiRequestPost } from 'flavours/glitch/api';
|
||||
import type { ApiPollJSON } from 'flavours/glitch/api_types/polls';
|
||||
|
||||
export const apiGetPoll = (pollId: string) =>
|
||||
apiRequestGet<ApiPollJSON>(`/v1/polls/${pollId}`);
|
||||
|
||||
export const apiPollVote = (pollId: string, choices: string[]) =>
|
||||
apiRequestPost<ApiPollJSON>(`/v1/polls/${pollId}/votes`, {
|
||||
choices,
|
||||
});
|
16
app/javascript/flavours/glitch/api/search.ts
Normal file
16
app/javascript/flavours/glitch/api/search.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { apiRequestGet } from 'flavours/glitch/api';
|
||||
import type {
|
||||
ApiSearchType,
|
||||
ApiSearchResultsJSON,
|
||||
} from 'flavours/glitch/api_types/search';
|
||||
|
||||
export const apiGetSearch = (params: {
|
||||
q: string;
|
||||
resolve?: boolean;
|
||||
type?: ApiSearchType;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) =>
|
||||
apiRequestGet<ApiSearchResultsJSON>('v2/search', {
|
||||
...params,
|
||||
});
|
11
app/javascript/flavours/glitch/api/tags.ts
Normal file
11
app/javascript/flavours/glitch/api/tags.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
import { apiRequestPost, apiRequestGet } from 'flavours/glitch/api';
|
||||
import type { ApiHashtagJSON } from 'flavours/glitch/api_types/tags';
|
||||
|
||||
export const apiGetTag = (tagId: string) =>
|
||||
apiRequestGet<ApiHashtagJSON>(`v1/tags/${tagId}`);
|
||||
|
||||
export const apiFollowTag = (tagId: string) =>
|
||||
apiRequestPost<ApiHashtagJSON>(`v1/tags/${tagId}/follow`);
|
||||
|
||||
export const apiUnfollowTag = (tagId: string) =>
|
||||
apiRequestPost<ApiHashtagJSON>(`v1/tags/${tagId}/unfollow`);
|
9
app/javascript/flavours/glitch/api_types/instance.ts
Normal file
9
app/javascript/flavours/glitch/api_types/instance.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
export interface ApiTermsOfServiceJSON {
|
||||
updated_at: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ApiPrivacyPolicyJSON {
|
||||
updated_at: string;
|
||||
content: string;
|
||||
}
|
|
@ -18,6 +18,6 @@ export interface ApiPollJSON {
|
|||
options: ApiPollOptionJSON[];
|
||||
emojis: ApiCustomEmojiJSON[];
|
||||
|
||||
voted: boolean;
|
||||
own_votes: number[];
|
||||
voted?: boolean;
|
||||
own_votes?: number[];
|
||||
}
|
||||
|
|
11
app/javascript/flavours/glitch/api_types/search.ts
Normal file
11
app/javascript/flavours/glitch/api_types/search.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
import type { ApiAccountJSON } from './accounts';
|
||||
import type { ApiStatusJSON } from './statuses';
|
||||
import type { ApiHashtagJSON } from './tags';
|
||||
|
||||
export type ApiSearchType = 'accounts' | 'statuses' | 'hashtags';
|
||||
|
||||
export interface ApiSearchResultsJSON {
|
||||
accounts: ApiAccountJSON[];
|
||||
statuses: ApiStatusJSON[];
|
||||
hashtags: ApiHashtagJSON[];
|
||||
}
|
13
app/javascript/flavours/glitch/api_types/tags.ts
Normal file
13
app/javascript/flavours/glitch/api_types/tags.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
interface ApiHistoryJSON {
|
||||
day: string;
|
||||
accounts: string;
|
||||
uses: string;
|
||||
}
|
||||
|
||||
export interface ApiHashtagJSON {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
history: [ApiHistoryJSON, ...ApiHistoryJSON[]];
|
||||
following?: boolean;
|
||||
}
|
|
@ -1,177 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
import { EmptyAccount } from 'flavours/glitch/components/empty_account';
|
||||
import { FollowButton } from 'flavours/glitch/components/follow_button';
|
||||
import { ShortNumber } from 'flavours/glitch/components/short_number';
|
||||
import { VerifiedBadge } from 'flavours/glitch/components/verified_badge';
|
||||
|
||||
import DropdownMenuContainer from '../containers/dropdown_menu_container';
|
||||
import { me } from '../initial_state';
|
||||
|
||||
import { Avatar } from './avatar';
|
||||
import { Button } from './button';
|
||||
import { FollowersCounter } from './counters';
|
||||
import { DisplayName } from './display_name';
|
||||
import { Permalink } from './permalink';
|
||||
import { RelativeTimestamp } from './relative_timestamp';
|
||||
|
||||
const messages = defineMessages({
|
||||
unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' },
|
||||
unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' },
|
||||
mute_notifications: { id: 'account.mute_notifications_short', defaultMessage: 'Mute notifications' },
|
||||
unmute_notifications: { id: 'account.unmute_notifications_short', defaultMessage: 'Unmute notifications' },
|
||||
mute: { id: 'account.mute_short', defaultMessage: 'Mute' },
|
||||
block: { id: 'account.block_short', defaultMessage: 'Block' },
|
||||
more: { id: 'status.more', defaultMessage: 'More' },
|
||||
});
|
||||
|
||||
const Account = ({ size = 46, account, onBlock, onMute, onMuteNotifications, hidden, minimal, defaultAction, withBio }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const handleBlock = useCallback(() => {
|
||||
onBlock(account);
|
||||
}, [onBlock, account]);
|
||||
|
||||
const handleMute = useCallback(() => {
|
||||
onMute(account);
|
||||
}, [onMute, account]);
|
||||
|
||||
const handleMuteNotifications = useCallback(() => {
|
||||
onMuteNotifications(account, true);
|
||||
}, [onMuteNotifications, account]);
|
||||
|
||||
const handleUnmuteNotifications = useCallback(() => {
|
||||
onMuteNotifications(account, false);
|
||||
}, [onMuteNotifications, account]);
|
||||
|
||||
if (!account) {
|
||||
return <EmptyAccount size={size} minimal={minimal} />;
|
||||
}
|
||||
|
||||
if (hidden) {
|
||||
return (
|
||||
<>
|
||||
{account.get('display_name')}
|
||||
{account.get('username')}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
let buttons;
|
||||
|
||||
if (account.get('id') !== me && account.get('relationship', null) !== null) {
|
||||
const requested = account.getIn(['relationship', 'requested']);
|
||||
const blocking = account.getIn(['relationship', 'blocking']);
|
||||
const muting = account.getIn(['relationship', 'muting']);
|
||||
|
||||
if (requested) {
|
||||
buttons = <FollowButton accountId={account.get('id')} />;
|
||||
} else if (blocking) {
|
||||
buttons = <Button text={intl.formatMessage(messages.unblock)} onClick={handleBlock} />;
|
||||
} else if (muting) {
|
||||
let menu;
|
||||
|
||||
if (account.getIn(['relationship', 'muting_notifications'])) {
|
||||
menu = [{ text: intl.formatMessage(messages.unmute_notifications), action: handleUnmuteNotifications }];
|
||||
} else {
|
||||
menu = [{ text: intl.formatMessage(messages.mute_notifications), action: handleMuteNotifications }];
|
||||
}
|
||||
|
||||
buttons = (
|
||||
<>
|
||||
<DropdownMenuContainer
|
||||
items={menu}
|
||||
icon='ellipsis-h'
|
||||
iconComponent={MoreHorizIcon}
|
||||
direction='right'
|
||||
title={intl.formatMessage(messages.more)}
|
||||
/>
|
||||
|
||||
<Button text={intl.formatMessage(messages.unmute)} onClick={handleMute} />
|
||||
</>
|
||||
);
|
||||
} else if (defaultAction === 'mute') {
|
||||
buttons = <Button text={intl.formatMessage(messages.mute)} onClick={handleMute} />;
|
||||
} else if (defaultAction === 'block') {
|
||||
buttons = <Button text={intl.formatMessage(messages.block)} onClick={handleBlock} />;
|
||||
} else {
|
||||
buttons = <FollowButton accountId={account.get('id')} />;
|
||||
}
|
||||
} else {
|
||||
buttons = <FollowButton accountId={account.get('id')} />;
|
||||
}
|
||||
|
||||
let muteTimeRemaining;
|
||||
|
||||
if (account.get('mute_expires_at')) {
|
||||
muteTimeRemaining = <>· <RelativeTimestamp timestamp={account.get('mute_expires_at')} futureDate /></>;
|
||||
}
|
||||
|
||||
let verification;
|
||||
|
||||
const firstVerifiedField = account.get('fields').find(item => !!item.get('verified_at'));
|
||||
|
||||
if (firstVerifiedField) {
|
||||
verification = <VerifiedBadge link={firstVerifiedField.get('value')} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames('account', { 'account--minimal': minimal })}>
|
||||
<div className='account__wrapper'>
|
||||
<Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`/@${account.get('acct')}`} data-hover-card-account={account.get('id')}>
|
||||
<div className='account__avatar-wrapper'>
|
||||
<Avatar account={account} size={size} />
|
||||
</div>
|
||||
|
||||
<div className='account__contents'>
|
||||
<DisplayName account={account} />
|
||||
{!minimal && (
|
||||
<div className='account__details'>
|
||||
{account.get('followers_count') !== -1 && (
|
||||
<ShortNumber value={account.get('followers_count')} renderer={FollowersCounter} />
|
||||
)} {verification} {muteTimeRemaining}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Permalink>
|
||||
|
||||
{!minimal && (
|
||||
<div className='account__relationship'>
|
||||
{buttons}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{withBio && (account.get('note').length > 0 ? (
|
||||
<div
|
||||
className='account__note translate'
|
||||
dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }}
|
||||
/>
|
||||
) : (
|
||||
<div className='account__note account__note--missing'><FormattedMessage id='account.no_bio' defaultMessage='No description provided.' /></div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Account.propTypes = {
|
||||
size: PropTypes.number,
|
||||
account: ImmutablePropTypes.record,
|
||||
onBlock: PropTypes.func,
|
||||
onMute: PropTypes.func,
|
||||
onMuteNotifications: PropTypes.func,
|
||||
hidden: PropTypes.bool,
|
||||
minimal: PropTypes.bool,
|
||||
defaultAction: PropTypes.string,
|
||||
withBio: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default Account;
|
237
app/javascript/flavours/glitch/components/account.tsx
Normal file
237
app/javascript/flavours/glitch/components/account.tsx
Normal file
|
@ -0,0 +1,237 @@
|
|||
import { useCallback } from 'react';
|
||||
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
import {
|
||||
blockAccount,
|
||||
unblockAccount,
|
||||
muteAccount,
|
||||
unmuteAccount,
|
||||
} from 'flavours/glitch/actions/accounts';
|
||||
import { initMuteModal } from 'flavours/glitch/actions/mutes';
|
||||
import { Avatar } from 'flavours/glitch/components/avatar';
|
||||
import { Button } from 'flavours/glitch/components/button';
|
||||
import { FollowersCounter } from 'flavours/glitch/components/counters';
|
||||
import { DisplayName } from 'flavours/glitch/components/display_name';
|
||||
import { FollowButton } from 'flavours/glitch/components/follow_button';
|
||||
import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp';
|
||||
import { ShortNumber } from 'flavours/glitch/components/short_number';
|
||||
import { Skeleton } from 'flavours/glitch/components/skeleton';
|
||||
import { VerifiedBadge } from 'flavours/glitch/components/verified_badge';
|
||||
import DropdownMenu from 'flavours/glitch/containers/dropdown_menu_container';
|
||||
import { me } from 'flavours/glitch/initial_state';
|
||||
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
|
||||
|
||||
import { Permalink } from './permalink';
|
||||
|
||||
const messages = defineMessages({
|
||||
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
||||
cancel_follow_request: {
|
||||
id: 'account.cancel_follow_request',
|
||||
defaultMessage: 'Withdraw follow request',
|
||||
},
|
||||
unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' },
|
||||
unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' },
|
||||
mute_notifications: {
|
||||
id: 'account.mute_notifications_short',
|
||||
defaultMessage: 'Mute notifications',
|
||||
},
|
||||
unmute_notifications: {
|
||||
id: 'account.unmute_notifications_short',
|
||||
defaultMessage: 'Unmute notifications',
|
||||
},
|
||||
mute: { id: 'account.mute_short', defaultMessage: 'Mute' },
|
||||
block: { id: 'account.block_short', defaultMessage: 'Block' },
|
||||
more: { id: 'status.more', defaultMessage: 'More' },
|
||||
});
|
||||
|
||||
export const Account: React.FC<{
|
||||
size?: number;
|
||||
id: string;
|
||||
hidden?: boolean;
|
||||
minimal?: boolean;
|
||||
defaultAction?: 'block' | 'mute';
|
||||
withBio?: boolean;
|
||||
}> = ({ id, size = 46, hidden, minimal, defaultAction, withBio }) => {
|
||||
const intl = useIntl();
|
||||
const account = useAppSelector((state) => state.accounts.get(id));
|
||||
const relationship = useAppSelector((state) => state.relationships.get(id));
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleBlock = useCallback(() => {
|
||||
if (relationship?.blocking) {
|
||||
dispatch(unblockAccount(id));
|
||||
} else {
|
||||
dispatch(blockAccount(id));
|
||||
}
|
||||
}, [dispatch, id, relationship]);
|
||||
|
||||
const handleMute = useCallback(() => {
|
||||
if (relationship?.muting) {
|
||||
dispatch(unmuteAccount(id));
|
||||
} else {
|
||||
dispatch(initMuteModal(account));
|
||||
}
|
||||
}, [dispatch, id, account, relationship]);
|
||||
|
||||
const handleMuteNotifications = useCallback(() => {
|
||||
dispatch(muteAccount(id, true));
|
||||
}, [dispatch, id]);
|
||||
|
||||
const handleUnmuteNotifications = useCallback(() => {
|
||||
dispatch(muteAccount(id, false));
|
||||
}, [dispatch, id]);
|
||||
|
||||
if (hidden) {
|
||||
return (
|
||||
<>
|
||||
{account?.display_name}
|
||||
{account?.username}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
let buttons;
|
||||
|
||||
if (account && account.id !== me && relationship) {
|
||||
const { requested, blocking, muting } = relationship;
|
||||
|
||||
if (requested) {
|
||||
buttons = <FollowButton accountId={id} />;
|
||||
} else if (blocking) {
|
||||
buttons = (
|
||||
<Button
|
||||
text={intl.formatMessage(messages.unblock)}
|
||||
onClick={handleBlock}
|
||||
/>
|
||||
);
|
||||
} else if (muting) {
|
||||
const menu = [
|
||||
{
|
||||
text: intl.formatMessage(
|
||||
relationship.muting_notifications
|
||||
? messages.unmute_notifications
|
||||
: messages.mute_notifications,
|
||||
),
|
||||
action: relationship.muting_notifications
|
||||
? handleUnmuteNotifications
|
||||
: handleMuteNotifications,
|
||||
},
|
||||
];
|
||||
|
||||
buttons = (
|
||||
<>
|
||||
<DropdownMenu
|
||||
items={menu}
|
||||
icon='ellipsis-h'
|
||||
iconComponent={MoreHorizIcon}
|
||||
direction='right'
|
||||
title={intl.formatMessage(messages.more)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
text={intl.formatMessage(messages.unmute)}
|
||||
onClick={handleMute}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
} else if (defaultAction === 'mute') {
|
||||
buttons = (
|
||||
<Button text={intl.formatMessage(messages.mute)} onClick={handleMute} />
|
||||
);
|
||||
} else if (defaultAction === 'block') {
|
||||
buttons = (
|
||||
<Button
|
||||
text={intl.formatMessage(messages.block)}
|
||||
onClick={handleBlock}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
buttons = <FollowButton accountId={id} />;
|
||||
}
|
||||
} else {
|
||||
buttons = <FollowButton accountId={id} />;
|
||||
}
|
||||
|
||||
let muteTimeRemaining;
|
||||
|
||||
if (account?.mute_expires_at) {
|
||||
muteTimeRemaining = (
|
||||
<>
|
||||
· <RelativeTimestamp timestamp={account.mute_expires_at} futureDate />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
let verification;
|
||||
|
||||
const firstVerifiedField = account?.fields.find((item) => !!item.verified_at);
|
||||
|
||||
if (firstVerifiedField) {
|
||||
verification = <VerifiedBadge link={firstVerifiedField.value} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames('account', { 'account--minimal': minimal })}>
|
||||
<div className='account__wrapper'>
|
||||
<Permalink
|
||||
className='account__display-name'
|
||||
title={account?.acct}
|
||||
href={account?.url}
|
||||
to={`/@${account?.acct}`}
|
||||
data-hover-card-account={id}
|
||||
>
|
||||
<div className='account__avatar-wrapper'>
|
||||
{account ? (
|
||||
<Avatar account={account} size={size} />
|
||||
) : (
|
||||
<Skeleton width={size} height={size} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='account__contents'>
|
||||
<DisplayName account={account} />
|
||||
|
||||
{!minimal && (
|
||||
<div className='account__details'>
|
||||
{account ? (
|
||||
<>
|
||||
<ShortNumber
|
||||
value={account.followers_count}
|
||||
renderer={FollowersCounter}
|
||||
/>{' '}
|
||||
{verification} {muteTimeRemaining}
|
||||
</>
|
||||
) : (
|
||||
<Skeleton width='7ch' />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Permalink>
|
||||
|
||||
{!minimal && <div className='account__relationship'>{buttons}</div>}
|
||||
</div>
|
||||
|
||||
{account &&
|
||||
withBio &&
|
||||
(account.note.length > 0 ? (
|
||||
<div
|
||||
className='account__note translate'
|
||||
dangerouslySetInnerHTML={{ __html: account.note_emojified }}
|
||||
/>
|
||||
) : (
|
||||
<div className='account__note account__note--missing'>
|
||||
<FormattedMessage
|
||||
id='account.no_bio'
|
||||
defaultMessage='No description provided.'
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -36,7 +36,7 @@ export default class AttachmentList extends ImmutablePureComponent {
|
|||
|
||||
return (
|
||||
<li key={attachment.get('id')}>
|
||||
<a href={displayUrl} target='_blank' rel='noopener noreferrer'>
|
||||
<a href={displayUrl} target='_blank' rel='noopener'>
|
||||
{compact && <Icon id='link' icon={LinkIcon} />}
|
||||
{compact && ' ' }
|
||||
{displayUrl ? filename(displayUrl) : <FormattedMessage id='attachments_list.unprocessed' defaultMessage='(unprocessed)' />}
|
||||
|
|
|
@ -1,46 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import ExpandLessIcon from '@/material-icons/400-24px/expand_less.svg?react';
|
||||
|
||||
import { IconButton } from './icon_button';
|
||||
|
||||
const messages = defineMessages({
|
||||
collapse: { id: 'status.collapse', defaultMessage: 'Collapse' },
|
||||
uncollapse: { id: 'status.uncollapse', defaultMessage: 'Uncollapse' },
|
||||
});
|
||||
|
||||
export const CollapseButton = ({ collapsed, setCollapsed }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const handleCollapsedClick = useCallback((e) => {
|
||||
if (e.button === 0) {
|
||||
setCollapsed(!collapsed);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}, [collapsed, setCollapsed]);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
className='status__collapse-button'
|
||||
animate
|
||||
active={collapsed}
|
||||
title={
|
||||
collapsed ?
|
||||
intl.formatMessage(messages.uncollapse) :
|
||||
intl.formatMessage(messages.collapse)
|
||||
}
|
||||
icon='angle-double-up'
|
||||
iconComponent={ExpandLessIcon}
|
||||
onClick={handleCollapsedClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
CollapseButton.propTypes = {
|
||||
collapsed: PropTypes.bool,
|
||||
setCollapsed: PropTypes.func.isRequired,
|
||||
};
|
|
@ -1,73 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||
|
||||
import { scrollTop } from '../scroll';
|
||||
|
||||
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
|
||||
|
||||
export default class Column extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
children: PropTypes.node,
|
||||
extraClasses: PropTypes.string,
|
||||
label: PropTypes.string,
|
||||
bindToDocument: PropTypes.bool,
|
||||
};
|
||||
|
||||
scrollTop () {
|
||||
let scrollable = null;
|
||||
|
||||
if (this.props.bindToDocument) {
|
||||
scrollable = document.scrollingElement;
|
||||
} else {
|
||||
scrollable = this.node.querySelector('.scrollable');
|
||||
}
|
||||
|
||||
if (!scrollable) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._interruptScrollAnimation = scrollTop(scrollable);
|
||||
}
|
||||
|
||||
handleWheel = () => {
|
||||
if (typeof this._interruptScrollAnimation !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
this._interruptScrollAnimation();
|
||||
};
|
||||
|
||||
setRef = c => {
|
||||
this.node = c;
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
if (this.props.bindToDocument) {
|
||||
document.addEventListener('wheel', this.handleWheel, listenerOptions);
|
||||
} else {
|
||||
this.node.addEventListener('wheel', this.handleWheel, listenerOptions);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
if (this.props.bindToDocument) {
|
||||
document.removeEventListener('wheel', this.handleWheel, listenerOptions);
|
||||
} else {
|
||||
this.node.removeEventListener('wheel', this.handleWheel, listenerOptions);
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const { label, children, extraClasses } = this.props;
|
||||
|
||||
return (
|
||||
<div role='region' aria-label={label} className={`column ${extraClasses || ''}`} ref={this.setRef}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
52
app/javascript/flavours/glitch/components/column.tsx
Normal file
52
app/javascript/flavours/glitch/components/column.tsx
Normal file
|
@ -0,0 +1,52 @@
|
|||
import { forwardRef, useRef, useImperativeHandle } from 'react';
|
||||
import type { Ref } from 'react';
|
||||
|
||||
import { scrollTop } from 'flavours/glitch/scroll';
|
||||
|
||||
export interface ColumnRef {
|
||||
scrollTop: () => void;
|
||||
node: HTMLDivElement | null;
|
||||
}
|
||||
|
||||
interface ColumnProps {
|
||||
children?: React.ReactNode;
|
||||
label?: string;
|
||||
bindToDocument?: boolean;
|
||||
}
|
||||
|
||||
export const Column = forwardRef<ColumnRef, ColumnProps>(
|
||||
({ children, label, bindToDocument }, ref: Ref<ColumnRef>) => {
|
||||
const nodeRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
node: nodeRef.current,
|
||||
|
||||
scrollTop() {
|
||||
let scrollable = null;
|
||||
|
||||
if (bindToDocument) {
|
||||
scrollable = document.scrollingElement;
|
||||
} else {
|
||||
scrollable = nodeRef.current?.querySelector('.scrollable');
|
||||
}
|
||||
|
||||
if (!scrollable) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrollTop(scrollable);
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<div role='region' aria-label={label} className='column' ref={nodeRef}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Column.displayName = 'Column';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default Column;
|
|
@ -1,17 +1,25 @@
|
|||
import type { IconName } from './media_icon';
|
||||
import { MediaIcon } from './media_icon';
|
||||
import { StatusBanner, BannerVariant } from './status_banner';
|
||||
|
||||
export const ContentWarning: React.FC<{
|
||||
text: string;
|
||||
expanded?: boolean;
|
||||
onClick?: () => void;
|
||||
icons?: React.ReactNode[];
|
||||
icons?: IconName[];
|
||||
}> = ({ text, expanded, onClick, icons }) => (
|
||||
<StatusBanner
|
||||
expanded={expanded}
|
||||
onClick={onClick}
|
||||
variant={BannerVariant.Warning}
|
||||
>
|
||||
{icons}
|
||||
{icons?.map((icon) => (
|
||||
<MediaIcon
|
||||
className='status__content__spoiler-icon'
|
||||
icon={icon}
|
||||
key={`icon-${icon}`}
|
||||
/>
|
||||
))}
|
||||
<p dangerouslySetInnerHTML={{ __html: text }} />
|
||||
</StatusBanner>
|
||||
);
|
||||
|
|
|
@ -124,7 +124,7 @@ class DropdownMenu extends PureComponent {
|
|||
|
||||
return (
|
||||
<li className={classNames('dropdown-menu__item', { 'dropdown-menu__item--dangerous': dangerous })} key={`${text}-${i}`}>
|
||||
<a href={href} target={target} data-method={method} rel='noopener noreferrer' role='button' tabIndex={0} ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onKeyPress={this.handleItemKeyPress} data-index={i}>
|
||||
<a href={href} target={target} data-method={method} rel='noopener' role='button' tabIndex={0} ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onKeyPress={this.handleItemKeyPress} data-index={i}>
|
||||
{text}
|
||||
</a>
|
||||
</li>
|
||||
|
|
|
@ -1,33 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { DisplayName } from 'flavours/glitch/components/display_name';
|
||||
import { Skeleton } from 'flavours/glitch/components/skeleton';
|
||||
|
||||
interface Props {
|
||||
size?: number;
|
||||
minimal?: boolean;
|
||||
}
|
||||
|
||||
export const EmptyAccount: React.FC<Props> = ({
|
||||
size = 46,
|
||||
minimal = false,
|
||||
}) => {
|
||||
return (
|
||||
<div className={classNames('account', { 'account--minimal': minimal })}>
|
||||
<div className='account__wrapper'>
|
||||
<div className='account__display-name'>
|
||||
<div className='account__avatar-wrapper'>
|
||||
<Skeleton width={size} height={size} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<DisplayName />
|
||||
<Skeleton width='7ch' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -98,7 +98,7 @@ export default class ErrorBoundary extends PureComponent {
|
|||
)}
|
||||
</p>
|
||||
|
||||
<p className='error-boundary__footer'>Mastodon v{version} · <a href={source_url} rel='noopener noreferrer' target='_blank'><FormattedMessage id='errors.unexpected_crash.report_issue' defaultMessage='Report issue' /></a> · <button onClick={this.handleCopyStackTrace} className={copied ? 'copied' : ''}><FormattedMessage id='errors.unexpected_crash.copy_stacktrace' defaultMessage='Copy stacktrace to clipboard' /></button></p>
|
||||
<p className='error-boundary__footer'>Mastodon v{version} · <a href={source_url} rel='noopener' target='_blank'><FormattedMessage id='errors.unexpected_crash.report_issue' defaultMessage='Report issue' /></a> · <button onClick={this.handleCopyStackTrace} className={copied ? 'copied' : ''}><FormattedMessage id='errors.unexpected_crash.copy_stacktrace' defaultMessage='Copy stacktrace to clipboard' /></button></p>
|
||||
</div>
|
||||
|
||||
<Helmet>
|
||||
|
|
|
@ -88,7 +88,7 @@ export const FollowButton: React.FC<{
|
|||
<a
|
||||
href='/settings/profile'
|
||||
target='_blank'
|
||||
rel='noreferrer noopener'
|
||||
rel='noopener'
|
||||
className='button button-secondary'
|
||||
>
|
||||
{label}
|
||||
|
|
|
@ -4,6 +4,7 @@ import { Component } from 'react';
|
|||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import type Immutable from 'immutable';
|
||||
|
||||
|
@ -11,8 +12,7 @@ import { Sparklines, SparklinesCurve } from 'react-sparklines';
|
|||
|
||||
import { ShortNumber } from 'flavours/glitch/components/short_number';
|
||||
import { Skeleton } from 'flavours/glitch/components/skeleton';
|
||||
|
||||
import { Permalink } from './permalink';
|
||||
import type { Hashtag as HashtagType } from 'flavours/glitch/models/tags';
|
||||
|
||||
interface SilentErrorBoundaryProps {
|
||||
children: React.ReactNode;
|
||||
|
@ -64,7 +64,6 @@ interface ImmutableHashtagProps {
|
|||
export const ImmutableHashtag = ({ hashtag }: ImmutableHashtagProps) => (
|
||||
<Hashtag
|
||||
name={hashtag.get('name') as string}
|
||||
href={hashtag.get('url') as string}
|
||||
to={`/tags/${hashtag.get('name') as string}`}
|
||||
people={
|
||||
(hashtag.getIn(['history', 0, 'accounts']) as number) * 1 +
|
||||
|
@ -82,11 +81,26 @@ export const ImmutableHashtag = ({ hashtag }: ImmutableHashtagProps) => (
|
|||
/>
|
||||
);
|
||||
|
||||
export const CompatibilityHashtag: React.FC<{
|
||||
hashtag: HashtagType;
|
||||
}> = ({ hashtag }) => (
|
||||
<Hashtag
|
||||
name={hashtag.name}
|
||||
to={`/tags/${hashtag.name}`}
|
||||
people={
|
||||
(hashtag.history[0].accounts as unknown as number) * 1 +
|
||||
((hashtag.history[1]?.accounts ?? 0) as unknown as number) * 1
|
||||
}
|
||||
history={hashtag.history
|
||||
.map((day) => (day.uses as unknown as number) * 1)
|
||||
.reverse()}
|
||||
/>
|
||||
);
|
||||
|
||||
export interface HashtagProps {
|
||||
className?: string;
|
||||
description?: React.ReactNode;
|
||||
history?: number[];
|
||||
href: string;
|
||||
name: string;
|
||||
people: number;
|
||||
to: string;
|
||||
|
@ -96,7 +110,6 @@ export interface HashtagProps {
|
|||
|
||||
export const Hashtag: React.FC<HashtagProps> = ({
|
||||
name,
|
||||
href,
|
||||
to,
|
||||
people,
|
||||
uses,
|
||||
|
@ -107,7 +120,7 @@ export const Hashtag: React.FC<HashtagProps> = ({
|
|||
}) => (
|
||||
<div className={classNames('trends__item', className)}>
|
||||
<div className='trends__item__name'>
|
||||
<Permalink href={href} to={to}>
|
||||
<Link to={to}>
|
||||
{name ? (
|
||||
<>
|
||||
#<span>{name}</span>
|
||||
|
@ -115,7 +128,7 @@ export const Hashtag: React.FC<HashtagProps> = ({
|
|||
) : (
|
||||
<Skeleton width={50} />
|
||||
)}
|
||||
</Permalink>
|
||||
</Link>
|
||||
|
||||
{description ? (
|
||||
<span>{description}</span>
|
||||
|
|
|
@ -107,7 +107,7 @@ class Item extends PureComponent {
|
|||
if (attachment.get('type') === 'unknown') {
|
||||
return (
|
||||
<div className={classNames('media-gallery__item', { standalone, 'media-gallery__item--tall': height === 100, 'media-gallery__item--wide': width === 100 })} key={attachment.get('id')}>
|
||||
<a className='media-gallery__item-thumbnail' href={attachment.get('remote_url') || attachment.get('url')} style={{ cursor: 'pointer' }} title={description} lang={lang} target='_blank' rel='noopener noreferrer'>
|
||||
<a className='media-gallery__item-thumbnail' href={attachment.get('remote_url') || attachment.get('url')} style={{ cursor: 'pointer' }} title={description} lang={lang} target='_blank' rel='noopener'>
|
||||
<Blurhash
|
||||
hash={attachment.get('blurhash')}
|
||||
className='media-gallery__preview'
|
||||
|
@ -139,7 +139,7 @@ class Item extends PureComponent {
|
|||
href={attachment.get('remote_url') || originalUrl}
|
||||
onClick={this.handleClick}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
rel='noopener'
|
||||
>
|
||||
<img
|
||||
className={letterbox ? 'letterbox' : null}
|
||||
|
|
55
app/javascript/flavours/glitch/components/media_icon.tsx
Normal file
55
app/javascript/flavours/glitch/components/media_icon.tsx
Normal file
|
@ -0,0 +1,55 @@
|
|||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import ImageIcon from '@/material-icons/400-24px/image.svg?react';
|
||||
import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react';
|
||||
import LinkIcon from '@/material-icons/400-24px/link.svg?react';
|
||||
import MovieIcon from '@/material-icons/400-24px/movie.svg?react';
|
||||
import MusicNoteIcon from '@/material-icons/400-24px/music_note.svg?react';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
|
||||
const messages = defineMessages({
|
||||
link: {
|
||||
id: 'status.has_preview_card',
|
||||
defaultMessage: 'Features an attached preview card',
|
||||
},
|
||||
'picture-o': {
|
||||
id: 'status.has_pictures',
|
||||
defaultMessage: 'Features attached pictures',
|
||||
},
|
||||
tasks: { id: 'status.is_poll', defaultMessage: 'This toot is a poll' },
|
||||
'video-camera': {
|
||||
id: 'status.has_video',
|
||||
defaultMessage: 'Features attached videos',
|
||||
},
|
||||
music: {
|
||||
id: 'status.has_audio',
|
||||
defaultMessage: 'Features attached audio files',
|
||||
},
|
||||
});
|
||||
|
||||
const iconComponents = {
|
||||
link: LinkIcon,
|
||||
'picture-o': ImageIcon,
|
||||
tasks: InsertChartIcon,
|
||||
'video-camera': MovieIcon,
|
||||
music: MusicNoteIcon,
|
||||
};
|
||||
|
||||
export type IconName = keyof typeof iconComponents;
|
||||
|
||||
export const MediaIcon: React.FC<{
|
||||
className?: string;
|
||||
icon: IconName;
|
||||
}> = ({ className, icon }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<Icon
|
||||
className={className}
|
||||
id={icon}
|
||||
icon={iconComponents[icon]}
|
||||
title={intl.formatMessage(messages[icon])}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,29 @@
|
|||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
|
||||
import { Permalink } from 'flavours/glitch/components/permalink';
|
||||
|
||||
export const MentionsPlaceholder = ({ status }) => {
|
||||
if (status.get('spoiler_text').length === 0 || !status.get('mentions')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='status__content'>
|
||||
{status.get('mentions').map(item => (
|
||||
<Permalink
|
||||
to={`/@${item.get('acct')}`}
|
||||
href={item.get('url')}
|
||||
key={item.get('id')}
|
||||
className='mention'
|
||||
>
|
||||
@<span>{item.get('username')}</span>
|
||||
</Permalink>
|
||||
)).reduce((aggregate, item) => [...aggregate, item, ' '], [])}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
MentionsPlaceholder.propTypes = {
|
||||
status: ImmutablePropTypes.map.isRequired,
|
||||
};
|
||||
|
|
@ -33,15 +33,10 @@ const messages = defineMessages({
|
|||
},
|
||||
});
|
||||
|
||||
const makeEmojiMap = record => record.get('emojis').reduce((obj, emoji) => {
|
||||
obj[`:${emoji.get('shortcode')}:`] = emoji.toJS();
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
class Poll extends ImmutablePureComponent {
|
||||
static propTypes = {
|
||||
identity: identityContextPropShape,
|
||||
poll: ImmutablePropTypes.map.isRequired,
|
||||
poll: ImmutablePropTypes.record.isRequired,
|
||||
status: ImmutablePropTypes.map.isRequired,
|
||||
lang: PropTypes.string,
|
||||
intl: PropTypes.object.isRequired,
|
||||
|
@ -150,7 +145,7 @@ class Poll extends ImmutablePureComponent {
|
|||
let titleHtml = option.getIn(['translation', 'titleHtml']) || option.get('titleHtml');
|
||||
|
||||
if (!titleHtml) {
|
||||
const emojiMap = makeEmojiMap(poll);
|
||||
const emojiMap = emojiMap(poll);
|
||||
titleHtml = emojify(escapeTextContentForBrowser(title), emojiMap);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,18 +0,0 @@
|
|||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import illustration from '@/images/elephant_ui_working.svg';
|
||||
|
||||
const RegenerationIndicator = () => (
|
||||
<div className='regeneration-indicator'>
|
||||
<div className='regeneration-indicator__figure'>
|
||||
<img src={illustration} alt='' />
|
||||
</div>
|
||||
|
||||
<div className='regeneration-indicator__label'>
|
||||
<FormattedMessage id='regeneration_indicator.label' tagName='strong' defaultMessage='Loading…' />
|
||||
<FormattedMessage id='regeneration_indicator.sublabel' defaultMessage='Your home feed is being prepared!' />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default RegenerationIndicator;
|
|
@ -0,0 +1,26 @@
|
|||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { GIF } from './gif';
|
||||
|
||||
export const RegenerationIndicator: React.FC = () => (
|
||||
<div className='regeneration-indicator'>
|
||||
<GIF
|
||||
src='/loading.gif'
|
||||
staticSrc='/loading.png'
|
||||
className='regeneration-indicator__figure'
|
||||
/>
|
||||
|
||||
<div className='regeneration-indicator__label'>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
id='regeneration_indicator.preparing_your_home_feed'
|
||||
defaultMessage='Preparing your home feed…'
|
||||
/>
|
||||
</strong>
|
||||
<FormattedMessage
|
||||
id='regeneration_indicator.please_stand_by'
|
||||
defaultMessage='Please stand by.'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
|
@ -8,10 +8,10 @@ import { Link } from 'react-router-dom';
|
|||
import { connect } from 'react-redux';
|
||||
|
||||
import { fetchServer } from 'flavours/glitch/actions/server';
|
||||
import { Account } from 'flavours/glitch/components/account';
|
||||
import { ServerHeroImage } from 'flavours/glitch/components/server_hero_image';
|
||||
import { ShortNumber } from 'flavours/glitch/components/short_number';
|
||||
import { Skeleton } from 'flavours/glitch/components/skeleton';
|
||||
import Account from 'flavours/glitch/containers/account_container';
|
||||
import { domain } from 'flavours/glitch/initial_state';
|
||||
|
||||
const messages = defineMessages({
|
||||
|
@ -42,7 +42,7 @@ class ServerBanner extends PureComponent {
|
|||
return (
|
||||
<div className='server-banner'>
|
||||
<div className='server-banner__introduction'>
|
||||
<FormattedMessage id='server_banner.is_one_of_many' defaultMessage='{domain} is one of the many independent Mastodon servers you can use to participate in the fediverse.' values={{ domain: <strong>{domain}</strong>, mastodon: <a href='https://joinmastodon.org' target='_blank'>Mastodon</a> }} />
|
||||
<FormattedMessage id='server_banner.is_one_of_many' defaultMessage='{domain} is one of the many independent Mastodon servers you can use to participate in the fediverse.' values={{ domain: <strong>{domain}</strong>, mastodon: <a href='https://joinmastodon.org' target='_blank' rel='noopener'>Mastodon</a> }} />
|
||||
</div>
|
||||
|
||||
<Link to='/about'>
|
||||
|
|
|
@ -9,8 +9,8 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
|||
|
||||
import { HotKeys } from 'react-hotkeys';
|
||||
|
||||
import { ContentWarning } from 'flavours/glitch/components/content_warning';
|
||||
import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder';
|
||||
import PollContainer from 'flavours/glitch/containers/poll_container';
|
||||
import NotificationOverlayContainer from 'flavours/glitch/features/notifications/containers/overlay_container';
|
||||
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
|
||||
import { autoUnfoldCW } from 'flavours/glitch/utils/content_warning';
|
||||
|
@ -25,11 +25,14 @@ import { SensitiveMediaContext } from '../features/ui/util/sensitive_media_conte
|
|||
import { displayMedia, visibleReactions } from '../initial_state';
|
||||
|
||||
import AttachmentList from './attachment_list';
|
||||
import { CollapseButton } from './collapse_button';
|
||||
import { Avatar } from './avatar';
|
||||
import { AvatarOverlay } from './avatar_overlay';
|
||||
import { DisplayName } from './display_name';
|
||||
import { getHashtagBarForStatus } from './hashtag_bar';
|
||||
import { MentionsPlaceholder } from './mentions_placeholder';
|
||||
import { Permalink } from './permalink';
|
||||
import StatusActionBar from './status_action_bar';
|
||||
import StatusContent from './status_content';
|
||||
import StatusHeader from './status_header';
|
||||
import StatusIcons from './status_icons';
|
||||
import StatusPrepend from './status_prepend';
|
||||
import StatusReactions from './status_reactions';
|
||||
|
@ -104,6 +107,7 @@ class Status extends ImmutablePureComponent {
|
|||
onEmbed: PropTypes.func,
|
||||
onHeightChange: PropTypes.func,
|
||||
onToggleHidden: PropTypes.func,
|
||||
onToggleCollapsed: PropTypes.func,
|
||||
onTranslate: PropTypes.func,
|
||||
onInteractionModal: PropTypes.func,
|
||||
muted: PropTypes.bool,
|
||||
|
@ -132,13 +136,11 @@ class Status extends ImmutablePureComponent {
|
|||
};
|
||||
|
||||
state = {
|
||||
isCollapsed: false,
|
||||
autoCollapsed: false,
|
||||
isExpanded: undefined,
|
||||
showMedia: defaultMediaVisibility(this.props.status, this.props.settings) && !(this.context?.hideMediaByDefault),
|
||||
revealBehindCW: undefined,
|
||||
showCard: false,
|
||||
forceFilter: undefined,
|
||||
showDespiteFilter: undefined,
|
||||
};
|
||||
|
||||
// Avoid checking props that are functions (and whose equality will always
|
||||
|
@ -161,19 +163,10 @@ class Status extends ImmutablePureComponent {
|
|||
|
||||
updateOnStates = [
|
||||
'isExpanded',
|
||||
'isCollapsed',
|
||||
'showMedia',
|
||||
'forceFilter',
|
||||
'showDespiteFilter',
|
||||
];
|
||||
|
||||
// If our settings have changed to disable collapsed statuses, then we
|
||||
// need to make sure that we uncollapse every one. We do that by watching
|
||||
// for changes to `settings.collapsed.enabled` in
|
||||
// `getderivedStateFromProps()`.
|
||||
|
||||
// We also need to watch for changes on the `collapse` prop---if this
|
||||
// changes to anything other than `undefined`, then we need to collapse or
|
||||
// uncollapse our status accordingly.
|
||||
static getDerivedStateFromProps(nextProps, prevState) {
|
||||
let update = {};
|
||||
let updated = false;
|
||||
|
@ -188,30 +181,12 @@ class Status extends ImmutablePureComponent {
|
|||
updated = true;
|
||||
}
|
||||
|
||||
// Update state based on new props
|
||||
if (!nextProps.settings.getIn(['collapsed', 'enabled'])) {
|
||||
if (prevState.isCollapsed) {
|
||||
update.isCollapsed = false;
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle uncollapsing toots when the shared CW state is expanded
|
||||
if (nextProps.settings.getIn(['content_warnings', 'shared_state']) &&
|
||||
nextProps.status?.get('spoiler_text')?.length && nextProps.status?.get('hidden') === false &&
|
||||
prevState.statusPropHidden !== false && prevState.isCollapsed
|
||||
) {
|
||||
update.isCollapsed = false;
|
||||
updated = true;
|
||||
}
|
||||
|
||||
// The “expanded” prop is used to one-off change the local state.
|
||||
// It's used in the thread view when unfolding/re-folding all CWs at once.
|
||||
if (nextProps.expanded !== prevState.expandedProp &&
|
||||
nextProps.expanded !== undefined
|
||||
) {
|
||||
update.isExpanded = nextProps.expanded;
|
||||
if (nextProps.expanded) update.isCollapsed = false;
|
||||
updated = true;
|
||||
}
|
||||
|
||||
|
@ -231,63 +206,13 @@ class Status extends ImmutablePureComponent {
|
|||
return updated ? update : null;
|
||||
}
|
||||
|
||||
// When mounting, we just check to see if our status should be collapsed,
|
||||
// and collapse it if so. We don't need to worry about whether collapsing
|
||||
// is enabled here, because `setCollapsed()` already takes that into
|
||||
// account.
|
||||
|
||||
// The cases where a status should be collapsed are:
|
||||
//
|
||||
// - The `collapse` prop has been set to `true`
|
||||
// - The user has decided in local settings to collapse all statuses.
|
||||
// - The user has decided to collapse all notifications ('muted'
|
||||
// statuses).
|
||||
// - The user has decided to collapse long statuses and the status is
|
||||
// over the user set value (default 400 without media, or 610px with).
|
||||
// - The status is a reply and the user has decided to collapse all
|
||||
// replies.
|
||||
// - The status contains media and the user has decided to collapse all
|
||||
// statuses with media.
|
||||
// - The status is a reblog the user has decided to collapse all
|
||||
// statuses which are reblogs.
|
||||
componentDidMount () {
|
||||
const { node } = this;
|
||||
const {
|
||||
status,
|
||||
settings,
|
||||
collapse,
|
||||
muted,
|
||||
prepend,
|
||||
} = this.props;
|
||||
|
||||
// Prevent a crash when node is undefined. Not completely sure why this
|
||||
// happens, might be because status === null.
|
||||
if (node === undefined) return;
|
||||
|
||||
const autoCollapseSettings = settings.getIn(['collapsed', 'auto']);
|
||||
|
||||
// Don't autocollapse if CW state is shared and status is explicitly revealed,
|
||||
// as it could cause surprising changes when receiving notifications
|
||||
if (settings.getIn(['content_warnings', 'shared_state']) && status.get('spoiler_text').length && !status.get('hidden')) return;
|
||||
|
||||
let autoCollapseHeight = parseInt(autoCollapseSettings.get('height'));
|
||||
if (status.get('media_attachments').size && !muted) {
|
||||
autoCollapseHeight += 210;
|
||||
}
|
||||
|
||||
if (collapse ||
|
||||
autoCollapseSettings.get('all') ||
|
||||
(autoCollapseSettings.get('notifications') && muted) ||
|
||||
(autoCollapseSettings.get('lengthy') && node.clientHeight > autoCollapseHeight) ||
|
||||
(autoCollapseSettings.get('reblogs') && prepend === 'reblogged_by') ||
|
||||
(autoCollapseSettings.get('replies') && status.get('in_reply_to_id', null) !== null) ||
|
||||
(autoCollapseSettings.get('media') && !(status.get('spoiler_text').length) && status.get('media_attachments').size > 0)
|
||||
) {
|
||||
this.setCollapsed(true);
|
||||
// Hack to fix timeline jumps on second rendering when auto-collapsing
|
||||
this.setState({ autoCollapsed: true });
|
||||
}
|
||||
|
||||
// Hack to fix timeline jumps when a preview card is fetched
|
||||
this.setState({
|
||||
showCard: !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card') && this.props.settings.get('inline_preview_cards'),
|
||||
|
@ -302,16 +227,15 @@ class Status extends ImmutablePureComponent {
|
|||
const { muted, hidden, status, settings } = this.props;
|
||||
|
||||
const doShowCard = !muted && !hidden && status && status.get('card') && settings.get('inline_preview_cards');
|
||||
if (this.state.autoCollapsed || (doShowCard && !this.state.showCard)) {
|
||||
if (doShowCard && !this.state.showCard) {
|
||||
if (doShowCard) this.setState({ showCard: true });
|
||||
if (this.state.autoCollapsed) this.setState({ autoCollapsed: false });
|
||||
return this.props.getScrollPosition();
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState, snapshot) {
|
||||
componentDidUpdate(prevProps, _prevState, snapshot) {
|
||||
if (snapshot !== null && this.props.updateScrollBottom && this.node.offsetTop < snapshot.top) {
|
||||
this.props.updateScrollBottom(snapshot.height - snapshot.top);
|
||||
}
|
||||
|
@ -324,7 +248,7 @@ class Status extends ImmutablePureComponent {
|
|||
if (this.props.status?.get('id') !== prevProps.status?.get('id')) {
|
||||
this.setState({
|
||||
showMedia: defaultMediaVisibility(this.props.status, this.props.settings) && !(this.context?.hideMediaByDefault),
|
||||
forceFilter: undefined,
|
||||
showDespiteFilter: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -340,72 +264,33 @@ class Status extends ImmutablePureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
// `setCollapsed()` sets the value of `isCollapsed` in our state, that is,
|
||||
// whether the toot is collapsed or not.
|
||||
|
||||
// `setCollapsed()` automatically checks for us whether toot collapsing
|
||||
// is enabled, so we don't have to.
|
||||
setCollapsed = (value) => {
|
||||
if (this.props.settings.getIn(['collapsed', 'enabled'])) {
|
||||
if (value) {
|
||||
this.setExpansion(false);
|
||||
}
|
||||
this.setState({ isCollapsed: value });
|
||||
} else {
|
||||
this.setState({ isCollapsed: false });
|
||||
}
|
||||
};
|
||||
|
||||
setExpansion = (value) => {
|
||||
if (this.props.settings.getIn(['content_warnings', 'shared_state']) && this.props.status.get('hidden') === value) {
|
||||
this.props.onToggleHidden(this.props.status);
|
||||
}
|
||||
|
||||
this.setState({ isExpanded: value });
|
||||
if (value) {
|
||||
this.setCollapsed(false);
|
||||
}
|
||||
};
|
||||
|
||||
// `parseClick()` takes a click event and responds appropriately.
|
||||
// If our status is collapsed, then clicking on it should uncollapse it.
|
||||
// If `Shift` is held, then clicking on it should collapse it.
|
||||
// Otherwise, we open the url handed to us in `destination`, if
|
||||
// applicable.
|
||||
parseClick = (e, destination) => {
|
||||
const { status, history } = this.props;
|
||||
const { isCollapsed } = this.state;
|
||||
if (!history) return;
|
||||
|
||||
if (e.button !== 0 || e.ctrlKey || e.altKey || e.metaKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCollapsed) this.setCollapsed(false);
|
||||
else if (e.shiftKey) {
|
||||
this.setCollapsed(true);
|
||||
document.getSelection().removeAllRanges();
|
||||
} else if (this.props.onClick) {
|
||||
this.props.onClick();
|
||||
return;
|
||||
} else {
|
||||
if (destination === undefined) {
|
||||
destination = `/@${
|
||||
status.getIn(['reblog', 'account', 'acct'], status.getIn(['account', 'acct']))
|
||||
}/${
|
||||
status.getIn(['reblog', 'id'], status.get('id'))
|
||||
}`;
|
||||
}
|
||||
history.push(destination);
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
handleToggleMediaVisibility = () => {
|
||||
this.setState({ showMedia: !this.state.showMedia });
|
||||
};
|
||||
|
||||
handleClick = e => {
|
||||
e.preventDefault();
|
||||
this.handleHotkeyOpen(e);
|
||||
};
|
||||
|
||||
handleMouseUp = e => {
|
||||
// Only handle clicks on the empty space above the content
|
||||
|
||||
if (e.target !== e.currentTarget && e.detail >= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleClick(e);
|
||||
};
|
||||
|
||||
handleExpandedToggle = () => {
|
||||
if (this.props.settings.getIn(['content_warnings', 'shared_state'])) {
|
||||
this.props.onToggleHidden(this.props.status);
|
||||
|
@ -470,13 +355,41 @@ class Status extends ImmutablePureComponent {
|
|||
this.props.onMention(this.props.status.get('account'));
|
||||
};
|
||||
|
||||
handleHotkeyOpen = () => {
|
||||
handleHotkeyOpen = (e) => {
|
||||
if (this.props.onClick) {
|
||||
this.props.onClick();
|
||||
return;
|
||||
}
|
||||
|
||||
const { history } = this.props;
|
||||
const status = this.props.status;
|
||||
this.props.history.push(`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`);
|
||||
|
||||
if (!history) {
|
||||
return;
|
||||
}
|
||||
|
||||
const path = `/@${status.getIn(['account', 'acct'])}/${status.get('id')}`;
|
||||
|
||||
if (e?.button === 1 || (e?.button === 0 && (e?.ctrlKey || e?.metaKey))) {
|
||||
window.open(path, '_blank', 'noopener');
|
||||
} else {
|
||||
history.push(path);
|
||||
}
|
||||
};
|
||||
|
||||
handleHotkeyOpenProfile = () => {
|
||||
this.props.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`);
|
||||
this._openProfile();
|
||||
};
|
||||
|
||||
_openProfile = () => {
|
||||
const { history } = this.props;
|
||||
const status = this.props.status;
|
||||
|
||||
if (!history) {
|
||||
return;
|
||||
}
|
||||
|
||||
history.push(`/@${status.getIn(['account', 'acct'])}`);
|
||||
};
|
||||
|
||||
handleHotkeyMoveUp = e => {
|
||||
|
@ -487,30 +400,27 @@ class Status extends ImmutablePureComponent {
|
|||
this.props.onMoveDown(this.props.containerId || this.props.id, e.target.getAttribute('data-featured'));
|
||||
};
|
||||
|
||||
handleHotkeyCollapse = () => {
|
||||
if (!this.props.settings.getIn(['collapsed', 'enabled']))
|
||||
return;
|
||||
|
||||
this.setCollapsed(!this.state.isCollapsed);
|
||||
};
|
||||
|
||||
handleHotkeyToggleSensitive = () => {
|
||||
this.handleToggleMediaVisibility();
|
||||
};
|
||||
|
||||
handleUnfilterClick = e => {
|
||||
this.setState({ forceFilter: false });
|
||||
this.setState({ showDespiteFilter: false });
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
handleFilterClick = () => {
|
||||
this.setState({ forceFilter: true });
|
||||
this.setState({ showDespiteFilter: true });
|
||||
};
|
||||
|
||||
handleRef = c => {
|
||||
this.node = c;
|
||||
};
|
||||
|
||||
handleCollapsedToggle = isCollapsed => {
|
||||
this.props.onToggleCollapsed(this.props.status, isCollapsed);
|
||||
};
|
||||
|
||||
handleTranslate = () => {
|
||||
this.props.onTranslate(this.props.status);
|
||||
};
|
||||
|
@ -530,16 +440,10 @@ class Status extends ImmutablePureComponent {
|
|||
render () {
|
||||
const { intl, hidden, featured, unfocusable, unread, pictureInPicture, previousId, nextInReplyToId, rootId, skipPrepend, avatarSize = 46 } = this.props;
|
||||
|
||||
const {
|
||||
parseClick,
|
||||
setCollapsed,
|
||||
} = this;
|
||||
|
||||
const {
|
||||
status,
|
||||
account,
|
||||
settings,
|
||||
collapsed,
|
||||
muted,
|
||||
intersectionObserverWrapper,
|
||||
onOpenVideo,
|
||||
|
@ -549,30 +453,18 @@ class Status extends ImmutablePureComponent {
|
|||
identity,
|
||||
...other
|
||||
} = this.props;
|
||||
const { isCollapsed } = this.state;
|
||||
let background = null;
|
||||
let attachments = null;
|
||||
|
||||
// Depending on user settings, some media are considered as parts of the
|
||||
// contents (affected by CW) while other will be displayed outside of the
|
||||
// CW.
|
||||
let contentMedia = [];
|
||||
let contentMediaIcons = [];
|
||||
let extraMedia = [];
|
||||
let extraMediaIcons = [];
|
||||
let media = contentMedia;
|
||||
let mediaIcons = contentMediaIcons;
|
||||
|
||||
if (settings.getIn(['content_warnings', 'media_outside'])) {
|
||||
media = extraMedia;
|
||||
mediaIcons = extraMediaIcons;
|
||||
}
|
||||
let media = [];
|
||||
let mediaIcons = [];
|
||||
let statusAvatar;
|
||||
|
||||
if (status === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isExpanded = settings.getIn(['content_warnings', 'shared_state']) ? !status.get('hidden') : this.state.isExpanded;
|
||||
const expanded = isExpanded || status.get('spoiler_text').length === 0;
|
||||
|
||||
const handlers = {
|
||||
reply: this.handleHotkeyReply,
|
||||
|
@ -585,9 +477,9 @@ class Status extends ImmutablePureComponent {
|
|||
moveDown: this.handleHotkeyMoveDown,
|
||||
toggleHidden: this.handleExpandedToggle,
|
||||
bookmark: this.handleHotkeyBookmark,
|
||||
toggleCollapse: this.handleHotkeyCollapse,
|
||||
toggleSensitive: this.handleHotkeyToggleSensitive,
|
||||
openMedia: this.handleHotkeyOpenMedia,
|
||||
onTranslate: this.handleTranslate,
|
||||
};
|
||||
|
||||
let prepend, rebloggedByText;
|
||||
|
@ -603,13 +495,13 @@ class Status extends ImmutablePureComponent {
|
|||
<div ref={this.handleRef} className='status focusable' tabIndex={unfocusable ? null : 0}>
|
||||
<span>{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}</span>
|
||||
{status.get('spoiler_text').length > 0 && (<span>{status.get('spoiler_text')}</span>)}
|
||||
{isExpanded && <span>{status.get('content')}</span>}
|
||||
{expanded && <span>{status.get('content')}</span>}
|
||||
</div>
|
||||
</HotKeys>
|
||||
);
|
||||
}
|
||||
|
||||
if (this.state.forceFilter === undefined ? matchedFilters : this.state.forceFilter) {
|
||||
if (this.state.showDespiteFilter === undefined ? matchedFilters : this.state.showDespiteFilter) {
|
||||
const minHandlers = this.props.muted ? {} : {
|
||||
moveUp: this.handleHotkeyMoveUp,
|
||||
moveDown: this.handleHotkeyMoveDown,
|
||||
|
@ -628,21 +520,10 @@ class Status extends ImmutablePureComponent {
|
|||
);
|
||||
}
|
||||
|
||||
// If user backgrounds for collapsed statuses are enabled, then we
|
||||
// initialize our background accordingly. This will only be rendered if
|
||||
// the status is collapsed.
|
||||
if (settings.getIn(['collapsed', 'backgrounds', 'user_backgrounds'])) {
|
||||
background = status.getIn(['account', 'header']);
|
||||
}
|
||||
|
||||
// This handles our media attachments.
|
||||
// If a media file is of unknwon type or if the status is muted
|
||||
// (notification), we show a list of links instead of embedded media.
|
||||
|
||||
// After we have generated our appropriate media element and stored it in
|
||||
// `media`, we snatch the thumbnail to use as our `background` if media
|
||||
// backgrounds for collapsed statuses are enabled.
|
||||
|
||||
attachments = status.get('media_attachments');
|
||||
|
||||
if (pictureInPicture.get('inUse')) {
|
||||
|
@ -668,7 +549,7 @@ class Status extends ImmutablePureComponent {
|
|||
sensitive={status.get('sensitive')}
|
||||
letterbox={settings.getIn(['media', 'letterbox'])}
|
||||
fullwidth={!rootId && settings.getIn(['media', 'fullwidth'])}
|
||||
hidden={isCollapsed || !isExpanded}
|
||||
hidden={!expanded}
|
||||
onOpenMedia={this.handleOpenMedia}
|
||||
cacheWidth={this.props.cacheMediaWidth}
|
||||
defaultWidth={this.props.cachedMediaWidth}
|
||||
|
@ -725,7 +606,7 @@ class Status extends ImmutablePureComponent {
|
|||
sensitive={status.get('sensitive')}
|
||||
letterbox={settings.getIn(['media', 'letterbox'])}
|
||||
fullwidth={!rootId && settings.getIn(['media', 'fullwidth'])}
|
||||
preventPlayback={isCollapsed || !isExpanded}
|
||||
preventPlayback={!expanded}
|
||||
onOpenVideo={this.handleOpenVideo}
|
||||
deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined}
|
||||
visible={this.state.showMedia}
|
||||
|
@ -735,10 +616,6 @@ class Status extends ImmutablePureComponent {
|
|||
);
|
||||
mediaIcons.push('video-camera');
|
||||
}
|
||||
|
||||
if (!status.get('sensitive') && !(status.get('spoiler_text').length > 0) && settings.getIn(['collapsed', 'backgrounds', 'preview_images'])) {
|
||||
background = attachments.getIn([0, 'preview_url']);
|
||||
}
|
||||
} else if (status.get('card') && settings.get('inline_preview_cards') && !this.props.muted) {
|
||||
media.push(
|
||||
<Card
|
||||
|
@ -751,9 +628,7 @@ class Status extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
if (status.get('poll')) {
|
||||
const language = status.getIn(['translation', 'language']) || status.get('language');
|
||||
contentMedia.push(<PollContainer pollId={status.get('poll')} status={status} lang={language} />);
|
||||
contentMediaIcons.push('tasks');
|
||||
mediaIcons.push('tasks');
|
||||
}
|
||||
|
||||
// Here we prepare extra data-* attributes for CSS selectors.
|
||||
|
@ -777,15 +652,8 @@ class Status extends ImmutablePureComponent {
|
|||
<StatusPrepend
|
||||
type={this.props.prepend}
|
||||
account={account}
|
||||
parseClick={parseClick}
|
||||
notificationId={this.props.notificationId}
|
||||
>
|
||||
{muted && settings.getIn(['collapsed', 'enabled']) && (
|
||||
<div className='notification__message-collapse-button'>
|
||||
<CollapseButton collapsed={isCollapsed} setCollapsed={setCollapsed} />
|
||||
</div>
|
||||
)}
|
||||
</StatusPrepend>
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -793,13 +661,18 @@ class Status extends ImmutablePureComponent {
|
|||
rebloggedByText = intl.formatMessage({ id: 'status.reblogged_by', defaultMessage: '{name} boosted' }, { name: account.get('acct') });
|
||||
}
|
||||
|
||||
if (account === undefined || account === null) {
|
||||
statusAvatar = <Avatar account={status.get('account')} size={avatarSize} />;
|
||||
} else {
|
||||
statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />;
|
||||
}
|
||||
|
||||
const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status);
|
||||
contentMedia.push(hashtagBar);
|
||||
|
||||
return (
|
||||
<HotKeys handlers={handlers} tabIndex={unfocusable ? null : -1}>
|
||||
<div
|
||||
className={classNames('status__wrapper', 'focusable', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, collapsed: isCollapsed })}
|
||||
className={classNames('status__wrapper', 'focusable', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread })}
|
||||
{...selectorAttribs}
|
||||
tabIndex={unfocusable ? null : 0}
|
||||
data-featured={featured ? 'true' : null}
|
||||
|
@ -810,47 +683,52 @@ class Status extends ImmutablePureComponent {
|
|||
{!skipPrepend && prepend}
|
||||
|
||||
<div
|
||||
className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), 'status--in-thread': !!rootId, 'status--first-in-thread': previousId && (!connectUp || connectToRoot), muted: this.props.muted, 'has-background': isCollapsed && background })}
|
||||
className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), 'status--in-thread': !!rootId, 'status--first-in-thread': previousId && (!connectUp || connectToRoot), muted: this.props.muted })}
|
||||
data-id={status.get('id')}
|
||||
style={isCollapsed && background ? { backgroundImage: `url(${background})` } : null}
|
||||
>
|
||||
{(connectReply || connectUp || connectToRoot) && <div className={classNames('status__line', { 'status__line--full': connectReply, 'status__line--first': !status.get('in_reply_to_id') && !connectToRoot })} />}
|
||||
|
||||
{(!muted || !isCollapsed) && (
|
||||
/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */
|
||||
<header onClick={this.parseClick} className='status__info'>
|
||||
<StatusHeader
|
||||
status={status}
|
||||
friend={account}
|
||||
collapsed={isCollapsed}
|
||||
parseClick={parseClick}
|
||||
avatarSize={avatarSize}
|
||||
/>
|
||||
{(!muted) && (
|
||||
<header onMouseUp={this.handleMouseUp} className='status__info'>
|
||||
<Permalink href={status.getIn(['account', 'url'])} to={`/@${status.getIn(['account', 'acct'])}`} title={status.getIn(['account', 'acct'])} data-hover-card-account={status.getIn(['account', 'id'])} className='status__display-name'>
|
||||
<div className='status__avatar'>
|
||||
{statusAvatar}
|
||||
</div>
|
||||
|
||||
<DisplayName account={status.get('account')} />
|
||||
</Permalink>
|
||||
<StatusIcons
|
||||
status={status}
|
||||
mediaIcons={contentMediaIcons.concat(extraMediaIcons)}
|
||||
collapsible={!muted && settings.getIn(['collapsed', 'enabled'])}
|
||||
collapsed={isCollapsed}
|
||||
setCollapsed={setCollapsed}
|
||||
mediaIcons={mediaIcons}
|
||||
settings={settings.get('status_icons')}
|
||||
/>
|
||||
</header>
|
||||
)}
|
||||
|
||||
{status.get('spoiler_text').length > 0 && <ContentWarning text={status.getIn(['translation', 'spoilerHtml']) || status.get('spoilerHtml')} expanded={expanded} onClick={this.handleExpandedToggle} icons={mediaIcons} />}
|
||||
|
||||
{expanded && (
|
||||
<>
|
||||
<StatusContent
|
||||
status={status}
|
||||
media={contentMedia}
|
||||
extraMedia={extraMedia}
|
||||
mediaIcons={contentMediaIcons}
|
||||
expanded={isExpanded}
|
||||
onExpandedToggle={this.handleExpandedToggle}
|
||||
onClick={this.handleClick}
|
||||
onTranslate={this.handleTranslate}
|
||||
parseClick={parseClick}
|
||||
disabled={!history}
|
||||
collapsible
|
||||
media={media}
|
||||
onCollapsedToggle={this.handleCollapsedToggle}
|
||||
tagLinks={settings.get('tag_misleading_links')}
|
||||
rewriteMentions={settings.get('rewrite_mentions')}
|
||||
{...statusContentProps}
|
||||
/>
|
||||
|
||||
{media}
|
||||
{hashtagBar}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* This is a glitch-soc addition to have a placeholder */}
|
||||
{!expanded && <MentionsPlaceholder status={status} />}
|
||||
|
||||
<StatusReactions
|
||||
statusId={status.get('id')}
|
||||
reactions={status.get('reactions')}
|
||||
|
@ -860,7 +738,6 @@ class Status extends ImmutablePureComponent {
|
|||
canReact={this.props.identity.signedIn}
|
||||
/>
|
||||
|
||||
{(!isCollapsed || !(muted || !settings.getIn(['collapsed', 'show_action_bar']))) && (
|
||||
<StatusActionBar
|
||||
status={status}
|
||||
account={status.get('account')}
|
||||
|
@ -868,7 +745,7 @@ class Status extends ImmutablePureComponent {
|
|||
onFilter={matchedFilters ? this.handleFilterClick : null}
|
||||
{...other}
|
||||
/>
|
||||
)}
|
||||
|
||||
{notification && (
|
||||
<NotificationOverlayContainer
|
||||
notification={notification}
|
||||
|
|
|
@ -52,7 +52,9 @@ const messages = defineMessages({
|
|||
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
|
||||
favourite: { id: 'status.favourite', defaultMessage: 'Favorite' },
|
||||
react: { id: 'status.react', defaultMessage: 'React' },
|
||||
removeFavourite: { id: 'status.remove_favourite', defaultMessage: 'Remove from favorites' },
|
||||
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
|
||||
removeBookmark: { id: 'status.remove_bookmark', defaultMessage: 'Remove bookmark' },
|
||||
open: { id: 'status.open', defaultMessage: 'Expand this status' },
|
||||
report: { id: 'status.report', defaultMessage: 'Report @{name}' },
|
||||
muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
|
||||
|
@ -339,6 +341,8 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
);
|
||||
|
||||
const canReact = permissions && status.get('reactions').filter(r => r.get('count') > 0 && r.get('me')).size < maxReactions;
|
||||
const bookmarkTitle = intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark);
|
||||
const favouriteTitle = intl.formatMessage(status.get('favourited') ? messages.removeFavourite : messages.favourite);
|
||||
|
||||
return (
|
||||
<div className='status__action-bar'>
|
||||
|
@ -354,27 +358,19 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
/>
|
||||
</div>
|
||||
<div className='status__action-bar__button-wrapper'>
|
||||
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })}
|
||||
disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle}
|
||||
icon={reblogIcon} iconComponent={reblogIconComponent} onClick={this.handleReblogClick}
|
||||
counter={withCounters ? status.get('reblogs_count') : undefined} />
|
||||
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon={reblogIcon} iconComponent={reblogIconComponent} onClick={this.handleReblogClick} counter={withCounters ? status.get('reblogs_count') : undefined} />
|
||||
</div>
|
||||
<div className='status__action-bar__button-wrapper'>
|
||||
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')}
|
||||
title={intl.formatMessage(messages.favourite)} icon='star'
|
||||
iconComponent={status.get('favourited') ? StarIcon : StarBorderIcon}
|
||||
onClick={this.handleFavouriteClick}
|
||||
counter={withCounters ? status.get('favourites_count') : undefined} />
|
||||
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' iconComponent={status.get('favourited') ? StarIcon : StarBorderIcon} onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} />
|
||||
</div>
|
||||
<div className='status__action-bar__button-wrapper'>
|
||||
<EmojiPickerDropdown className='status__action-bar-button' onPickEmoji={this.handleEmojiPick}
|
||||
title={intl.formatMessage(messages.react)} icon={AddReactionIcon} disabled={!canReact} />
|
||||
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} title={favouriteTitle} icon='star' iconComponent={status.get('favourited') ? StarIcon : StarBorderIcon} onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} />
|
||||
</div>
|
||||
<div className='status__action-bar__button-wrapper'>
|
||||
<IconButton className='status__action-bar-button bookmark-icon' disabled={!signedIn}
|
||||
active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark'
|
||||
iconComponent={status.get('bookmarked') ? BookmarkIcon : BookmarkBorderIcon}
|
||||
onClick={this.handleBookmarkClick} />
|
||||
<EmojiPickerDropdown className='status__action-bar-button' onPickEmoji={this.handleEmojiPick} title={intl.formatMessage(messages.react)} icon={AddReactionIcon} disabled={!canReact} />
|
||||
</div>
|
||||
<div className='status__action-bar__button-wrapper'>
|
||||
<IconButton className='status__action-bar-button bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={bookmarkTitle} icon='bookmark' iconComponent={status.get('bookmarked') ? BookmarkIcon : BookmarkBorderIcon} onClick={this.handleBookmarkClick} />
|
||||
</div>
|
||||
|
||||
{filterButton}
|
||||
|
|
|
@ -9,19 +9,14 @@ import { withRouter } from 'react-router-dom';
|
|||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import ImageIcon from '@/material-icons/400-24px/image.svg?react';
|
||||
import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react';
|
||||
import LinkIcon from '@/material-icons/400-24px/link.svg?react';
|
||||
import MovieIcon from '@/material-icons/400-24px/movie.svg?react';
|
||||
import MusicNoteIcon from '@/material-icons/400-24px/music_note.svg?react';
|
||||
import { ContentWarning } from 'flavours/glitch/components/content_warning';
|
||||
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import PollContainer from 'flavours/glitch/containers/poll_container';
|
||||
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
|
||||
import { autoPlayGif, languages as preloadedLanguages } from 'flavours/glitch/initial_state';
|
||||
import { decode as decodeIDNA } from 'flavours/glitch/utils/idna';
|
||||
|
||||
|
||||
import { Permalink } from './permalink';
|
||||
const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
|
||||
|
||||
const textMatchesTarget = (text, origin, host) => {
|
||||
return (text === origin || text === host
|
||||
|
@ -133,16 +128,10 @@ class StatusContent extends PureComponent {
|
|||
identity: identityContextPropShape,
|
||||
status: ImmutablePropTypes.map.isRequired,
|
||||
statusContent: PropTypes.string,
|
||||
expanded: PropTypes.bool,
|
||||
collapsed: PropTypes.bool,
|
||||
onExpandedToggle: PropTypes.func,
|
||||
onTranslate: PropTypes.func,
|
||||
media: PropTypes.node,
|
||||
extraMedia: PropTypes.node,
|
||||
mediaIcons: PropTypes.arrayOf(PropTypes.string),
|
||||
parseClick: PropTypes.func,
|
||||
disabled: PropTypes.bool,
|
||||
onUpdate: PropTypes.func,
|
||||
onClick: PropTypes.func,
|
||||
collapsible: PropTypes.bool,
|
||||
onCollapsedToggle: PropTypes.func,
|
||||
tagLinks: PropTypes.bool,
|
||||
rewriteMentions: PropTypes.string,
|
||||
languages: ImmutablePropTypes.map,
|
||||
|
@ -158,28 +147,29 @@ class StatusContent extends PureComponent {
|
|||
rewriteMentions: 'no',
|
||||
};
|
||||
|
||||
state = {
|
||||
hidden: true,
|
||||
};
|
||||
|
||||
_updateStatusLinks () {
|
||||
const node = this.contentsNode;
|
||||
const node = this.node;
|
||||
const { tagLinks, rewriteMentions } = this.props;
|
||||
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { status, onCollapsedToggle } = this.props;
|
||||
const links = node.querySelectorAll('a');
|
||||
|
||||
let link, mention;
|
||||
|
||||
for (var i = 0; i < links.length; ++i) {
|
||||
let link = links[i];
|
||||
link = links[i];
|
||||
|
||||
if (link.classList.contains('status-link')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
link.classList.add('status-link');
|
||||
|
||||
let mention = this.props.status.get('mentions').find(item => link.href === item.get('url'));
|
||||
mention = this.props.status.get('mentions').find(item => link.href === item.get('url'));
|
||||
|
||||
if (mention) {
|
||||
link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
|
||||
|
@ -195,7 +185,6 @@ class StatusContent extends PureComponent {
|
|||
} else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
|
||||
link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
|
||||
} else {
|
||||
link.addEventListener('click', this.onLinkClick.bind(this), false);
|
||||
link.setAttribute('title', link.href);
|
||||
link.classList.add('unhandled-link');
|
||||
|
||||
|
@ -228,6 +217,18 @@ class StatusContent extends PureComponent {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (status.get('collapsed', null) === null && onCollapsedToggle) {
|
||||
const { collapsible, onClick } = this.props;
|
||||
|
||||
const collapsed =
|
||||
collapsible
|
||||
&& onClick
|
||||
&& node.clientHeight > MAX_HEIGHT
|
||||
&& status.get('spoiler_text').length === 0;
|
||||
|
||||
onCollapsedToggle(collapsed);
|
||||
}
|
||||
}
|
||||
|
||||
handleMouseEnter = ({ currentTarget }) => {
|
||||
|
@ -262,26 +263,21 @@ class StatusContent extends PureComponent {
|
|||
|
||||
componentDidUpdate () {
|
||||
this._updateStatusLinks();
|
||||
if (this.props.onUpdate) this.props.onUpdate();
|
||||
}
|
||||
|
||||
onLinkClick = (e) => {
|
||||
if (this.props.collapsed) {
|
||||
if (this.props.parseClick) this.props.parseClick(e);
|
||||
}
|
||||
};
|
||||
|
||||
onMentionClick = (mention, e) => {
|
||||
if (this.props.parseClick) {
|
||||
this.props.parseClick(e, `/@${mention.get('acct')}`);
|
||||
if (this.props.history && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
this.props.history.push(`/@${mention.get('acct')}`);
|
||||
}
|
||||
};
|
||||
|
||||
onHashtagClick = (hashtag, e) => {
|
||||
hashtag = hashtag.replace(/^#/, '');
|
||||
|
||||
if (this.props.parseClick) {
|
||||
this.props.parseClick(e, `/tags/${hashtag}`);
|
||||
if (this.props.history && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
this.props.history.push(`/tags/${hashtag}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -290,9 +286,7 @@ class StatusContent extends PureComponent {
|
|||
};
|
||||
|
||||
handleMouseUp = (e) => {
|
||||
const { parseClick, disabled } = this.props;
|
||||
|
||||
if (disabled || !this.startXY) {
|
||||
if (!this.startXY) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -307,168 +301,70 @@ class StatusContent extends PureComponent {
|
|||
element = element.parentNode;
|
||||
}
|
||||
|
||||
if (deltaX + deltaY < 5 && e.button === 0 && parseClick) {
|
||||
parseClick(e);
|
||||
if (deltaX + deltaY < 5 && (e.button === 0 || e.button === 1) && e.detail >= 1 && this.props.onClick) {
|
||||
this.props.onClick(e);
|
||||
}
|
||||
|
||||
this.startXY = null;
|
||||
};
|
||||
|
||||
handleSpoilerClick = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (this.props.onExpandedToggle) {
|
||||
this.props.onExpandedToggle();
|
||||
} else {
|
||||
this.setState({ hidden: !this.state.hidden });
|
||||
}
|
||||
};
|
||||
|
||||
handleTranslate = () => {
|
||||
this.props.onTranslate();
|
||||
};
|
||||
|
||||
setContentsRef = (c) => {
|
||||
this.contentsNode = c;
|
||||
setRef = (c) => {
|
||||
this.node = c;
|
||||
};
|
||||
|
||||
render () {
|
||||
const {
|
||||
status,
|
||||
media,
|
||||
extraMedia,
|
||||
mediaIcons,
|
||||
parseClick,
|
||||
disabled,
|
||||
tagLinks,
|
||||
rewriteMentions,
|
||||
intl,
|
||||
statusContent,
|
||||
} = this.props;
|
||||
const { status, intl, statusContent } = this.props;
|
||||
|
||||
const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
|
||||
const renderReadMore = this.props.onClick && status.get('collapsed');
|
||||
const contentLocale = intl.locale.replace(/[_-].*/, '');
|
||||
const targetLanguages = this.props.languages?.get(status.get('language') || 'und');
|
||||
const renderTranslate = this.props.onTranslate && this.props.identity.signedIn && ['public', 'unlisted'].includes(status.get('visibility')) && status.get('search_index').trim().length > 0 && targetLanguages?.includes(contentLocale);
|
||||
|
||||
const content = { __html: statusContent ?? getStatusContent(status) };
|
||||
const spoilerHtml = status.getIn(['translation', 'spoilerHtml']) || status.get('spoilerHtml');
|
||||
const language = status.getIn(['translation', 'language']) || status.get('language');
|
||||
const classNames = classnames('status__content', {
|
||||
'status__content--with-action': parseClick && !disabled,
|
||||
'status__content--with-spoiler': status.get('spoiler_text').length > 0,
|
||||
'status__content--with-action': this.props.onClick && this.props.history,
|
||||
'status__content--collapsed': renderReadMore,
|
||||
});
|
||||
|
||||
const readMoreButton = renderReadMore && (
|
||||
<button className='status__content__read-more-button' onClick={this.props.onClick} key='read-more'>
|
||||
<FormattedMessage id='status.read_more' defaultMessage='Read more' /><Icon id='angle-right' icon={ChevronRightIcon} />
|
||||
</button>
|
||||
);
|
||||
|
||||
const translateButton = renderTranslate && (
|
||||
<TranslateButton onClick={this.handleTranslate} translation={status.get('translation')} />
|
||||
);
|
||||
|
||||
if (status.get('spoiler_text').length > 0) {
|
||||
let mentionsPlaceholder = '';
|
||||
|
||||
const mentionLinks = status.get('mentions').map(item => (
|
||||
<Permalink
|
||||
to={`/@${item.get('acct')}`}
|
||||
href={item.get('url')}
|
||||
key={item.get('id')}
|
||||
className='mention'
|
||||
>
|
||||
@<span>{item.get('username')}</span>
|
||||
</Permalink>
|
||||
)).reduce((aggregate, item) => [...aggregate, item, ' '], []);
|
||||
|
||||
let spoilerIcons = [];
|
||||
if (mediaIcons) {
|
||||
const mediaComponents = {
|
||||
'link': LinkIcon,
|
||||
'picture-o': ImageIcon,
|
||||
'tasks': InsertChartIcon,
|
||||
'video-camera': MovieIcon,
|
||||
'music': MusicNoteIcon,
|
||||
};
|
||||
|
||||
spoilerIcons = mediaIcons.map((mediaIcon) => (
|
||||
<Icon
|
||||
fixedWidth
|
||||
className='status__content__spoiler-icon'
|
||||
id={mediaIcon}
|
||||
icon={mediaComponents[mediaIcon]}
|
||||
aria-hidden='true'
|
||||
key={`icon-${mediaIcon}`}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
if (hidden) {
|
||||
mentionsPlaceholder = <div>{mentionLinks}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames} tabIndex={0} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
|
||||
<ContentWarning text={spoilerHtml} expanded={!hidden} onClick={this.handleSpoilerClick} icons={spoilerIcons} />
|
||||
|
||||
{mentionsPlaceholder}
|
||||
|
||||
<div className={`status__content__spoiler ${!hidden ? 'status__content__spoiler--visible' : ''}`}>
|
||||
<div
|
||||
ref={this.setContentsRef}
|
||||
key={`contents-${tagLinks}`}
|
||||
tabIndex={!hidden ? 0 : null}
|
||||
dangerouslySetInnerHTML={content}
|
||||
className='status__content__text translate'
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
lang={language}
|
||||
/>
|
||||
{!hidden && translateButton}
|
||||
{media}
|
||||
</div>
|
||||
|
||||
{extraMedia}
|
||||
</div>
|
||||
const poll = !!status.get('poll') && (
|
||||
<PollContainer pollId={status.get('poll')} status={status} lang={language} />
|
||||
);
|
||||
} else if (parseClick) {
|
||||
|
||||
if (this.props.onClick) {
|
||||
return (
|
||||
<div
|
||||
className={classNames}
|
||||
onMouseDown={this.handleMouseDown}
|
||||
onMouseUp={this.handleMouseUp}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div
|
||||
ref={this.setContentsRef}
|
||||
key={`contents-${tagLinks}-${rewriteMentions}`}
|
||||
dangerouslySetInnerHTML={content}
|
||||
className='status__content__text translate'
|
||||
tabIndex={0}
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
lang={language}
|
||||
/>
|
||||
<>
|
||||
<div className={classNames} ref={this.setRef} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} tabIndex={0} key='status-content' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
||||
<div className='status__content__text status__content__text--visible translate' lang={language} dangerouslySetInnerHTML={content} />
|
||||
|
||||
{poll}
|
||||
{translateButton}
|
||||
{media}
|
||||
{extraMedia}
|
||||
</div>
|
||||
|
||||
{readMoreButton}
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div
|
||||
className='status__content'
|
||||
tabIndex={0}
|
||||
>
|
||||
<div
|
||||
ref={this.setContentsRef}
|
||||
key={`contents-${tagLinks}`}
|
||||
className='status__content__text translate'
|
||||
dangerouslySetInnerHTML={content}
|
||||
tabIndex={0}
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
lang={language}
|
||||
/>
|
||||
<div className={classNames} ref={this.setRef} tabIndex={0} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
||||
<div className='status__content__text status__content__text--visible translate' lang={language} dangerouslySetInnerHTML={content} />
|
||||
|
||||
{poll}
|
||||
{translateButton}
|
||||
{media}
|
||||
{extraMedia}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,63 +0,0 @@
|
|||
// Package imports.
|
||||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
|
||||
// Mastodon imports.
|
||||
import { Avatar } from './avatar';
|
||||
import { AvatarOverlay } from './avatar_overlay';
|
||||
import { DisplayName } from './display_name';
|
||||
|
||||
export default class StatusHeader extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
status: ImmutablePropTypes.map.isRequired,
|
||||
friend: ImmutablePropTypes.map,
|
||||
avatarSize: PropTypes.number,
|
||||
parseClick: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
handleAccountClick = (e) => {
|
||||
const { status, parseClick } = this.props;
|
||||
parseClick(e, `/@${status.getIn(['account', 'acct'])}`);
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
// Rendering.
|
||||
render () {
|
||||
const {
|
||||
status,
|
||||
friend,
|
||||
avatarSize,
|
||||
} = this.props;
|
||||
|
||||
const account = status.get('account');
|
||||
|
||||
let statusAvatar;
|
||||
if (friend === undefined || friend === null) {
|
||||
statusAvatar = <Avatar account={account} size={avatarSize} />;
|
||||
} else {
|
||||
statusAvatar = <AvatarOverlay account={account} friend={friend} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={account.get('url')}
|
||||
className='status__display-name'
|
||||
target='_blank'
|
||||
onClick={this.handleAccountClick}
|
||||
rel='noopener noreferrer'
|
||||
title={status.getIn(['account', 'acct'])}
|
||||
data-hover-card-account={status.getIn(['account', 'id'])}
|
||||
>
|
||||
<div className='status__avatar'>
|
||||
{statusAvatar}
|
||||
</div>
|
||||
|
||||
<DisplayName account={account} />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -8,26 +8,14 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
|||
|
||||
import ForumIcon from '@/material-icons/400-24px/forum.svg?react';
|
||||
import HomeIcon from '@/material-icons/400-24px/home.svg?react';
|
||||
import ImageIcon from '@/material-icons/400-24px/image.svg?react';
|
||||
import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react';
|
||||
import LinkIcon from '@/material-icons/400-24px/link.svg?react';
|
||||
import MovieIcon from '@/material-icons/400-24px/movie.svg?react';
|
||||
import MusicNoteIcon from '@/material-icons/400-24px/music_note.svg?react';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import { MediaIcon } from 'flavours/glitch/components/media_icon';
|
||||
import { languages } from 'flavours/glitch/initial_state';
|
||||
|
||||
import { CollapseButton } from './collapse_button';
|
||||
import { VisibilityIcon } from './visibility_icon';
|
||||
|
||||
const messages = defineMessages({
|
||||
collapse: { id: 'status.collapse', defaultMessage: 'Collapse' },
|
||||
uncollapse: { id: 'status.uncollapse', defaultMessage: 'Uncollapse' },
|
||||
inReplyTo: { id: 'status.in_reply_to', defaultMessage: 'This toot is a reply' },
|
||||
previewCard: { id: 'status.has_preview_card', defaultMessage: 'Features an attached preview card' },
|
||||
pictures: { id: 'status.has_pictures', defaultMessage: 'Features attached pictures' },
|
||||
poll: { id: 'status.is_poll', defaultMessage: 'This toot is a poll' },
|
||||
video: { id: 'status.has_video', defaultMessage: 'Features attached videos' },
|
||||
audio: { id: 'status.has_audio', defaultMessage: 'Features attached audio files' },
|
||||
localOnly: { id: 'status.local_only', defaultMessage: 'Only visible from your instance' },
|
||||
});
|
||||
|
||||
|
@ -53,70 +41,14 @@ class StatusIcons extends PureComponent {
|
|||
static propTypes = {
|
||||
status: ImmutablePropTypes.map.isRequired,
|
||||
mediaIcons: PropTypes.arrayOf(PropTypes.string),
|
||||
collapsible: PropTypes.bool,
|
||||
collapsed: PropTypes.bool,
|
||||
setCollapsed: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
settings: ImmutablePropTypes.map.isRequired,
|
||||
};
|
||||
|
||||
// Handles clicks on collapsed button
|
||||
handleCollapsedClick = (e) => {
|
||||
const { collapsed, setCollapsed } = this.props;
|
||||
if (e.button === 0) {
|
||||
setCollapsed(!collapsed);
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
renderIcon (mediaIcon) {
|
||||
const { intl } = this.props;
|
||||
|
||||
let title, iconComponent;
|
||||
|
||||
switch (mediaIcon) {
|
||||
case 'link':
|
||||
title = messages.previewCard;
|
||||
iconComponent = LinkIcon;
|
||||
break;
|
||||
case 'picture-o':
|
||||
title = messages.pictures;
|
||||
iconComponent = ImageIcon;
|
||||
break;
|
||||
case 'tasks':
|
||||
title = messages.poll;
|
||||
iconComponent = InsertChartIcon;
|
||||
break;
|
||||
case 'video-camera':
|
||||
title = messages.video;
|
||||
iconComponent = MovieIcon;
|
||||
break;
|
||||
case 'music':
|
||||
title = messages.audio;
|
||||
iconComponent = MusicNoteIcon;
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<Icon
|
||||
fixedWidth
|
||||
className='status__media-icon'
|
||||
key={`media-icon--${mediaIcon}`}
|
||||
id={mediaIcon}
|
||||
icon={iconComponent}
|
||||
aria-hidden='true'
|
||||
title={title && intl.formatMessage(title)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render () {
|
||||
const {
|
||||
status,
|
||||
mediaIcons,
|
||||
collapsible,
|
||||
collapsed,
|
||||
setCollapsed,
|
||||
settings,
|
||||
intl,
|
||||
} = this.props;
|
||||
|
@ -140,9 +72,8 @@ class StatusIcons extends PureComponent {
|
|||
aria-hidden='true'
|
||||
title={intl.formatMessage(messages.localOnly)}
|
||||
/>}
|
||||
{settings.get('media') && !!mediaIcons && mediaIcons.map(icon => this.renderIcon(icon))}
|
||||
{settings.get('media') && !!mediaIcons && mediaIcons.map(icon => (<MediaIcon key={`media-icon--${icon}`} className='status__media-icon' icon={icon} />))}
|
||||
{settings.get('visibility') && <VisibilityIcon visibility={status.get('visibility')} />}
|
||||
{collapsible && <CollapseButton collapsed={collapsed} setCollapsed={setCollapsed} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
|||
import { debounce } from 'lodash';
|
||||
|
||||
import { TIMELINE_GAP, TIMELINE_SUGGESTIONS } from 'flavours/glitch/actions/timelines';
|
||||
import RegenerationIndicator from 'flavours/glitch/components/regeneration_indicator';
|
||||
import { RegenerationIndicator } from 'flavours/glitch/components/regeneration_indicator';
|
||||
import { InlineFollowSuggestions } from 'flavours/glitch/features/home_timeline/components/inline_follow_suggestions';
|
||||
|
||||
import StatusContainer from '../containers/status_container';
|
||||
|
|
|
@ -16,27 +16,22 @@ import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
|
|||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import { me } from 'flavours/glitch/initial_state';
|
||||
|
||||
import { Permalink } from './permalink';
|
||||
|
||||
export default class StatusPrepend extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
type: PropTypes.string.isRequired,
|
||||
account: ImmutablePropTypes.map.isRequired,
|
||||
parseClick: PropTypes.func.isRequired,
|
||||
notificationId: PropTypes.number,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
handleClick = (e) => {
|
||||
const { account, parseClick } = this.props;
|
||||
parseClick(e, `/@${account.get('acct')}`);
|
||||
};
|
||||
|
||||
Message = () => {
|
||||
const { type, account } = this.props;
|
||||
let link = (
|
||||
<a
|
||||
onClick={this.handleClick}
|
||||
<Permalink
|
||||
to={`/@${account.get('acct')}`}
|
||||
href={account.get('url')}
|
||||
className='status__display-name'
|
||||
data-hover-card-account={account.get('id')}
|
||||
|
@ -48,7 +43,7 @@ export default class StatusPrepend extends PureComponent {
|
|||
}}
|
||||
/>
|
||||
</bdi>
|
||||
</a>
|
||||
</Permalink>
|
||||
);
|
||||
switch (type) {
|
||||
case 'featured':
|
||||
|
|
|
@ -1,60 +0,0 @@
|
|||
import { injectIntl } from 'react-intl';
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { openModal } from 'flavours/glitch/actions/modal';
|
||||
|
||||
import {
|
||||
followAccount,
|
||||
blockAccount,
|
||||
unblockAccount,
|
||||
muteAccount,
|
||||
unmuteAccount,
|
||||
} from '../actions/accounts';
|
||||
import { initMuteModal } from '../actions/mutes';
|
||||
import Account from '../components/account';
|
||||
import { makeGetAccount } from '../selectors';
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const getAccount = makeGetAccount();
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
account: getAccount(state, props.id),
|
||||
});
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
|
||||
onFollow (account) {
|
||||
if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
|
||||
dispatch(openModal({ modalType: 'CONFIRM_UNFOLLOW', modalProps: { account } }));
|
||||
} else {
|
||||
dispatch(followAccount(account.get('id')));
|
||||
}
|
||||
},
|
||||
|
||||
onBlock (account) {
|
||||
if (account.getIn(['relationship', 'blocking'])) {
|
||||
dispatch(unblockAccount(account.get('id')));
|
||||
} else {
|
||||
dispatch(blockAccount(account.get('id')));
|
||||
}
|
||||
},
|
||||
|
||||
onMute (account) {
|
||||
if (account.getIn(['relationship', 'muting'])) {
|
||||
dispatch(unmuteAccount(account.get('id')));
|
||||
} else {
|
||||
dispatch(initMuteModal(account));
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
onMuteNotifications (account, notifications) {
|
||||
dispatch(muteAccount(account.get('id'), notifications));
|
||||
},
|
||||
});
|
||||
|
||||
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Account));
|
|
@ -9,14 +9,14 @@ import Poll from 'flavours/glitch/components/poll';
|
|||
const mapDispatchToProps = (dispatch, { pollId }) => ({
|
||||
refresh: debounce(
|
||||
() => {
|
||||
dispatch(fetchPoll(pollId));
|
||||
dispatch(fetchPoll({ pollId }));
|
||||
},
|
||||
1000,
|
||||
{ leading: true },
|
||||
),
|
||||
|
||||
onVote (choices) {
|
||||
dispatch(vote(pollId, choices));
|
||||
dispatch(vote({ pollId, choices }));
|
||||
},
|
||||
|
||||
onInteractionModal (type, status) {
|
||||
|
@ -32,7 +32,7 @@ const mapDispatchToProps = (dispatch, { pollId }) => ({
|
|||
});
|
||||
|
||||
const mapStateToProps = (state, { pollId }) => ({
|
||||
poll: state.getIn(['polls', pollId]),
|
||||
poll: state.polls.get(pollId),
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Poll);
|
||||
|
|
|
@ -28,6 +28,7 @@ import {
|
|||
unmuteStatus,
|
||||
deleteStatus,
|
||||
toggleStatusSpoilers,
|
||||
toggleStatusCollapse,
|
||||
editStatus,
|
||||
translateStatus,
|
||||
undoStatusTranslation,
|
||||
|
@ -201,6 +202,11 @@ const mapDispatchToProps = (dispatch, { contextType }) => ({
|
|||
dispatch(toggleStatusSpoilers(status.get('id')));
|
||||
},
|
||||
|
||||
onToggleCollapsed (status, isCollapsed) {
|
||||
dispatch(toggleStatusCollapse(status.get('id'), isCollapsed));
|
||||
},
|
||||
|
||||
|
||||
deployPictureInPicture (status, type, mediaProps) {
|
||||
dispatch((_, getState) => {
|
||||
if (getState().getIn(['local_settings', 'media', 'pop_in_player'])) {
|
||||
|
|
|
@ -60,6 +60,10 @@ window.addEventListener('message', (e) => {
|
|||
|
||||
const data = e.data;
|
||||
|
||||
// Only set overflow to `hidden` once we got the expected `message` so the post can still be scrolled if
|
||||
// embedded without parent Javascript support
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// We use a timeout to allow for the React page to render before calculating the height
|
||||
afterInitialRender(() => {
|
||||
window.parent.postMessage(
|
||||
|
|
|
@ -13,12 +13,12 @@ import { connect } from 'react-redux';
|
|||
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
||||
import ExpandMoreIcon from '@/material-icons/400-24px/expand_more.svg?react';
|
||||
import { fetchServer, fetchExtendedDescription, fetchDomainBlocks } from 'flavours/glitch/actions/server';
|
||||
import { Account } from 'flavours/glitch/components/account';
|
||||
import Column from 'flavours/glitch/components/column';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import { ServerHeroImage } from 'flavours/glitch/components/server_hero_image';
|
||||
import { Skeleton } from 'flavours/glitch/components/skeleton';
|
||||
import Account from 'flavours/glitch/containers/account_container';
|
||||
import LinkFooter from 'flavours/glitch/features/ui/components/link_footer';
|
||||
import { LinkFooter} from 'flavours/glitch/features/ui/components/link_footer';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'column.about', defaultMessage: 'About' },
|
||||
|
@ -123,7 +123,7 @@ class About extends PureComponent {
|
|||
<div className='about__header'>
|
||||
<ServerHeroImage blurhash={server.getIn(['thumbnail', 'blurhash'])} src={server.getIn(['thumbnail', 'url'])} srcSet={server.getIn(['thumbnail', 'versions'])?.map((value, key) => `${value} ${key.replace('@', '')}`).join(', ')} className='about__header__hero' />
|
||||
<h1>{isLoading ? <Skeleton width='10ch' /> : server.get('domain')}</h1>
|
||||
<p><FormattedMessage id='about.powered_by' defaultMessage='Decentralized social media powered by {mastodon}' values={{ mastodon: <a href='https://joinmastodon.org' className='about__mail' target='_blank'>Mastodon</a> }} /></p>
|
||||
<p><FormattedMessage id='about.powered_by' defaultMessage='Decentralized social media powered by {mastodon}' values={{ mastodon: <a href='https://joinmastodon.org' className='about__mail' target='_blank' rel='noopener'>Mastodon</a> }} /></p>
|
||||
</div>
|
||||
|
||||
<div className='about__meta'>
|
||||
|
|
|
@ -344,7 +344,7 @@ class Header extends ImmutablePureComponent {
|
|||
|
||||
<div className='account__header__bar'>
|
||||
<div className='account__header__tabs'>
|
||||
<a className='avatar' href={account.get('avatar')} rel='noopener noreferrer' target='_blank' onClick={this.handleAvatarClick}>
|
||||
<a className='avatar' href={account.get('avatar')} rel='noopener' target='_blank' onClick={this.handleAvatarClick}>
|
||||
<Avatar account={suspended || hidden ? undefined : account} size={90} />
|
||||
</a>
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
/* eslint-disable react/jsx-no-useless-fragment */
|
||||
import { FormattedMessage, FormattedNumber } from 'react-intl';
|
||||
|
||||
import { domain } from 'flavours/glitch/initial_state';
|
||||
import type { Percentiles } from 'flavours/glitch/models/annual_report';
|
||||
|
||||
export const Percentile: React.FC<{
|
||||
|
@ -12,7 +13,7 @@ export const Percentile: React.FC<{
|
|||
<div className='annual-report__bento__box annual-report__summary__percentile'>
|
||||
<FormattedMessage
|
||||
id='annual_report.summary.percentile.text'
|
||||
defaultMessage='<topLabel>That puts you in the top</topLabel><percentage></percentage><bottomLabel>of Mastodon users.</bottomLabel>'
|
||||
defaultMessage='<topLabel>That puts you in the top</topLabel><percentage></percentage><bottomLabel>of {domain} users.</bottomLabel>'
|
||||
values={{
|
||||
topLabel: (str) => (
|
||||
<div className='annual-report__summary__percentile__label'>
|
||||
|
@ -44,6 +45,8 @@ export const Percentile: React.FC<{
|
|||
)}
|
||||
</div>
|
||||
),
|
||||
|
||||
domain,
|
||||
}}
|
||||
>
|
||||
{(message) => <>{message}</>}
|
||||
|
|
|
@ -9,11 +9,11 @@ import { connect } from 'react-redux';
|
|||
import { debounce } from 'lodash';
|
||||
|
||||
import BlockIcon from '@/material-icons/400-24px/block-fill.svg?react';
|
||||
import { Account } from 'flavours/glitch/components/account';
|
||||
|
||||
import { fetchBlocks, expandBlocks } from '../../actions/blocks';
|
||||
import { LoadingIndicator } from '../../components/loading_indicator';
|
||||
import ScrollableList from '../../components/scrollable_list';
|
||||
import AccountContainer from '../../containers/account_container';
|
||||
import Column from '../ui/components/column';
|
||||
|
||||
const messages = defineMessages({
|
||||
|
@ -70,7 +70,7 @@ class Blocks extends ImmutablePureComponent {
|
|||
bindToDocument={!multiColumn}
|
||||
>
|
||||
{accountIds.map(id =>
|
||||
<AccountContainer key={id} id={id} defaultAction='block' />,
|
||||
<Account key={id} id={id} defaultAction='block' />,
|
||||
)}
|
||||
</ScrollableList>
|
||||
</Column>
|
||||
|
|
|
@ -6,7 +6,7 @@ import { useSelector, useDispatch } from 'react-redux';
|
|||
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import { cancelReplyCompose } from 'flavours/glitch/actions/compose';
|
||||
import Account from 'flavours/glitch/components/account';
|
||||
import { Account } from 'flavours/glitch/components/account';
|
||||
import { IconButton } from 'flavours/glitch/components/icon_button';
|
||||
import { me } from 'flavours/glitch/initial_state';
|
||||
|
||||
|
@ -20,7 +20,6 @@ const messages = defineMessages({
|
|||
export const NavigationBar = () => {
|
||||
const dispatch = useDispatch();
|
||||
const intl = useIntl();
|
||||
const account = useSelector(state => state.getIn(['accounts', me]));
|
||||
const isReplying = useSelector(state => !!state.getIn(['compose', 'in_reply_to']));
|
||||
|
||||
const handleCancelClick = useCallback(() => {
|
||||
|
@ -29,7 +28,7 @@ export const NavigationBar = () => {
|
|||
|
||||
return (
|
||||
<div className='navigation-bar'>
|
||||
<Account account={account} minimal />
|
||||
<Account id={me} minimal />
|
||||
{isReplying ? <IconButton title={intl.formatMessage(messages.cancel)} iconComponent={CloseIcon} onClick={handleCancelClick} /> : <ActionBar />}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,402 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { defineMessages, injectIntl, FormattedMessage, FormattedList } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
|
||||
import CancelIcon from '@/material-icons/400-24px/cancel-fill.svg?react';
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
|
||||
import { domain, searchEnabled } from 'flavours/glitch/initial_state';
|
||||
import { HASHTAG_REGEX } from 'flavours/glitch/utils/hashtags';
|
||||
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
|
||||
|
||||
const messages = defineMessages({
|
||||
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
|
||||
placeholderSignedIn: { id: 'search.search_or_paste', defaultMessage: 'Search or paste URL' },
|
||||
});
|
||||
|
||||
const labelForRecentSearch = search => {
|
||||
switch(search.get('type')) {
|
||||
case 'account':
|
||||
return `@${search.get('q')}`;
|
||||
case 'hashtag':
|
||||
return `#${search.get('q')}`;
|
||||
default:
|
||||
return search.get('q');
|
||||
}
|
||||
};
|
||||
|
||||
class Search extends PureComponent {
|
||||
static propTypes = {
|
||||
identity: identityContextPropShape,
|
||||
value: PropTypes.string.isRequired,
|
||||
recent: ImmutablePropTypes.orderedSet,
|
||||
submitted: PropTypes.bool,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
onOpenURL: PropTypes.func.isRequired,
|
||||
onClickSearchResult: PropTypes.func.isRequired,
|
||||
onForgetSearchResult: PropTypes.func.isRequired,
|
||||
onClear: PropTypes.func.isRequired,
|
||||
onShow: PropTypes.func.isRequired,
|
||||
openInRoute: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
singleColumn: PropTypes.bool,
|
||||
...WithRouterPropTypes,
|
||||
};
|
||||
|
||||
state = {
|
||||
expanded: false,
|
||||
selectedOption: -1,
|
||||
options: [],
|
||||
};
|
||||
|
||||
defaultOptions = [
|
||||
{ key: 'prompt-has', label: <><mark>has:</mark> <FormattedList type='disjunction' value={['media', 'poll', 'embed']} /></>, action: e => { e.preventDefault(); this._insertText('has:'); } },
|
||||
{ key: 'prompt-is', label: <><mark>is:</mark> <FormattedList type='disjunction' value={['reply', 'sensitive']} /></>, action: e => { e.preventDefault(); this._insertText('is:'); } },
|
||||
{ key: 'prompt-language', label: <><mark>language:</mark> <FormattedMessage id='search_popout.language_code' defaultMessage='ISO language code' /></>, action: e => { e.preventDefault(); this._insertText('language:'); } },
|
||||
{ key: 'prompt-from', label: <><mark>from:</mark> <FormattedMessage id='search_popout.user' defaultMessage='user' /></>, action: e => { e.preventDefault(); this._insertText('from:'); } },
|
||||
{ key: 'prompt-before', label: <><mark>before:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('before:'); } },
|
||||
{ key: 'prompt-during', label: <><mark>during:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('during:'); } },
|
||||
{ key: 'prompt-after', label: <><mark>after:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('after:'); } },
|
||||
{ key: 'prompt-in', label: <><mark>in:</mark> <FormattedList type='disjunction' value={['all', 'library', 'public']} /></>, action: e => { e.preventDefault(); this._insertText('in:'); } }
|
||||
];
|
||||
|
||||
setRef = c => {
|
||||
this.searchForm = c;
|
||||
};
|
||||
|
||||
handleChange = ({ target }) => {
|
||||
const { onChange } = this.props;
|
||||
|
||||
onChange(target.value);
|
||||
|
||||
this._calculateOptions(target.value);
|
||||
};
|
||||
|
||||
handleClear = e => {
|
||||
const { value, submitted, onClear } = this.props;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
if (value.length > 0 || submitted) {
|
||||
onClear();
|
||||
this.setState({ options: [], selectedOption: -1 });
|
||||
}
|
||||
};
|
||||
|
||||
handleKeyDown = (e) => {
|
||||
const { selectedOption } = this.state;
|
||||
const options = searchEnabled ? this._getOptions().concat(this.defaultOptions) : this._getOptions();
|
||||
|
||||
switch(e.key) {
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
this._unfocus();
|
||||
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
|
||||
if (options.length > 0) {
|
||||
this.setState({ selectedOption: Math.min(selectedOption + 1, options.length - 1) });
|
||||
}
|
||||
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
|
||||
if (options.length > 0) {
|
||||
this.setState({ selectedOption: Math.max(selectedOption - 1, -1) });
|
||||
}
|
||||
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
|
||||
if (selectedOption === -1) {
|
||||
this._submit();
|
||||
} else if (options.length > 0) {
|
||||
options[selectedOption].action(e);
|
||||
}
|
||||
|
||||
break;
|
||||
case 'Delete':
|
||||
if (selectedOption > -1 && options.length > 0) {
|
||||
const search = options[selectedOption];
|
||||
|
||||
if (typeof search.forget === 'function') {
|
||||
e.preventDefault();
|
||||
search.forget(e);
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
handleFocus = () => {
|
||||
const { onShow, singleColumn } = this.props;
|
||||
|
||||
this.setState({ expanded: true, selectedOption: -1 });
|
||||
onShow();
|
||||
|
||||
if (this.searchForm && !singleColumn) {
|
||||
const { left, right } = this.searchForm.getBoundingClientRect();
|
||||
|
||||
if (left < 0 || right > (window.innerWidth || document.documentElement.clientWidth)) {
|
||||
this.searchForm.scrollIntoView();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleBlur = () => {
|
||||
this.setState({ expanded: false, selectedOption: -1 });
|
||||
};
|
||||
|
||||
handleHashtagClick = () => {
|
||||
const { value, onClickSearchResult, history } = this.props;
|
||||
|
||||
const query = value.trim().replace(/^#/, '');
|
||||
|
||||
history.push(`/tags/${query}`);
|
||||
onClickSearchResult(query, 'hashtag');
|
||||
this._unfocus();
|
||||
};
|
||||
|
||||
handleAccountClick = () => {
|
||||
const { value, onClickSearchResult, history } = this.props;
|
||||
|
||||
const query = value.trim().replace(/^@/, '');
|
||||
|
||||
history.push(`/@${query}`);
|
||||
onClickSearchResult(query, 'account');
|
||||
this._unfocus();
|
||||
};
|
||||
|
||||
handleURLClick = () => {
|
||||
const { value, onOpenURL, history } = this.props;
|
||||
|
||||
onOpenURL(value, history);
|
||||
this._unfocus();
|
||||
};
|
||||
|
||||
handleStatusSearch = () => {
|
||||
this._submit('statuses');
|
||||
};
|
||||
|
||||
handleAccountSearch = () => {
|
||||
this._submit('accounts');
|
||||
};
|
||||
|
||||
handleRecentSearchClick = search => {
|
||||
const { onChange, history } = this.props;
|
||||
|
||||
if (search.get('type') === 'account') {
|
||||
history.push(`/@${search.get('q')}`);
|
||||
} else if (search.get('type') === 'hashtag') {
|
||||
history.push(`/tags/${search.get('q')}`);
|
||||
} else {
|
||||
onChange(search.get('q'));
|
||||
this._submit(search.get('type'));
|
||||
}
|
||||
|
||||
this._unfocus();
|
||||
};
|
||||
|
||||
handleForgetRecentSearchClick = search => {
|
||||
const { onForgetSearchResult } = this.props;
|
||||
|
||||
onForgetSearchResult(search.get('q'));
|
||||
};
|
||||
|
||||
_unfocus () {
|
||||
document.querySelector('.ui').parentElement.focus();
|
||||
}
|
||||
|
||||
_insertText (text) {
|
||||
const { value, onChange } = this.props;
|
||||
|
||||
if (value === '') {
|
||||
onChange(text);
|
||||
} else if (value[value.length - 1] === ' ') {
|
||||
onChange(`${value}${text}`);
|
||||
} else {
|
||||
onChange(`${value} ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
_submit (type) {
|
||||
const { onSubmit, openInRoute, value, onClickSearchResult, history } = this.props;
|
||||
|
||||
onSubmit(type);
|
||||
|
||||
if (value) {
|
||||
onClickSearchResult(value, type);
|
||||
}
|
||||
|
||||
if (openInRoute) {
|
||||
history.push('/search');
|
||||
}
|
||||
|
||||
this._unfocus();
|
||||
}
|
||||
|
||||
_getOptions () {
|
||||
const { options } = this.state;
|
||||
|
||||
if (options.length > 0) {
|
||||
return options;
|
||||
}
|
||||
|
||||
const { recent } = this.props;
|
||||
|
||||
return recent.toArray().map(search => ({
|
||||
key: `${search.get('type')}/${search.get('q')}`,
|
||||
|
||||
label: labelForRecentSearch(search),
|
||||
|
||||
action: () => this.handleRecentSearchClick(search),
|
||||
|
||||
forget: e => {
|
||||
e.stopPropagation();
|
||||
this.handleForgetRecentSearchClick(search);
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
_calculateOptions (value) {
|
||||
const { signedIn } = this.props.identity;
|
||||
const trimmedValue = value.trim();
|
||||
const options = [];
|
||||
|
||||
if (trimmedValue.length > 0) {
|
||||
const couldBeURL = trimmedValue.startsWith('https://') && !trimmedValue.includes(' ');
|
||||
|
||||
if (couldBeURL) {
|
||||
options.push({ key: 'open-url', label: <FormattedMessage id='search.quick_action.open_url' defaultMessage='Open URL in Mastodon' />, action: this.handleURLClick });
|
||||
}
|
||||
|
||||
const couldBeHashtag = (trimmedValue.startsWith('#') && trimmedValue.length > 1) || trimmedValue.match(HASHTAG_REGEX);
|
||||
|
||||
if (couldBeHashtag) {
|
||||
options.push({ key: 'go-to-hashtag', label: <FormattedMessage id='search.quick_action.go_to_hashtag' defaultMessage='Go to hashtag {x}' values={{ x: <mark>#{trimmedValue.replace(/^#/, '')}</mark> }} />, action: this.handleHashtagClick });
|
||||
}
|
||||
|
||||
const couldBeUsername = trimmedValue.match(/^@?[a-z0-9_-]+(@[^\s]+)?$/i);
|
||||
|
||||
if (couldBeUsername) {
|
||||
options.push({ key: 'go-to-account', label: <FormattedMessage id='search.quick_action.go_to_account' defaultMessage='Go to profile {x}' values={{ x: <mark>@{trimmedValue.replace(/^@/, '')}</mark> }} />, action: this.handleAccountClick });
|
||||
}
|
||||
|
||||
const couldBeStatusSearch = searchEnabled;
|
||||
|
||||
if (couldBeStatusSearch && signedIn) {
|
||||
options.push({ key: 'status-search', label: <FormattedMessage id='search.quick_action.status_search' defaultMessage='Posts matching {x}' values={{ x: <mark>{trimmedValue}</mark> }} />, action: this.handleStatusSearch });
|
||||
}
|
||||
|
||||
const couldBeUserSearch = true;
|
||||
|
||||
if (couldBeUserSearch) {
|
||||
options.push({ key: 'account-search', label: <FormattedMessage id='search.quick_action.account_search' defaultMessage='Profiles matching {x}' values={{ x: <mark>{trimmedValue}</mark> }} />, action: this.handleAccountSearch });
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({ options });
|
||||
}
|
||||
|
||||
render () {
|
||||
const { intl, value, submitted, recent } = this.props;
|
||||
const { expanded, options, selectedOption } = this.state;
|
||||
const { signedIn } = this.props.identity;
|
||||
|
||||
const hasValue = value.length > 0 || submitted;
|
||||
|
||||
return (
|
||||
<div className={classNames('search', { active: expanded })}>
|
||||
<input
|
||||
ref={this.setRef}
|
||||
className='search__input'
|
||||
type='text'
|
||||
placeholder={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)}
|
||||
aria-label={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)}
|
||||
value={value}
|
||||
onChange={this.handleChange}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onFocus={this.handleFocus}
|
||||
onBlur={this.handleBlur}
|
||||
/>
|
||||
|
||||
<div role='button' tabIndex={0} className='search__icon' onClick={this.handleClear}>
|
||||
<Icon id='search' icon={SearchIcon} className={hasValue ? '' : 'active'} />
|
||||
<Icon id='times-circle' icon={CancelIcon} className={hasValue ? 'active' : ''} aria-label={intl.formatMessage(messages.placeholder)} />
|
||||
</div>
|
||||
|
||||
<div className='search__popout'>
|
||||
{options.length === 0 && (
|
||||
<>
|
||||
<h4><FormattedMessage id='search_popout.recent' defaultMessage='Recent searches' /></h4>
|
||||
|
||||
<div className='search__popout__menu'>
|
||||
{recent.size > 0 ? this._getOptions().map(({ label, key, action, forget }, i) => (
|
||||
<button key={key} onMouseDown={action} className={classNames('search__popout__menu__item search__popout__menu__item--flex', { selected: selectedOption === i })}>
|
||||
<span>{label}</span>
|
||||
<button className='icon-button' onMouseDown={forget}><Icon id='times' icon={CloseIcon} /></button>
|
||||
</button>
|
||||
)) : (
|
||||
<div className='search__popout__menu__message'>
|
||||
<FormattedMessage id='search.no_recent_searches' defaultMessage='No recent searches' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{options.length > 0 && (
|
||||
<>
|
||||
<h4><FormattedMessage id='search_popout.quick_actions' defaultMessage='Quick actions' /></h4>
|
||||
|
||||
<div className='search__popout__menu'>
|
||||
{options.map(({ key, label, action }, i) => (
|
||||
<button key={key} onMouseDown={action} className={classNames('search__popout__menu__item', { selected: selectedOption === i })}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<h4><FormattedMessage id='search_popout.options' defaultMessage='Search options' /></h4>
|
||||
|
||||
{searchEnabled && signedIn ? (
|
||||
<div className='search__popout__menu'>
|
||||
{this.defaultOptions.map(({ key, label, action }, i) => (
|
||||
<button key={key} onMouseDown={action} className={classNames('search__popout__menu__item', { selected: selectedOption === ((options.length || recent.size) + i) })}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className='search__popout__menu__message'>
|
||||
{searchEnabled ? (
|
||||
<FormattedMessage id='search_popout.full_text_search_logged_out_message' defaultMessage='Only available when logged in.' />
|
||||
) : (
|
||||
<FormattedMessage id='search_popout.full_text_search_disabled_message' defaultMessage='Not available on {domain}.' values={{ domain }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default withRouter(withIdentity(injectIntl(Search)));
|
|
@ -0,0 +1,593 @@
|
|||
import { useCallback, useState, useRef } from 'react';
|
||||
|
||||
import {
|
||||
defineMessages,
|
||||
useIntl,
|
||||
FormattedMessage,
|
||||
FormattedList,
|
||||
} from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { isFulfilled } from '@reduxjs/toolkit';
|
||||
|
||||
import CancelIcon from '@/material-icons/400-24px/cancel-fill.svg?react';
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
|
||||
import {
|
||||
clickSearchResult,
|
||||
forgetSearchResult,
|
||||
openURL,
|
||||
} from 'flavours/glitch/actions/search';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import { useIdentity } from 'flavours/glitch/identity_context';
|
||||
import { domain, searchEnabled } from 'flavours/glitch/initial_state';
|
||||
import type { RecentSearch, SearchType } from 'flavours/glitch/models/search';
|
||||
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
|
||||
import { HASHTAG_REGEX } from 'flavours/glitch/utils/hashtags';
|
||||
|
||||
const messages = defineMessages({
|
||||
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
|
||||
placeholderSignedIn: {
|
||||
id: 'search.search_or_paste',
|
||||
defaultMessage: 'Search or paste URL',
|
||||
},
|
||||
});
|
||||
|
||||
const labelForRecentSearch = (search: RecentSearch) => {
|
||||
switch (search.type) {
|
||||
case 'account':
|
||||
return `@${search.q}`;
|
||||
case 'hashtag':
|
||||
return `#${search.q}`;
|
||||
default:
|
||||
return search.q;
|
||||
}
|
||||
};
|
||||
|
||||
const unfocus = () => {
|
||||
document.querySelector('.ui')?.parentElement?.focus();
|
||||
};
|
||||
|
||||
interface SearchOption {
|
||||
key: string;
|
||||
label: React.ReactNode;
|
||||
action: (e: React.MouseEvent | React.KeyboardEvent) => void;
|
||||
forget?: (e: React.MouseEvent | React.KeyboardEvent) => void;
|
||||
}
|
||||
|
||||
export const Search: React.FC<{
|
||||
singleColumn: boolean;
|
||||
initialValue?: string;
|
||||
}> = ({ singleColumn, initialValue }) => {
|
||||
const intl = useIntl();
|
||||
const recent = useAppSelector((state) => state.search.recent);
|
||||
const { signedIn } = useIdentity();
|
||||
const dispatch = useAppDispatch();
|
||||
const history = useHistory();
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const [value, setValue] = useState(initialValue ?? '');
|
||||
const hasValue = value.length > 0;
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [selectedOption, setSelectedOption] = useState(-1);
|
||||
const [quickActions, setQuickActions] = useState<SearchOption[]>([]);
|
||||
const searchOptions: SearchOption[] = [];
|
||||
|
||||
if (searchEnabled) {
|
||||
searchOptions.push(
|
||||
{
|
||||
key: 'prompt-has',
|
||||
label: (
|
||||
<>
|
||||
<mark>has:</mark>{' '}
|
||||
<FormattedList
|
||||
type='disjunction'
|
||||
value={['media', 'poll', 'embed']}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
action: (e) => {
|
||||
e.preventDefault();
|
||||
insertText('has:');
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'prompt-is',
|
||||
label: (
|
||||
<>
|
||||
<mark>is:</mark>{' '}
|
||||
<FormattedList type='disjunction' value={['reply', 'sensitive']} />
|
||||
</>
|
||||
),
|
||||
action: (e) => {
|
||||
e.preventDefault();
|
||||
insertText('is:');
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'prompt-language',
|
||||
label: (
|
||||
<>
|
||||
<mark>language:</mark>{' '}
|
||||
<FormattedMessage
|
||||
id='search_popout.language_code'
|
||||
defaultMessage='ISO language code'
|
||||
/>
|
||||
</>
|
||||
),
|
||||
action: (e) => {
|
||||
e.preventDefault();
|
||||
insertText('language:');
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'prompt-from',
|
||||
label: (
|
||||
<>
|
||||
<mark>from:</mark>{' '}
|
||||
<FormattedMessage id='search_popout.user' defaultMessage='user' />
|
||||
</>
|
||||
),
|
||||
action: (e) => {
|
||||
e.preventDefault();
|
||||
insertText('from:');
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'prompt-before',
|
||||
label: (
|
||||
<>
|
||||
<mark>before:</mark>{' '}
|
||||
<FormattedMessage
|
||||
id='search_popout.specific_date'
|
||||
defaultMessage='specific date'
|
||||
/>
|
||||
</>
|
||||
),
|
||||
action: (e) => {
|
||||
e.preventDefault();
|
||||
insertText('before:');
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'prompt-during',
|
||||
label: (
|
||||
<>
|
||||
<mark>during:</mark>{' '}
|
||||
<FormattedMessage
|
||||
id='search_popout.specific_date'
|
||||
defaultMessage='specific date'
|
||||
/>
|
||||
</>
|
||||
),
|
||||
action: (e) => {
|
||||
e.preventDefault();
|
||||
insertText('during:');
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'prompt-after',
|
||||
label: (
|
||||
<>
|
||||
<mark>after:</mark>{' '}
|
||||
<FormattedMessage
|
||||
id='search_popout.specific_date'
|
||||
defaultMessage='specific date'
|
||||
/>
|
||||
</>
|
||||
),
|
||||
action: (e) => {
|
||||
e.preventDefault();
|
||||
insertText('after:');
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'prompt-in',
|
||||
label: (
|
||||
<>
|
||||
<mark>in:</mark>{' '}
|
||||
<FormattedList
|
||||
type='disjunction'
|
||||
value={['all', 'library', 'public']}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
action: (e) => {
|
||||
e.preventDefault();
|
||||
insertText('in:');
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const recentOptions: SearchOption[] = recent.map((search) => ({
|
||||
key: `${search.type}/${search.q}`,
|
||||
label: labelForRecentSearch(search),
|
||||
action: () => {
|
||||
setValue(search.q);
|
||||
|
||||
if (search.type === 'account') {
|
||||
history.push(`/@${search.q}`);
|
||||
} else if (search.type === 'hashtag') {
|
||||
history.push(`/tags/${search.q}`);
|
||||
} else {
|
||||
const queryParams = new URLSearchParams({ q: search.q });
|
||||
if (search.type) queryParams.set('type', search.type);
|
||||
history.push({ pathname: '/search', search: queryParams.toString() });
|
||||
}
|
||||
|
||||
unfocus();
|
||||
},
|
||||
forget: (e) => {
|
||||
e.stopPropagation();
|
||||
void dispatch(forgetSearchResult(search.q));
|
||||
},
|
||||
}));
|
||||
|
||||
const navigableOptions = hasValue
|
||||
? quickActions.concat(searchOptions)
|
||||
: recentOptions.concat(quickActions, searchOptions);
|
||||
|
||||
const insertText = (text: string) => {
|
||||
setValue((currentValue) => {
|
||||
if (currentValue === '') {
|
||||
return text;
|
||||
} else if (currentValue.endsWith(' ')) {
|
||||
return `${currentValue}${text}`;
|
||||
} else {
|
||||
return `${currentValue} ${text}`;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const submit = useCallback(
|
||||
(q: string, type?: SearchType) => {
|
||||
void dispatch(clickSearchResult({ q, type }));
|
||||
const queryParams = new URLSearchParams({ q });
|
||||
if (type) queryParams.set('type', type);
|
||||
history.push({ pathname: '/search', search: queryParams.toString() });
|
||||
unfocus();
|
||||
},
|
||||
[dispatch, history],
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setValue(value);
|
||||
|
||||
const trimmedValue = value.trim();
|
||||
const newQuickActions = [];
|
||||
|
||||
if (trimmedValue.length > 0) {
|
||||
const couldBeURL =
|
||||
trimmedValue.startsWith('https://') && !trimmedValue.includes(' ');
|
||||
|
||||
if (couldBeURL) {
|
||||
newQuickActions.push({
|
||||
key: 'open-url',
|
||||
label: (
|
||||
<FormattedMessage
|
||||
id='search.quick_action.open_url'
|
||||
defaultMessage='Open URL in Mastodon'
|
||||
/>
|
||||
),
|
||||
action: async () => {
|
||||
const result = await dispatch(openURL({ url: trimmedValue }));
|
||||
|
||||
if (isFulfilled(result)) {
|
||||
if (result.payload.accounts[0]) {
|
||||
history.push(`/@${result.payload.accounts[0].acct}`);
|
||||
} else if (result.payload.statuses[0]) {
|
||||
history.push(
|
||||
`/@${result.payload.statuses[0].account.acct}/${result.payload.statuses[0].id}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
unfocus();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const couldBeHashtag =
|
||||
(trimmedValue.startsWith('#') && trimmedValue.length > 1) ||
|
||||
trimmedValue.match(HASHTAG_REGEX);
|
||||
|
||||
if (couldBeHashtag) {
|
||||
newQuickActions.push({
|
||||
key: 'go-to-hashtag',
|
||||
label: (
|
||||
<FormattedMessage
|
||||
id='search.quick_action.go_to_hashtag'
|
||||
defaultMessage='Go to hashtag {x}'
|
||||
values={{ x: <mark>#{trimmedValue.replace(/^#/, '')}</mark> }}
|
||||
/>
|
||||
),
|
||||
action: () => {
|
||||
const query = trimmedValue.replace(/^#/, '');
|
||||
history.push(`/tags/${query}`);
|
||||
void dispatch(clickSearchResult({ q: query, type: 'hashtag' }));
|
||||
unfocus();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const couldBeUsername = /^@?[a-z0-9_-]+(@[^\s]+)?$/i.exec(trimmedValue);
|
||||
|
||||
if (couldBeUsername) {
|
||||
newQuickActions.push({
|
||||
key: 'go-to-account',
|
||||
label: (
|
||||
<FormattedMessage
|
||||
id='search.quick_action.go_to_account'
|
||||
defaultMessage='Go to profile {x}'
|
||||
values={{ x: <mark>@{trimmedValue.replace(/^@/, '')}</mark> }}
|
||||
/>
|
||||
),
|
||||
action: () => {
|
||||
const query = trimmedValue.replace(/^@/, '');
|
||||
history.push(`/@${query}`);
|
||||
void dispatch(clickSearchResult({ q: query, type: 'account' }));
|
||||
unfocus();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const couldBeStatusSearch = searchEnabled;
|
||||
|
||||
if (couldBeStatusSearch && signedIn) {
|
||||
newQuickActions.push({
|
||||
key: 'status-search',
|
||||
label: (
|
||||
<FormattedMessage
|
||||
id='search.quick_action.status_search'
|
||||
defaultMessage='Posts matching {x}'
|
||||
values={{ x: <mark>{trimmedValue}</mark> }}
|
||||
/>
|
||||
),
|
||||
action: () => {
|
||||
submit(trimmedValue, 'statuses');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
newQuickActions.push({
|
||||
key: 'account-search',
|
||||
label: (
|
||||
<FormattedMessage
|
||||
id='search.quick_action.account_search'
|
||||
defaultMessage='Profiles matching {x}'
|
||||
values={{ x: <mark>{trimmedValue}</mark> }}
|
||||
/>
|
||||
),
|
||||
action: () => {
|
||||
submit(trimmedValue, 'accounts');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
setQuickActions(newQuickActions);
|
||||
},
|
||||
[dispatch, history, signedIn, setValue, setQuickActions, submit],
|
||||
);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setValue('');
|
||||
setQuickActions([]);
|
||||
setSelectedOption(-1);
|
||||
}, [setValue, setQuickActions, setSelectedOption]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
unfocus();
|
||||
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
|
||||
if (navigableOptions.length > 0) {
|
||||
setSelectedOption(
|
||||
Math.min(selectedOption + 1, navigableOptions.length - 1),
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
|
||||
if (navigableOptions.length > 0) {
|
||||
setSelectedOption(Math.max(selectedOption - 1, -1));
|
||||
}
|
||||
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
|
||||
if (selectedOption === -1) {
|
||||
submit(value);
|
||||
} else if (navigableOptions.length > 0) {
|
||||
navigableOptions[selectedOption]?.action(e);
|
||||
}
|
||||
|
||||
break;
|
||||
case 'Delete':
|
||||
if (selectedOption > -1 && navigableOptions.length > 0) {
|
||||
const search = navigableOptions[selectedOption];
|
||||
|
||||
if (typeof search?.forget === 'function') {
|
||||
e.preventDefault();
|
||||
search.forget(e);
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
},
|
||||
[navigableOptions, value, selectedOption, setSelectedOption, submit],
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
setExpanded(true);
|
||||
setSelectedOption(-1);
|
||||
|
||||
if (searchInputRef.current && !singleColumn) {
|
||||
const { left, right } = searchInputRef.current.getBoundingClientRect();
|
||||
|
||||
if (
|
||||
left < 0 ||
|
||||
right > (window.innerWidth || document.documentElement.clientWidth)
|
||||
) {
|
||||
searchInputRef.current.scrollIntoView();
|
||||
}
|
||||
}
|
||||
}, [setExpanded, setSelectedOption, singleColumn]);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setExpanded(false);
|
||||
setSelectedOption(-1);
|
||||
}, [setExpanded, setSelectedOption]);
|
||||
|
||||
return (
|
||||
<form className={classNames('search', { active: expanded })}>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
className='search__input'
|
||||
type='text'
|
||||
placeholder={intl.formatMessage(
|
||||
signedIn ? messages.placeholderSignedIn : messages.placeholder,
|
||||
)}
|
||||
aria-label={intl.formatMessage(
|
||||
signedIn ? messages.placeholderSignedIn : messages.placeholder,
|
||||
)}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
|
||||
<button type='button' className='search__icon' onClick={handleClear}>
|
||||
<Icon
|
||||
id='search'
|
||||
icon={SearchIcon}
|
||||
className={hasValue ? '' : 'active'}
|
||||
/>
|
||||
<Icon
|
||||
id='times-circle'
|
||||
icon={CancelIcon}
|
||||
className={hasValue ? 'active' : ''}
|
||||
aria-label={intl.formatMessage(messages.placeholder)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div className='search__popout'>
|
||||
{!hasValue && (
|
||||
<>
|
||||
<h4>
|
||||
<FormattedMessage
|
||||
id='search_popout.recent'
|
||||
defaultMessage='Recent searches'
|
||||
/>
|
||||
</h4>
|
||||
|
||||
<div className='search__popout__menu'>
|
||||
{recentOptions.length > 0 ? (
|
||||
recentOptions.map(({ label, key, action, forget }, i) => (
|
||||
<button
|
||||
key={key}
|
||||
onMouseDown={action}
|
||||
className={classNames(
|
||||
'search__popout__menu__item search__popout__menu__item--flex',
|
||||
{ selected: selectedOption === i },
|
||||
)}
|
||||
>
|
||||
<span>{label}</span>
|
||||
<button className='icon-button' onMouseDown={forget}>
|
||||
<Icon id='times' icon={CloseIcon} />
|
||||
</button>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className='search__popout__menu__message'>
|
||||
<FormattedMessage
|
||||
id='search.no_recent_searches'
|
||||
defaultMessage='No recent searches'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{quickActions.length > 0 && (
|
||||
<>
|
||||
<h4>
|
||||
<FormattedMessage
|
||||
id='search_popout.quick_actions'
|
||||
defaultMessage='Quick actions'
|
||||
/>
|
||||
</h4>
|
||||
|
||||
<div className='search__popout__menu'>
|
||||
{quickActions.map(({ key, label, action }, i) => (
|
||||
<button
|
||||
key={key}
|
||||
onMouseDown={action}
|
||||
className={classNames('search__popout__menu__item', {
|
||||
selected: selectedOption === i,
|
||||
})}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<h4>
|
||||
<FormattedMessage
|
||||
id='search_popout.options'
|
||||
defaultMessage='Search options'
|
||||
/>
|
||||
</h4>
|
||||
|
||||
{searchEnabled && signedIn ? (
|
||||
<div className='search__popout__menu'>
|
||||
{searchOptions.map(({ key, label, action }, i) => (
|
||||
<button
|
||||
key={key}
|
||||
onMouseDown={action}
|
||||
className={classNames('search__popout__menu__item', {
|
||||
selected:
|
||||
selectedOption ===
|
||||
(quickActions.length || recent.length) + i,
|
||||
})}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className='search__popout__menu__message'>
|
||||
{searchEnabled ? (
|
||||
<FormattedMessage
|
||||
id='search_popout.full_text_search_logged_out_message'
|
||||
defaultMessage='Only available when logged in.'
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='search_popout.full_text_search_disabled_message'
|
||||
defaultMessage='Not available on {domain}.'
|
||||
values={{ domain }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
|
@ -1,93 +0,0 @@
|
|||
import { useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import FindInPageIcon from '@/material-icons/400-24px/find_in_page.svg?react';
|
||||
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
|
||||
import TagIcon from '@/material-icons/400-24px/tag.svg?react';
|
||||
import { expandSearch } from 'flavours/glitch/actions/search';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import { LoadMore } from 'flavours/glitch/components/load_more';
|
||||
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
||||
import { SearchSection } from 'flavours/glitch/features/explore/components/search_section';
|
||||
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
||||
|
||||
import { ImmutableHashtag as Hashtag } from '../../../components/hashtag';
|
||||
import AccountContainer from '../../../containers/account_container';
|
||||
import StatusContainer from '../../../containers/status_container';
|
||||
|
||||
const INITIAL_PAGE_LIMIT = 10;
|
||||
|
||||
const withoutLastResult = list => {
|
||||
if (list.size > INITIAL_PAGE_LIMIT && list.size % INITIAL_PAGE_LIMIT === 1) {
|
||||
return list.skipLast(1);
|
||||
} else {
|
||||
return list;
|
||||
}
|
||||
};
|
||||
|
||||
export const SearchResults = () => {
|
||||
const results = useAppSelector((state) => state.getIn(['search', 'results']));
|
||||
const isLoading = useAppSelector((state) => state.getIn(['search', 'isLoading']));
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleLoadMoreAccounts = useCallback(() => {
|
||||
dispatch(expandSearch('accounts'));
|
||||
}, [dispatch]);
|
||||
|
||||
const handleLoadMoreStatuses = useCallback(() => {
|
||||
dispatch(expandSearch('statuses'));
|
||||
}, [dispatch]);
|
||||
|
||||
const handleLoadMoreHashtags = useCallback(() => {
|
||||
dispatch(expandSearch('hashtags'));
|
||||
}, [dispatch]);
|
||||
|
||||
let accounts, statuses, hashtags;
|
||||
|
||||
if (results.get('accounts') && results.get('accounts').size > 0) {
|
||||
accounts = (
|
||||
<SearchSection title={<><Icon id='users' icon={PeopleIcon} /><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></>}>
|
||||
{withoutLastResult(results.get('accounts')).map(accountId => <AccountContainer key={accountId} id={accountId} />)}
|
||||
{(results.get('accounts').size > INITIAL_PAGE_LIMIT && results.get('accounts').size % INITIAL_PAGE_LIMIT === 1) && <LoadMore visible onClick={handleLoadMoreAccounts} />}
|
||||
</SearchSection>
|
||||
);
|
||||
}
|
||||
|
||||
if (results.get('hashtags') && results.get('hashtags').size > 0) {
|
||||
hashtags = (
|
||||
<SearchSection title={<><Icon id='hashtag' icon={TagIcon} /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></>}>
|
||||
{withoutLastResult(results.get('hashtags')).map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
|
||||
{(results.get('hashtags').size > INITIAL_PAGE_LIMIT && results.get('hashtags').size % INITIAL_PAGE_LIMIT === 1) && <LoadMore visible onClick={handleLoadMoreHashtags} />}
|
||||
</SearchSection>
|
||||
);
|
||||
}
|
||||
|
||||
if (results.get('statuses') && results.get('statuses').size > 0) {
|
||||
statuses = (
|
||||
<SearchSection title={<><Icon id='quote-right' icon={FindInPageIcon} /><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></>}>
|
||||
{withoutLastResult(results.get('statuses')).map(statusId => <StatusContainer key={statusId} id={statusId} />)}
|
||||
{(results.get('statuses').size > INITIAL_PAGE_LIMIT && results.get('statuses').size % INITIAL_PAGE_LIMIT === 1) && <LoadMore visible onClick={handleLoadMoreStatuses} />}
|
||||
</SearchSection>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='search-results'>
|
||||
{!accounts && !hashtags && !statuses && (
|
||||
isLoading ? (
|
||||
<LoadingIndicator />
|
||||
) : (
|
||||
<div className='empty-column-indicator'>
|
||||
<FormattedMessage id='search_results.nothing_found' defaultMessage='Could not find anything for these search terms' />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{accounts}
|
||||
{hashtags}
|
||||
{statuses}
|
||||
</div>
|
||||
);
|
||||
|
||||
};
|
|
@ -1,59 +0,0 @@
|
|||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import {
|
||||
changeSearch,
|
||||
clearSearch,
|
||||
submitSearch,
|
||||
showSearch,
|
||||
openURL,
|
||||
clickSearchResult,
|
||||
forgetSearchResult,
|
||||
} from 'flavours/glitch/actions/search';
|
||||
|
||||
import Search from '../components/search';
|
||||
|
||||
const getRecentSearches = createSelector(
|
||||
state => state.getIn(['search', 'recent']),
|
||||
recent => recent.reverse(),
|
||||
);
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
value: state.getIn(['search', 'value']),
|
||||
submitted: state.getIn(['search', 'submitted']),
|
||||
recent: getRecentSearches(state),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
|
||||
onChange (value) {
|
||||
dispatch(changeSearch(value));
|
||||
},
|
||||
|
||||
onClear () {
|
||||
dispatch(clearSearch());
|
||||
},
|
||||
|
||||
onSubmit (type) {
|
||||
dispatch(submitSearch(type));
|
||||
},
|
||||
|
||||
onShow () {
|
||||
dispatch(showSearch());
|
||||
},
|
||||
|
||||
onOpenURL (q, routerHistory) {
|
||||
dispatch(openURL(q, routerHistory));
|
||||
},
|
||||
|
||||
onClickSearchResult (q, type) {
|
||||
dispatch(clickSearchResult(q, type));
|
||||
},
|
||||
|
||||
onForgetSearchResult (q) {
|
||||
dispatch(forgetSearchResult(q));
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Search);
|
|
@ -9,8 +9,6 @@ import { Link } from 'react-router-dom';
|
|||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import spring from 'react-motion/lib/spring';
|
||||
|
||||
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
|
||||
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
|
||||
import LogoutIcon from '@/material-icons/400-24px/logout.svg?react';
|
||||
|
@ -29,11 +27,9 @@ import elephantUIPlane from '../../../../images/elephant_ui_plane.svg';
|
|||
import { changeComposing, mountCompose, unmountCompose } from '../../actions/compose';
|
||||
import { mascot } from '../../initial_state';
|
||||
import { isMobile } from '../../is_mobile';
|
||||
import Motion from '../ui/util/optional_motion';
|
||||
|
||||
import { SearchResults } from './components/search_results';
|
||||
import { Search } from './components/search';
|
||||
import ComposeFormContainer from './containers/compose_form_container';
|
||||
import SearchContainer from './containers/search_container';
|
||||
|
||||
const messages = defineMessages({
|
||||
start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
|
||||
|
@ -46,9 +42,8 @@ const messages = defineMessages({
|
|||
compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new post' },
|
||||
});
|
||||
|
||||
const mapStateToProps = (state, ownProps) => ({
|
||||
const mapStateToProps = (state) => ({
|
||||
columns: state.getIn(['settings', 'columns']),
|
||||
showSearch: ownProps.multiColumn ? state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']) : false,
|
||||
unreadNotifications: state.getIn(['notifications', 'unread']),
|
||||
showNotificationsBadge: state.getIn(['local_settings', 'notifications', 'tab_badge']),
|
||||
});
|
||||
|
@ -64,7 +59,6 @@ class Compose extends PureComponent {
|
|||
dispatch: PropTypes.func.isRequired,
|
||||
columns: ImmutablePropTypes.list.isRequired,
|
||||
multiColumn: PropTypes.bool,
|
||||
showSearch: PropTypes.bool,
|
||||
unreadNotifications: PropTypes.number,
|
||||
showNotificationsBadge: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
|
@ -117,7 +111,7 @@ class Compose extends PureComponent {
|
|||
};
|
||||
|
||||
render () {
|
||||
const { multiColumn, showSearch, showNotificationsBadge, unreadNotifications, intl } = this.props;
|
||||
const { multiColumn, showNotificationsBadge, unreadNotifications, intl } = this.props;
|
||||
|
||||
const elefriend = [glitchedElephant1, glitchedElephant2, glitchedElephant3, elephantUIPlane][this.state.elefriend];
|
||||
|
||||
|
@ -157,7 +151,7 @@ class Compose extends PureComponent {
|
|||
<a href='/auth/sign_out' className='drawer__tab' title={intl.formatMessage(messages.logout)} aria-label={intl.formatMessage(messages.logout)} onClick={this.handleLogoutClick}><Icon id='sign-out' icon={LogoutIcon} /></a>
|
||||
</nav>
|
||||
|
||||
{multiColumn && <SearchContainer /> }
|
||||
{multiColumn && <Search /> }
|
||||
|
||||
<div className='drawer__pager'>
|
||||
<div className='drawer__inner' onFocus={this.onFocus}>
|
||||
|
@ -168,14 +162,6 @@ class Compose extends PureComponent {
|
|||
<img alt='' draggable='false' src={mascot || elefriend} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
|
||||
{({ x }) => (
|
||||
<div className='drawer__inner darker' style={{ transform: `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
|
||||
<SearchResults />
|
||||
</div>
|
||||
)}
|
||||
</Motion>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -63,19 +63,6 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
|
|||
const sharedCWState = useSelector(state => state.getIn(['state', 'content_warnings', 'shared_state']));
|
||||
const [expanded, setExpanded] = useState(undefined);
|
||||
|
||||
const parseClick = useCallback((e, destination) => {
|
||||
if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey)) {
|
||||
if (destination === undefined) {
|
||||
if (unread) {
|
||||
dispatch(markConversationRead(id));
|
||||
}
|
||||
destination = `/statuses/${lastStatus.get('id')}`;
|
||||
}
|
||||
history.push(destination);
|
||||
e.preventDefault();
|
||||
}
|
||||
}, [dispatch, history, unread, id, lastStatus]);
|
||||
|
||||
const handleMouseEnter = useCallback(({ currentTarget }) => {
|
||||
if (autoPlayGif) {
|
||||
return;
|
||||
|
@ -215,7 +202,7 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
|
|||
|
||||
<StatusContent
|
||||
status={lastStatus}
|
||||
parseClick={parseClick}
|
||||
onClick={handleClick}
|
||||
expanded={sharedCWState ? lastStatus.get('hidden') : expanded}
|
||||
onExpandedToggle={handleShowMore}
|
||||
collapsible
|
||||
|
|
|
@ -18,7 +18,8 @@ import {
|
|||
fetchDirectory,
|
||||
expandDirectory,
|
||||
} from 'flavours/glitch/actions/directory';
|
||||
import Column from 'flavours/glitch/components/column';
|
||||
import { Column } from 'flavours/glitch/components/column';
|
||||
import type { ColumnRef } from 'flavours/glitch/components/column';
|
||||
import { ColumnHeader } from 'flavours/glitch/components/column_header';
|
||||
import { LoadMore } from 'flavours/glitch/components/load_more';
|
||||
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
||||
|
@ -52,7 +53,7 @@ export const Directory: React.FC<{
|
|||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const column = useRef<Column>(null);
|
||||
const column = useRef<ColumnRef>(null);
|
||||
|
||||
const [orderParam, setOrderParam] = useSearchParam('order');
|
||||
const [localParam, setLocalParam] = useSearchParam('local');
|
||||
|
|
|
@ -1,20 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
export const SearchSection = ({ title, onClickMore, children }) => (
|
||||
<div className='search-results__section'>
|
||||
<div className='search-results__section__header'>
|
||||
<h3>{title}</h3>
|
||||
{onClickMore && <button onClick={onClickMore}><FormattedMessage id='search_results.see_all' defaultMessage='See all' /></button>}
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
SearchSection.propTypes = {
|
||||
title: PropTypes.node.isRequired,
|
||||
onClickMore: PropTypes.func,
|
||||
children: PropTypes.children,
|
||||
};
|
|
@ -1,114 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { NavLink, Switch, Route } from 'react-router-dom';
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import ExploreIcon from '@/material-icons/400-24px/explore.svg?react';
|
||||
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
|
||||
import Column from 'flavours/glitch/components/column';
|
||||
import ColumnHeader from 'flavours/glitch/components/column_header';
|
||||
import Search from 'flavours/glitch/features/compose/containers/search_container';
|
||||
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
|
||||
import { trendsEnabled } from 'flavours/glitch/initial_state';
|
||||
|
||||
import Links from './links';
|
||||
import SearchResults from './results';
|
||||
import Statuses from './statuses';
|
||||
import Suggestions from './suggestions';
|
||||
import Tags from './tags';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'explore.title', defaultMessage: 'Explore' },
|
||||
searchResults: { id: 'explore.search_results', defaultMessage: 'Search results' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
layout: state.getIn(['meta', 'layout']),
|
||||
isSearching: state.getIn(['search', 'submitted']) || !trendsEnabled,
|
||||
});
|
||||
|
||||
class Explore extends PureComponent {
|
||||
static propTypes = {
|
||||
identity: identityContextPropShape,
|
||||
intl: PropTypes.object.isRequired,
|
||||
multiColumn: PropTypes.bool,
|
||||
isSearching: PropTypes.bool,
|
||||
};
|
||||
|
||||
handleHeaderClick = () => {
|
||||
this.column.scrollTop();
|
||||
};
|
||||
|
||||
setRef = c => {
|
||||
this.column = c;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { intl, multiColumn, isSearching } = this.props;
|
||||
const { signedIn } = this.props.identity;
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
|
||||
<ColumnHeader
|
||||
icon={isSearching ? 'search' : 'explore'}
|
||||
iconComponent={isSearching ? SearchIcon : ExploreIcon}
|
||||
title={intl.formatMessage(isSearching ? messages.searchResults : messages.title)}
|
||||
onClick={this.handleHeaderClick}
|
||||
multiColumn={multiColumn}
|
||||
/>
|
||||
|
||||
<div className='explore__search-header'>
|
||||
<Search />
|
||||
</div>
|
||||
|
||||
{isSearching ? (
|
||||
<SearchResults />
|
||||
) : (
|
||||
<>
|
||||
<div className='account__section-headline'>
|
||||
<NavLink exact to='/explore'>
|
||||
<FormattedMessage tagName='div' id='explore.trending_statuses' defaultMessage='Posts' />
|
||||
</NavLink>
|
||||
|
||||
<NavLink exact to='/explore/tags'>
|
||||
<FormattedMessage tagName='div' id='explore.trending_tags' defaultMessage='Hashtags' />
|
||||
</NavLink>
|
||||
|
||||
{signedIn && (
|
||||
<NavLink exact to='/explore/suggestions'>
|
||||
<FormattedMessage tagName='div' id='explore.suggested_follows' defaultMessage='People' />
|
||||
</NavLink>
|
||||
)}
|
||||
|
||||
<NavLink exact to='/explore/links'>
|
||||
<FormattedMessage tagName='div' id='explore.trending_links' defaultMessage='News' />
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<Switch>
|
||||
<Route path='/explore/tags' component={Tags} />
|
||||
<Route path='/explore/links' component={Links} />
|
||||
<Route path='/explore/suggestions' component={Suggestions} />
|
||||
<Route exact path={['/explore', '/explore/posts', '/search']}>
|
||||
<Statuses multiColumn={multiColumn} />
|
||||
</Route>
|
||||
</Switch>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.title)}</title>
|
||||
<meta name='robots' content={isSearching ? 'noindex' : 'all'} />
|
||||
</Helmet>
|
||||
</>
|
||||
)}
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default withIdentity(connect(mapStateToProps)(injectIntl(Explore)));
|
105
app/javascript/flavours/glitch/features/explore/index.tsx
Normal file
105
app/javascript/flavours/glitch/features/explore/index.tsx
Normal file
|
@ -0,0 +1,105 @@
|
|||
import { useCallback, useRef } from 'react';
|
||||
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { NavLink, Switch, Route } from 'react-router-dom';
|
||||
|
||||
import ExploreIcon from '@/material-icons/400-24px/explore.svg?react';
|
||||
import { Column } from 'flavours/glitch/components/column';
|
||||
import type { ColumnRef } from 'flavours/glitch/components/column';
|
||||
import { ColumnHeader } from 'flavours/glitch/components/column_header';
|
||||
import { Search } from 'flavours/glitch/features/compose/components/search';
|
||||
import { useIdentity } from 'flavours/glitch/identity_context';
|
||||
|
||||
import Links from './links';
|
||||
import Statuses from './statuses';
|
||||
import Suggestions from './suggestions';
|
||||
import Tags from './tags';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'explore.title', defaultMessage: 'Explore' },
|
||||
});
|
||||
|
||||
const Explore: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
||||
const { signedIn } = useIdentity();
|
||||
const intl = useIntl();
|
||||
const columnRef = useRef<ColumnRef>(null);
|
||||
|
||||
const handleHeaderClick = useCallback(() => {
|
||||
columnRef.current?.scrollTop();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Column
|
||||
bindToDocument={!multiColumn}
|
||||
ref={columnRef}
|
||||
label={intl.formatMessage(messages.title)}
|
||||
>
|
||||
<ColumnHeader
|
||||
icon={'explore'}
|
||||
iconComponent={ExploreIcon}
|
||||
title={intl.formatMessage(messages.title)}
|
||||
onClick={handleHeaderClick}
|
||||
multiColumn={multiColumn}
|
||||
/>
|
||||
|
||||
<div className='explore__search-header'>
|
||||
<Search singleColumn />
|
||||
</div>
|
||||
|
||||
<div className='account__section-headline'>
|
||||
<NavLink exact to='/explore'>
|
||||
<FormattedMessage
|
||||
tagName='div'
|
||||
id='explore.trending_statuses'
|
||||
defaultMessage='Posts'
|
||||
/>
|
||||
</NavLink>
|
||||
|
||||
<NavLink exact to='/explore/tags'>
|
||||
<FormattedMessage
|
||||
tagName='div'
|
||||
id='explore.trending_tags'
|
||||
defaultMessage='Hashtags'
|
||||
/>
|
||||
</NavLink>
|
||||
|
||||
{signedIn && (
|
||||
<NavLink exact to='/explore/suggestions'>
|
||||
<FormattedMessage
|
||||
tagName='div'
|
||||
id='explore.suggested_follows'
|
||||
defaultMessage='People'
|
||||
/>
|
||||
</NavLink>
|
||||
)}
|
||||
|
||||
<NavLink exact to='/explore/links'>
|
||||
<FormattedMessage
|
||||
tagName='div'
|
||||
id='explore.trending_links'
|
||||
defaultMessage='News'
|
||||
/>
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<Switch>
|
||||
<Route path='/explore/tags' component={Tags} />
|
||||
<Route path='/explore/links' component={Links} />
|
||||
<Route path='/explore/suggestions' component={Suggestions} />
|
||||
<Route exact path={['/explore', '/explore/posts']}>
|
||||
<Statuses multiColumn={multiColumn} />
|
||||
</Route>
|
||||
</Switch>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.title)}</title>
|
||||
<meta name='robots' content='all' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default Explore;
|
|
@ -1,232 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import FindInPageIcon from '@/material-icons/400-24px/find_in_page.svg?react';
|
||||
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
|
||||
import TagIcon from '@/material-icons/400-24px/tag.svg?react';
|
||||
import { submitSearch, expandSearch } from 'flavours/glitch/actions/search';
|
||||
import { ImmutableHashtag as Hashtag } from 'flavours/glitch/components/hashtag';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import ScrollableList from 'flavours/glitch/components/scrollable_list';
|
||||
import Account from 'flavours/glitch/containers/account_container';
|
||||
import Status from 'flavours/glitch/containers/status_container';
|
||||
|
||||
import { SearchSection } from './components/search_section';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'search_results.title', defaultMessage: 'Search for {q}' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
isLoading: state.getIn(['search', 'isLoading']),
|
||||
results: state.getIn(['search', 'results']),
|
||||
q: state.getIn(['search', 'searchTerm']),
|
||||
submittedType: state.getIn(['search', 'type']),
|
||||
});
|
||||
|
||||
const INITIAL_PAGE_LIMIT = 10;
|
||||
const INITIAL_DISPLAY = 4;
|
||||
|
||||
const hidePeek = list => {
|
||||
if (list.size > INITIAL_PAGE_LIMIT && list.size % INITIAL_PAGE_LIMIT === 1) {
|
||||
return list.skipLast(1);
|
||||
} else {
|
||||
return list;
|
||||
}
|
||||
};
|
||||
|
||||
const renderAccounts = accounts => hidePeek(accounts).map(id => (
|
||||
<Account key={id} id={id} />
|
||||
));
|
||||
|
||||
const renderHashtags = hashtags => hidePeek(hashtags).map(hashtag => (
|
||||
<Hashtag key={hashtag.get('name')} hashtag={hashtag} />
|
||||
));
|
||||
|
||||
const renderStatuses = statuses => hidePeek(statuses).map(id => (
|
||||
<Status key={id} id={id} />
|
||||
));
|
||||
|
||||
class Results extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
results: ImmutablePropTypes.contains({
|
||||
accounts: ImmutablePropTypes.orderedSet,
|
||||
statuses: ImmutablePropTypes.orderedSet,
|
||||
hashtags: ImmutablePropTypes.orderedSet,
|
||||
}),
|
||||
isLoading: PropTypes.bool,
|
||||
multiColumn: PropTypes.bool,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
q: PropTypes.string,
|
||||
intl: PropTypes.object,
|
||||
submittedType: PropTypes.oneOf(['accounts', 'statuses', 'hashtags']),
|
||||
};
|
||||
|
||||
state = {
|
||||
type: this.props.submittedType || 'all',
|
||||
};
|
||||
|
||||
static getDerivedStateFromProps(props, state) {
|
||||
if (props.submittedType !== state.type) {
|
||||
return {
|
||||
type: props.submittedType || 'all',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
handleSelectAll = () => {
|
||||
const { submittedType, dispatch } = this.props;
|
||||
|
||||
// If we originally searched for a specific type, we need to resubmit
|
||||
// the query to get all types of results
|
||||
if (submittedType) {
|
||||
dispatch(submitSearch());
|
||||
}
|
||||
|
||||
this.setState({ type: 'all' });
|
||||
};
|
||||
|
||||
handleSelectAccounts = () => {
|
||||
const { submittedType, dispatch } = this.props;
|
||||
|
||||
// If we originally searched for something else (but not everything),
|
||||
// we need to resubmit the query for this specific type
|
||||
if (submittedType !== 'accounts') {
|
||||
dispatch(submitSearch('accounts'));
|
||||
}
|
||||
|
||||
this.setState({ type: 'accounts' });
|
||||
};
|
||||
|
||||
handleSelectHashtags = () => {
|
||||
const { submittedType, dispatch } = this.props;
|
||||
|
||||
// If we originally searched for something else (but not everything),
|
||||
// we need to resubmit the query for this specific type
|
||||
if (submittedType !== 'hashtags') {
|
||||
dispatch(submitSearch('hashtags'));
|
||||
}
|
||||
|
||||
this.setState({ type: 'hashtags' });
|
||||
};
|
||||
|
||||
handleSelectStatuses = () => {
|
||||
const { submittedType, dispatch } = this.props;
|
||||
|
||||
// If we originally searched for something else (but not everything),
|
||||
// we need to resubmit the query for this specific type
|
||||
if (submittedType !== 'statuses') {
|
||||
dispatch(submitSearch('statuses'));
|
||||
}
|
||||
|
||||
this.setState({ type: 'statuses' });
|
||||
};
|
||||
|
||||
handleLoadMoreAccounts = () => this._loadMore('accounts');
|
||||
handleLoadMoreStatuses = () => this._loadMore('statuses');
|
||||
handleLoadMoreHashtags = () => this._loadMore('hashtags');
|
||||
|
||||
_loadMore (type) {
|
||||
const { dispatch } = this.props;
|
||||
dispatch(expandSearch(type));
|
||||
}
|
||||
|
||||
handleLoadMore = () => {
|
||||
const { type } = this.state;
|
||||
|
||||
if (type !== 'all') {
|
||||
this._loadMore(type);
|
||||
}
|
||||
};
|
||||
|
||||
render () {
|
||||
const { intl, isLoading, q, results } = this.props;
|
||||
const { type } = this.state;
|
||||
|
||||
// We request 1 more result than we display so we can tell if there'd be a next page
|
||||
const hasMore = type !== 'all' ? results.get(type, ImmutableList()).size > INITIAL_PAGE_LIMIT && results.get(type).size % INITIAL_PAGE_LIMIT === 1 : false;
|
||||
|
||||
let filteredResults;
|
||||
|
||||
const accounts = results.get('accounts', ImmutableList());
|
||||
const hashtags = results.get('hashtags', ImmutableList());
|
||||
const statuses = results.get('statuses', ImmutableList());
|
||||
|
||||
switch(type) {
|
||||
case 'all':
|
||||
filteredResults = (accounts.size + hashtags.size + statuses.size) > 0 ? (
|
||||
<>
|
||||
{accounts.size > 0 && (
|
||||
<SearchSection key='accounts' title={<><Icon id='users' icon={PeopleIcon} /><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></>} onClickMore={this.handleLoadMoreAccounts}>
|
||||
{accounts.take(INITIAL_DISPLAY).map(id => <Account key={id} id={id} />)}
|
||||
</SearchSection>
|
||||
)}
|
||||
|
||||
{hashtags.size > 0 && (
|
||||
<SearchSection key='hashtags' title={<><Icon id='hashtag' icon={TagIcon} /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></>} onClickMore={this.handleLoadMoreHashtags}>
|
||||
{hashtags.take(INITIAL_DISPLAY).map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
|
||||
</SearchSection>
|
||||
)}
|
||||
|
||||
{statuses.size > 0 && (
|
||||
<SearchSection key='statuses' title={<><Icon id='quote-right' icon={FindInPageIcon} /><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></>} onClickMore={this.handleLoadMoreStatuses}>
|
||||
{statuses.take(INITIAL_DISPLAY).map(id => <Status key={id} id={id} />)}
|
||||
</SearchSection>
|
||||
)}
|
||||
</>
|
||||
) : [];
|
||||
break;
|
||||
case 'accounts':
|
||||
filteredResults = renderAccounts(accounts);
|
||||
break;
|
||||
case 'hashtags':
|
||||
filteredResults = renderHashtags(hashtags);
|
||||
break;
|
||||
case 'statuses':
|
||||
filteredResults = renderStatuses(statuses);
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='account__section-headline'>
|
||||
<button onClick={this.handleSelectAll} className={type === 'all' ? 'active' : undefined}><FormattedMessage id='search_results.all' defaultMessage='All' /></button>
|
||||
<button onClick={this.handleSelectAccounts} className={type === 'accounts' ? 'active' : undefined}><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></button>
|
||||
<button onClick={this.handleSelectHashtags} className={type === 'hashtags' ? 'active' : undefined}><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></button>
|
||||
<button onClick={this.handleSelectStatuses} className={type === 'statuses' ? 'active' : undefined}><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></button>
|
||||
</div>
|
||||
|
||||
<div className='explore__search-results' data-nosnippet>
|
||||
<ScrollableList
|
||||
scrollKey='search-results'
|
||||
isLoading={isLoading}
|
||||
onLoadMore={this.handleLoadMore}
|
||||
hasMore={hasMore}
|
||||
emptyMessage={<FormattedMessage id='search_results.nothing_found' defaultMessage='Could not find anything for these search terms' />}
|
||||
bindToDocument
|
||||
>
|
||||
{filteredResults}
|
||||
</ScrollableList>
|
||||
</div>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.title, { q })}</title>
|
||||
</Helmet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(injectIntl(Results));
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue