Compare commits
98 Commits
fixes/refr
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15a7507a09 | ||
|
|
cdf721a273 | ||
|
|
cafe7ea35c | ||
|
|
e18ca373eb | ||
|
|
e54f927149 | ||
|
|
6735902c1a | ||
|
|
dc3ffac4a2 | ||
|
|
fe885d5788 | ||
|
|
adfe7242f7 | ||
|
|
dfcfef38af | ||
|
|
fbc116ef90 | ||
|
|
6b5e18fb1d | ||
|
|
d39f7bc72f | ||
|
|
e68c1c824a | ||
|
|
076c8ec51e | ||
|
|
f5b57e8ba7 | ||
|
|
0786c1e57a | ||
|
|
ec2a99341c | ||
|
|
6e7e8de343 | ||
|
|
a444a0b572 | ||
|
|
6f8558a6b9 | ||
|
|
22203f8aeb | ||
|
|
f28715d370 | ||
|
|
fcf012c602 | ||
|
|
dee1dc41aa | ||
|
|
655de32990 | ||
|
|
99db6a1910 | ||
|
|
d5a7b383fa | ||
|
|
34c91555ae | ||
|
|
bd77f2e86d | ||
|
|
de7282d1cd | ||
|
|
7f5b16a6ad | ||
|
|
e3f81c7368 | ||
|
|
b36c121a53 | ||
|
|
b3992e62ed | ||
|
|
1232b55211 | ||
|
|
5f33cf0b0a | ||
|
|
c3afdb760c | ||
|
|
40f5533990 | ||
|
|
eec97e387a | ||
|
|
eea90c205a | ||
|
|
7592813e15 | ||
|
|
f0204f3e62 | ||
|
|
01434ad4b6 | ||
|
|
c26003af21 | ||
|
|
07a05e1edf | ||
|
|
2402730083 | ||
|
|
28ae61f34d | ||
|
|
dcb6dbbc86 | ||
|
|
99b72f60ad | ||
|
|
19914e9ef6 | ||
|
|
a05d2d7ee2 | ||
|
|
19b19ad8c2 | ||
|
|
8f47470853 | ||
|
|
75024a1778 | ||
|
|
db304735bf | ||
|
|
bb94f91f86 | ||
|
|
cdf48e806d | ||
|
|
b946b8679d | ||
|
|
3294b5777f | ||
|
|
f095346f8f | ||
|
|
d70c807a76 | ||
|
|
630ad9fd49 | ||
|
|
8bbde181db | ||
|
|
13fbf00a97 | ||
|
|
0ef5dca3c8 | ||
|
|
771fdcbb9f | ||
|
|
758db36ec7 | ||
|
|
bbb3392dbe | ||
|
|
cb5c5432b3 | ||
|
|
7c05f56fe8 | ||
|
|
d2f640272f | ||
|
|
2f1bbe051c | ||
|
|
a547dfff37 | ||
|
|
735a00d741 | ||
|
|
a6a8a37ae1 | ||
|
|
82ce9367c3 | ||
|
|
e081d5936c | ||
|
|
c3a1e04692 | ||
|
|
817a0a6764 | ||
|
|
c45287c72d | ||
|
|
2a890822e3 | ||
|
|
2e7df27180 | ||
|
|
8e03c9c1fc | ||
|
|
b1f7c9641f | ||
|
|
e7ed8bb682 | ||
|
|
dcc26c1b24 | ||
|
|
8d6406f561 | ||
|
|
2543425e04 | ||
|
|
cf7a092053 | ||
|
|
86e4ecfa20 | ||
|
|
658ad9f57e | ||
|
|
b71333921b | ||
|
|
9ff094b62e | ||
|
|
b2aa476abb | ||
|
|
496d41cdce | ||
|
|
674e2685be | ||
|
|
fcd56d6732 |
@@ -1,6 +1,6 @@
|
||||
defaults
|
||||
> 0.2%
|
||||
firefox >= 78
|
||||
> 0.2% and not ios < 15.6
|
||||
firefox >= 91
|
||||
ios >= 15.6
|
||||
not dead
|
||||
not OperaMini all
|
||||
|
||||
@@ -59,7 +59,7 @@ body:
|
||||
Any additional technical details you may have, like logs or error traces
|
||||
value: |
|
||||
If this is happening on your own Mastodon server, please fill out those:
|
||||
- Ruby version: (from `ruby --version`, eg. v4.0.3)
|
||||
- Ruby version: (from `ruby --version`, eg. v4.0.5)
|
||||
- Node.js version: (from `node --version`, eg. v22.16.0)
|
||||
validations:
|
||||
required: false
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/3.troubleshooting.yml
vendored
2
.github/ISSUE_TEMPLATE/3.troubleshooting.yml
vendored
@@ -61,7 +61,7 @@ body:
|
||||
value: |
|
||||
Please at least include those informations:
|
||||
- Operating system: (eg. Ubuntu 24.04.2)
|
||||
- Ruby version: (from `ruby --version`, eg. v4.0.3)
|
||||
- Ruby version: (from `ruby --version`, eg. v4.0.5)
|
||||
- Node.js version: (from `node --version`, eg. v22.16.0)
|
||||
validations:
|
||||
required: false
|
||||
|
||||
10
.github/actions/setup-ruby/action.yml
vendored
10
.github/actions/setup-ruby/action.yml
vendored
@@ -14,10 +14,16 @@ runs:
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install --no-install-recommends -y libicu-dev libidn11-dev libvips42 ${{ inputs.additional-system-dependencies }}
|
||||
sudo apt-get install --no-install-recommends -y \
|
||||
libicu-dev \
|
||||
libidn11-dev \
|
||||
libvips42 \
|
||||
libheif-plugin-aomdec \
|
||||
libheif-plugin-libde265 \
|
||||
${{ inputs.additional-system-dependencies }}
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@94e4d89d3e6c1c7599e0210d114c5ffb23f1a866 # v1
|
||||
uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1
|
||||
with:
|
||||
ruby-version: ${{ inputs.ruby-version }}
|
||||
bundler-cache: true
|
||||
|
||||
4
.github/workflows/build-container-image.yml
vendored
4
.github/workflows/build-container-image.yml
vendored
@@ -87,8 +87,8 @@ jobs:
|
||||
platforms: ${{ matrix.platform }}
|
||||
provenance: false
|
||||
push: ${{ inputs.push_to_images != '' }}
|
||||
cache-from: ${{ inputs.cache && 'type=gha' || '' }}
|
||||
cache-to: ${{ inputs.cache && 'type=gha,mode=max' || '' }}
|
||||
cache-from: ${{ inputs.cache && format('type=gha,scope=build-{0}-{1}', hashFiles(inputs.file_to_build), env.PLATFORM_PAIR) || '' }}
|
||||
cache-to: ${{ inputs.cache && format('type=gha,mode=max,scope=build-{0}-{1}', hashFiles(inputs.file_to_build), env.PLATFORM_PAIR) || '' }}
|
||||
outputs: type=image,"name=${{ env.IMAGE_NAMES }}",push-by-digest=true,name-canonical=true,push=${{ inputs.push_to_images != '' }}
|
||||
|
||||
- name: Export digest
|
||||
|
||||
2
.github/workflows/bundler-audit.yml
vendored
2
.github/workflows/bundler-audit.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@94e4d89d3e6c1c7599e0210d114c5ffb23f1a866 # v1
|
||||
uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
|
||||
2
.github/workflows/chromatic.yml
vendored
2
.github/workflows/chromatic.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3
|
||||
- uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
|
||||
6
.github/workflows/codeql.yml
vendored
6
.github/workflows/codeql.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4
|
||||
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@e46ed2cbd01164d986452f91f178727624ae40d7 # v4
|
||||
uses: github/codeql-action/autobuild@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
@@ -61,6 +61,6 @@ jobs:
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4
|
||||
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
|
||||
2
.github/workflows/crowdin-download.yml
vendored
2
.github/workflows/crowdin-download.yml
vendored
@@ -52,7 +52,7 @@ jobs:
|
||||
|
||||
# Create or update the pull request
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8
|
||||
with:
|
||||
commit-message: 'New Crowdin translations'
|
||||
title: 'New Crowdin Translations (automated)'
|
||||
|
||||
2
.github/workflows/lint-css.yml
vendored
2
.github/workflows/lint-css.yml
vendored
@@ -13,7 +13,6 @@ on:
|
||||
- '**/*.css'
|
||||
- '**/*.scss'
|
||||
- '.github/workflows/lint-css.yml'
|
||||
- '.github/stylelint-matcher.json'
|
||||
|
||||
pull_request:
|
||||
paths:
|
||||
@@ -24,7 +23,6 @@ on:
|
||||
- '**/*.css'
|
||||
- '**/*.scss'
|
||||
- '.github/workflows/lint-css.yml'
|
||||
- '.github/stylelint-matcher.json'
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
|
||||
4
.github/workflows/lint-haml.yml
vendored
4
.github/workflows/lint-haml.yml
vendored
@@ -6,7 +6,6 @@ on:
|
||||
- 'main'
|
||||
- 'stable-*'
|
||||
paths:
|
||||
- '.github/workflows/haml-lint-problem-matcher.json'
|
||||
- '.github/workflows/lint-haml.yml'
|
||||
- '.haml-lint*.yml'
|
||||
- '.rubocop*.yml'
|
||||
@@ -16,7 +15,6 @@ on:
|
||||
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/haml-lint-problem-matcher.json'
|
||||
- '.github/workflows/lint-haml.yml'
|
||||
- '.haml-lint*.yml'
|
||||
- '.rubocop*.yml'
|
||||
@@ -36,7 +34,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@94e4d89d3e6c1c7599e0210d114c5ffb23f1a866 # v1
|
||||
uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
|
||||
4
.github/workflows/lint-ruby.yml
vendored
4
.github/workflows/lint-ruby.yml
vendored
@@ -10,7 +10,6 @@ on:
|
||||
- '.rubocop*.yml'
|
||||
- '.ruby-version'
|
||||
- 'bin/rubocop'
|
||||
- 'config/brakeman.ignore'
|
||||
- '**/*.rb'
|
||||
- '**/*.rake'
|
||||
- '.github/workflows/lint-ruby.yml'
|
||||
@@ -21,7 +20,6 @@ on:
|
||||
- '.rubocop*.yml'
|
||||
- '.ruby-version'
|
||||
- 'bin/rubocop'
|
||||
- 'config/brakeman.ignore'
|
||||
- '**/*.rb'
|
||||
- '**/*.rake'
|
||||
- '.github/workflows/lint-ruby.yml'
|
||||
@@ -38,7 +36,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@94e4d89d3e6c1c7599e0210d114c5ffb23f1a866 # v1
|
||||
uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
|
||||
4
.github/workflows/test-ruby.yml
vendored
4
.github/workflows/test-ruby.yml
vendored
@@ -163,11 +163,11 @@ jobs:
|
||||
rspec-persistence-main
|
||||
rspec-persistence
|
||||
|
||||
- run: bin/flatware rspec -r ./spec/flatware_helper.rb
|
||||
- run: bin/flatware rspec
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: matrix.ruby-version == '.ruby-version'
|
||||
uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6
|
||||
with:
|
||||
files: coverage/lcov/*.lcov
|
||||
env:
|
||||
|
||||
@@ -1 +1 @@
|
||||
4.0.3
|
||||
4.0.5
|
||||
|
||||
26
.simplecov
Normal file
26
.simplecov
Normal file
@@ -0,0 +1,26 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
SimpleCov.start 'rails' do
|
||||
# During parallel runs, ensure unique names for post-run merge
|
||||
command_name "job-#{ENV['TEST_ENV_NUMBER']}" if ENV['TEST_ENV_NUMBER']
|
||||
|
||||
if ENV['CI']
|
||||
require 'simplecov-lcov'
|
||||
formatter SimpleCov::Formatter::LcovFormatter
|
||||
formatter.config.report_with_single_file = true
|
||||
else
|
||||
formatter SimpleCov::Formatter::HTMLFormatter
|
||||
end
|
||||
|
||||
enable_coverage :branch
|
||||
|
||||
add_filter 'lib/linter'
|
||||
|
||||
add_group 'Libraries', 'lib'
|
||||
add_group 'Policies', 'app/policies'
|
||||
add_group 'Presenters', 'app/presenters'
|
||||
add_group 'Search', 'app/chewy'
|
||||
add_group 'Serializers', 'app/serializers'
|
||||
add_group 'Services', 'app/services'
|
||||
add_group 'Validators', 'app/validators'
|
||||
end
|
||||
16
CHANGELOG.md
16
CHANGELOG.md
@@ -2,6 +2,22 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [4.5.10] - 2026-05-20
|
||||
|
||||
### Security
|
||||
|
||||
- Fix SSRF protection bypass ([GHSA-crr4-7rm4-8gpw](https://github.com/mastodon/mastodon/security/advisories/GHSA-crr4-7rm4-8gpw), [GHSA-xx55-4rrg-8xg6](https://github.com/mastodon/mastodon/security/advisories/GHSA-xx55-4rrg-8xg6))
|
||||
- Fix Linked-Data Signature bypass through JSON-LD graph restructuring features ([GHSA-53m7-2wrh-q839](https://github.com/mastodon/mastodon/security/advisories/GHSA-53m7-2wrh-q839), [GHSA-chgx-jx3p-rf73](https://github.com/mastodon/mastodon/security/advisories/GHSA-chgx-jx3p-rf73))
|
||||
- Updated dependencies
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix type of `interactingObject`, `interactionTarget` and add missing `QuoteAuthorization` (#38940 by @ClearlyClaire)
|
||||
|
||||
### Removed
|
||||
|
||||
- Remove unused devise strategies (#38795 by @ClearlyClaire)
|
||||
|
||||
## [4.5.9] - 2026-04-15
|
||||
|
||||
### Security
|
||||
|
||||
13
Dockerfile
13
Dockerfile
@@ -13,7 +13,7 @@ ARG BASE_REGISTRY="docker.io"
|
||||
|
||||
# Ruby image to use for base image, change with [--build-arg RUBY_VERSION="4.0.x"]
|
||||
# renovate: datasource=docker depName=docker.io/ruby
|
||||
ARG RUBY_VERSION="4.0.3"
|
||||
ARG RUBY_VERSION="4.0.5"
|
||||
# # Node.js version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="22"]
|
||||
# renovate: datasource=node-version depName=node
|
||||
ARG NODE_MAJOR_VERSION="24"
|
||||
@@ -340,10 +340,13 @@ COPY --from=node /usr/local/bin /usr/local/bin
|
||||
COPY --from=node /usr/local/lib /usr/local/lib
|
||||
|
||||
RUN \
|
||||
# Configure Corepack
|
||||
rm /usr/local/bin/yarn*; \
|
||||
npm i -g corepack; \
|
||||
corepack prepare --activate;
|
||||
# Mount local Corepack and Yarn caches from Docker buildx caches
|
||||
--mount=type=cache,id=corepack-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/corepack,sharing=locked \
|
||||
--mount=type=cache,id=yarn-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/yarn,sharing=locked \
|
||||
# Remove pre-installed Yarn binaries (only present on Node <26)
|
||||
rm -f /usr/local/bin/yarn*; \
|
||||
# Install Corepack
|
||||
npm i -g corepack;
|
||||
|
||||
# hadolint ignore=DL3008
|
||||
RUN \
|
||||
|
||||
4
Gemfile
4
Gemfile
@@ -102,10 +102,10 @@ gem 'rdf-normalize', '~> 0.5'
|
||||
|
||||
gem 'prometheus_exporter', '~> 2.2', require: false
|
||||
|
||||
gem 'opentelemetry-api', '~> 1.9.0'
|
||||
gem 'opentelemetry-api', '~> 1.10.0'
|
||||
|
||||
group :opentelemetry do
|
||||
gem 'opentelemetry-exporter-otlp', '~> 0.33.0', require: false
|
||||
gem 'opentelemetry-exporter-otlp', '~> 0.34.0', require: false
|
||||
gem 'opentelemetry-instrumentation-active_job', '~> 0.12.0', require: false
|
||||
gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.25.0', require: false
|
||||
gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.25.0', require: false
|
||||
|
||||
52
Gemfile.lock
52
Gemfile.lock
@@ -99,8 +99,8 @@ GEM
|
||||
ast (2.4.3)
|
||||
attr_required (1.0.2)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1242.0)
|
||||
aws-sdk-core (3.246.0)
|
||||
aws-partitions (1.1249.0)
|
||||
aws-sdk-core (3.247.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
@@ -108,11 +108,11 @@ GEM
|
||||
bigdecimal
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
logger
|
||||
aws-sdk-kms (1.124.0)
|
||||
aws-sdk-core (~> 3, >= 3.244.0)
|
||||
aws-sdk-kms (1.125.0)
|
||||
aws-sdk-core (~> 3, >= 3.247.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.220.0)
|
||||
aws-sdk-core (~> 3, >= 3.244.0)
|
||||
aws-sdk-s3 (1.222.0)
|
||||
aws-sdk-core (~> 3, >= 3.247.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.12.1)
|
||||
@@ -132,7 +132,7 @@ GEM
|
||||
binding_of_caller (2.0.0)
|
||||
debug_inspector (>= 1.2.0)
|
||||
blurhash (0.1.8)
|
||||
bootsnap (1.24.1)
|
||||
bootsnap (1.24.4)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (8.0.4)
|
||||
racc
|
||||
@@ -190,7 +190,7 @@ GEM
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
debug_inspector (1.2.0)
|
||||
devise (5.0.3)
|
||||
devise (5.0.4)
|
||||
bcrypt (~> 3.0)
|
||||
orm_adapter (~> 0.1)
|
||||
railties (>= 7.0)
|
||||
@@ -305,8 +305,8 @@ GEM
|
||||
json
|
||||
highline (3.1.2)
|
||||
reline
|
||||
hiredis-client (0.28.0)
|
||||
redis-client (= 0.28.0)
|
||||
hiredis-client (0.29.0)
|
||||
redis-client (= 0.29.0)
|
||||
hkdf (0.3.0)
|
||||
htmlentities (4.4.2)
|
||||
http (5.3.1)
|
||||
@@ -354,7 +354,7 @@ GEM
|
||||
azure-blob (~> 0.5.2)
|
||||
hashie (~> 5.0)
|
||||
jmespath (1.6.2)
|
||||
json (2.19.4)
|
||||
json (2.19.5)
|
||||
json-canonicalization (1.0.0)
|
||||
json-jwt (1.17.0)
|
||||
activesupport (>= 4.2)
|
||||
@@ -449,7 +449,7 @@ GEM
|
||||
mime-types-data (3.2026.0414)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.9)
|
||||
minitest (6.0.5)
|
||||
minitest (6.0.6)
|
||||
drb (~> 2.0)
|
||||
prism (~> 1.5)
|
||||
msgpack (1.8.0)
|
||||
@@ -507,11 +507,11 @@ GEM
|
||||
openssl (4.0.1)
|
||||
openssl-signature_algorithm (1.3.0)
|
||||
openssl (> 2.0)
|
||||
opentelemetry-api (1.9.0)
|
||||
opentelemetry-api (1.10.0)
|
||||
logger
|
||||
opentelemetry-common (0.24.0)
|
||||
opentelemetry-common (0.25.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-exporter-otlp (0.33.0)
|
||||
opentelemetry-exporter-otlp (0.34.0)
|
||||
google-protobuf (>= 3.18)
|
||||
googleapis-common-protos-types (~> 1.3)
|
||||
opentelemetry-api (~> 1.1)
|
||||
@@ -574,19 +574,19 @@ GEM
|
||||
opentelemetry-instrumentation-base (~> 0.25)
|
||||
opentelemetry-instrumentation-sidekiq (0.29.0)
|
||||
opentelemetry-instrumentation-base (~> 0.25)
|
||||
opentelemetry-registry (0.5.0)
|
||||
opentelemetry-registry (0.6.0)
|
||||
opentelemetry-api (~> 1.1)
|
||||
opentelemetry-sdk (1.11.0)
|
||||
opentelemetry-sdk (1.12.0)
|
||||
logger
|
||||
opentelemetry-api (~> 1.1)
|
||||
opentelemetry-common (~> 0.20)
|
||||
opentelemetry-registry (~> 0.2)
|
||||
opentelemetry-semantic_conventions
|
||||
opentelemetry-semantic_conventions (1.37.0)
|
||||
opentelemetry-semantic_conventions (1.37.1)
|
||||
opentelemetry-api (~> 1.0)
|
||||
orm_adapter (0.5.0)
|
||||
ostruct (0.6.3)
|
||||
ox (2.14.25)
|
||||
ox (2.14.26)
|
||||
bigdecimal (>= 3.0)
|
||||
parallel (1.28.0)
|
||||
parser (3.3.11.1)
|
||||
@@ -709,7 +709,7 @@ GEM
|
||||
redcarpet (3.6.1)
|
||||
redis (5.4.1)
|
||||
redis-client (>= 0.22.0)
|
||||
redis-client (0.28.0)
|
||||
redis-client (0.29.0)
|
||||
connection_pool
|
||||
regexp_parser (2.12.0)
|
||||
reline (0.6.3)
|
||||
@@ -816,12 +816,12 @@ GEM
|
||||
securerandom (0.4.1)
|
||||
shoulda-matchers (7.0.1)
|
||||
activesupport (>= 7.1)
|
||||
sidekiq (8.1.3)
|
||||
sidekiq (8.1.5)
|
||||
connection_pool (>= 3.0.0)
|
||||
json (>= 2.16.0)
|
||||
logger (>= 1.7.0)
|
||||
rack (>= 3.2.0)
|
||||
redis-client (>= 0.26.0)
|
||||
redis-client (>= 0.29.0)
|
||||
sidekiq-bulk (0.2.0)
|
||||
sidekiq
|
||||
sidekiq-scheduler (6.0.2)
|
||||
@@ -851,7 +851,7 @@ GEM
|
||||
concurrent-ruby
|
||||
zeitwerk
|
||||
stringio (3.2.0)
|
||||
strong_migrations (2.7.0)
|
||||
strong_migrations (2.8.0)
|
||||
activerecord (>= 7.2)
|
||||
swd (2.0.3)
|
||||
activesupport (>= 3)
|
||||
@@ -1020,8 +1020,8 @@ DEPENDENCIES
|
||||
omniauth-rails_csrf_protection (~> 2.0)
|
||||
omniauth-saml (~> 2.0)
|
||||
omniauth_openid_connect (~> 0.8.0)
|
||||
opentelemetry-api (~> 1.9.0)
|
||||
opentelemetry-exporter-otlp (~> 0.33.0)
|
||||
opentelemetry-api (~> 1.10.0)
|
||||
opentelemetry-exporter-otlp (~> 0.34.0)
|
||||
opentelemetry-instrumentation-active_job (~> 0.12.0)
|
||||
opentelemetry-instrumentation-active_model_serializers (~> 0.25.0)
|
||||
opentelemetry-instrumentation-concurrent_ruby (~> 0.25.0)
|
||||
@@ -1097,7 +1097,7 @@ DEPENDENCIES
|
||||
xorcist (~> 1.1)
|
||||
|
||||
RUBY VERSION
|
||||
ruby 4.0.3
|
||||
ruby 4.0.5
|
||||
|
||||
BUNDLED WITH
|
||||
4.0.11
|
||||
|
||||
@@ -59,7 +59,7 @@ Mastodon is a **free, open-source social network server** based on [ActivityPub]
|
||||
- **Ruby** 3.3+
|
||||
- **PostgreSQL** 14+
|
||||
- **Redis** 7.0+
|
||||
- **Node.js** 20+
|
||||
- **Node.js** 22+
|
||||
- **FFmpeg** 5.1+
|
||||
|
||||
This repository includes deployment configurations for **Docker and docker-compose**, as well as for other environments like Heroku and Scalingo. For Helm charts, reference the [mastodon/chart repository](https://github.com/mastodon/chart). A [**standalone** installation guide](https://docs.joinmastodon.org/admin/install/) is available in the main documentation.
|
||||
|
||||
2
Vagrantfile
vendored
2
Vagrantfile
vendored
@@ -12,7 +12,7 @@ sudo apt-add-repository 'deb https://dl.yarnpkg.com/debian/ stable main'
|
||||
# Add repo for NodeJS
|
||||
sudo mkdir -p /etc/apt/keyrings
|
||||
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
|
||||
NODE_MAJOR=20
|
||||
NODE_MAJOR=24
|
||||
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | sudo tee /etc/apt/sources.list.d/nodesource.list
|
||||
sudo apt-get update
|
||||
|
||||
|
||||
@@ -4,13 +4,48 @@ module Admin
|
||||
class CollectionsController < BaseController
|
||||
before_action :set_account
|
||||
before_action :set_collection, only: :show
|
||||
before_action :set_collections, except: :show
|
||||
|
||||
PER_PAGE = 20
|
||||
|
||||
def index
|
||||
authorize [:admin, :collection], :index?
|
||||
@collection_batch_action = Admin::CollectionBatchAction.new
|
||||
end
|
||||
|
||||
def show
|
||||
authorize @collection, :show?
|
||||
end
|
||||
|
||||
def batch
|
||||
authorize [:admin, :collection], :index?
|
||||
|
||||
@collection_batch_action = Admin::CollectionBatchAction.new(admin_collection_batch_action_params.merge(current_account: current_account, report_id: params[:report_id], type: action_from_button))
|
||||
|
||||
@collection_batch_action.save!
|
||||
rescue ActionController::ParameterMissing
|
||||
flash[:alert] = I18n.t('admin.collections.no_collection_selected')
|
||||
ensure
|
||||
redirect_to after_create_redirect_path
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def after_create_redirect_path
|
||||
report_id = @collections_batch_action&.report_id || params[:report_id]
|
||||
|
||||
if report_id.present?
|
||||
admin_report_path(report_id)
|
||||
else
|
||||
admin_account_collections_path(params[:account_id], params[:page])
|
||||
end
|
||||
end
|
||||
|
||||
def admin_collection_batch_action_params
|
||||
params
|
||||
.expect(admin_collection_batch_action: [collection_ids: []])
|
||||
end
|
||||
|
||||
def set_account
|
||||
@account = Account.find(params[:account_id])
|
||||
end
|
||||
@@ -18,5 +53,17 @@ module Admin
|
||||
def set_collection
|
||||
@collection = @account.collections.includes(accepted_collection_items: :account).find(params[:id])
|
||||
end
|
||||
|
||||
def set_collections
|
||||
@collections = @account.collections.includes(accepted_collection_items: :account).page(params[:page]).per(PER_PAGE)
|
||||
end
|
||||
|
||||
def action_from_button
|
||||
if params[:report]
|
||||
'report'
|
||||
elsif params[:remove_from_report]
|
||||
'remove_from_report'
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -16,6 +16,8 @@ module Admin
|
||||
@report_notes = @report.notes.chronological.includes(:account)
|
||||
@action_logs = @report.history.includes(:target)
|
||||
@form = Admin::StatusBatchAction.new
|
||||
@collection_form = Admin::CollectionBatchAction.new
|
||||
@collections = @report.collections
|
||||
@statuses = @report.statuses.with_includes
|
||||
end
|
||||
|
||||
|
||||
67
app/controllers/api/v1/statuses/contexts_controller.rb
Normal file
67
app/controllers/api/v1/statuses/contexts_controller.rb
Normal file
@@ -0,0 +1,67 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Statuses::ContextsController < Api::BaseController
|
||||
include Authorization
|
||||
include AsyncRefreshesConcern
|
||||
|
||||
before_action -> { authorize_if_got_token! :read, :'read:statuses' }
|
||||
before_action :set_status
|
||||
|
||||
# This API was originally unlimited, pagination cannot be introduced without
|
||||
# breaking backwards-compatibility. Arbitrarily high number to cover most
|
||||
# conversations as quasi-unlimited, it would be too much work to render more
|
||||
# than this anyway
|
||||
CONTEXT_LIMIT = 4_096
|
||||
|
||||
# This remains expensive and we don't want to show everything to logged-out users
|
||||
ANCESTORS_LIMIT = 40
|
||||
DESCENDANTS_LIMIT = 60
|
||||
DESCENDANTS_DEPTH_LIMIT = 20
|
||||
|
||||
def show
|
||||
cache_if_unauthenticated!
|
||||
|
||||
ancestors_limit = CONTEXT_LIMIT
|
||||
descendants_limit = CONTEXT_LIMIT
|
||||
descendants_depth_limit = nil
|
||||
|
||||
if current_account.nil?
|
||||
ancestors_limit = ANCESTORS_LIMIT
|
||||
descendants_limit = DESCENDANTS_LIMIT
|
||||
descendants_depth_limit = DESCENDANTS_DEPTH_LIMIT
|
||||
end
|
||||
|
||||
ancestors_results = @status.in_reply_to_id.nil? ? [] : @status.ancestors(ancestors_limit, current_account)
|
||||
descendants_results = @status.descendants(descendants_limit, current_account, descendants_depth_limit)
|
||||
loaded_ancestors = preload_collection(ancestors_results, Status)
|
||||
loaded_descendants = preload_collection(descendants_results, Status)
|
||||
|
||||
@context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants)
|
||||
statuses = [@status] + @context.ancestors + @context.descendants
|
||||
|
||||
refresh_key = "context:#{@status.id}:refresh"
|
||||
async_refresh = AsyncRefresh.new(refresh_key)
|
||||
|
||||
if async_refresh.running?
|
||||
add_async_refresh_header(async_refresh)
|
||||
elsif !current_account.nil? && @status.should_fetch_replies?
|
||||
add_async_refresh_header(AsyncRefresh.create(refresh_key, count_results: true))
|
||||
|
||||
WorkerBatch.new.within do |batch|
|
||||
batch.connect(refresh_key, threshold: 1.0)
|
||||
ActivityPub::FetchAllRepliesWorker.perform_async(@status.id, { 'batch_id' => batch.id })
|
||||
end
|
||||
end
|
||||
|
||||
render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_status
|
||||
@status = Status.find(params[:status_id])
|
||||
authorize @status, :show?
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
end
|
||||
@@ -2,14 +2,13 @@
|
||||
|
||||
class Api::V1::StatusesController < Api::BaseController
|
||||
include Authorization
|
||||
include AsyncRefreshesConcern
|
||||
include Api::InteractionPoliciesConcern
|
||||
|
||||
before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [:create, :update, :destroy]
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:create, :update, :destroy]
|
||||
before_action :require_user!, except: [:index, :show, :context]
|
||||
before_action :require_user!, except: [:index, :show]
|
||||
before_action :set_statuses, only: [:index]
|
||||
before_action :set_status, only: [:show, :context]
|
||||
before_action :set_status, only: [:show]
|
||||
before_action :set_thread, only: [:create]
|
||||
before_action :set_quoted_status, only: [:create]
|
||||
before_action :check_statuses_limit, only: [:index]
|
||||
@@ -17,17 +16,6 @@ class Api::V1::StatusesController < Api::BaseController
|
||||
override_rate_limit_headers :create, family: :statuses
|
||||
override_rate_limit_headers :update, family: :statuses
|
||||
|
||||
# This API was originally unlimited, pagination cannot be introduced without
|
||||
# breaking backwards-compatibility. Arbitrarily high number to cover most
|
||||
# conversations as quasi-unlimited, it would be too much work to render more
|
||||
# than this anyway
|
||||
CONTEXT_LIMIT = 4_096
|
||||
|
||||
# This remains expensive and we don't want to show everything to logged-out users
|
||||
ANCESTORS_LIMIT = 40
|
||||
DESCENDANTS_LIMIT = 60
|
||||
DESCENDANTS_DEPTH_LIMIT = 20
|
||||
|
||||
def index
|
||||
@statuses = preload_collection(@statuses, Status)
|
||||
render json: @statuses, each_serializer: REST::StatusSerializer
|
||||
@@ -39,44 +27,6 @@ class Api::V1::StatusesController < Api::BaseController
|
||||
render json: @status, serializer: REST::StatusSerializer
|
||||
end
|
||||
|
||||
def context
|
||||
cache_if_unauthenticated!
|
||||
|
||||
ancestors_limit = CONTEXT_LIMIT
|
||||
descendants_limit = CONTEXT_LIMIT
|
||||
descendants_depth_limit = nil
|
||||
|
||||
if current_account.nil?
|
||||
ancestors_limit = ANCESTORS_LIMIT
|
||||
descendants_limit = DESCENDANTS_LIMIT
|
||||
descendants_depth_limit = DESCENDANTS_DEPTH_LIMIT
|
||||
end
|
||||
|
||||
ancestors_results = @status.in_reply_to_id.nil? ? [] : @status.ancestors(ancestors_limit, current_account)
|
||||
descendants_results = @status.descendants(descendants_limit, current_account, descendants_depth_limit)
|
||||
loaded_ancestors = preload_collection(ancestors_results, Status)
|
||||
loaded_descendants = preload_collection(descendants_results, Status)
|
||||
|
||||
@context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants)
|
||||
statuses = [@status] + @context.ancestors + @context.descendants
|
||||
|
||||
refresh_key = "context:#{@status.id}:refresh"
|
||||
async_refresh = AsyncRefresh.new(refresh_key)
|
||||
|
||||
if async_refresh.running?
|
||||
add_async_refresh_header(async_refresh)
|
||||
elsif !current_account.nil? && @status.should_fetch_replies?
|
||||
add_async_refresh_header(AsyncRefresh.create(refresh_key, count_results: true))
|
||||
|
||||
WorkerBatch.new.within do |batch|
|
||||
batch.connect(refresh_key, threshold: 1.0)
|
||||
ActivityPub::FetchAllRepliesWorker.perform_async(@status.id, { 'batch_id' => batch.id })
|
||||
end
|
||||
end
|
||||
|
||||
render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id)
|
||||
end
|
||||
|
||||
def create
|
||||
@status = PostStatusService.new.call(
|
||||
current_user.account,
|
||||
|
||||
@@ -28,10 +28,9 @@ class Api::V1Alpha::CollectionsController < Api::BaseController
|
||||
cache_if_unauthenticated!
|
||||
authorize @account, :index_collections?
|
||||
|
||||
presenter = CollectionsPresenter.new(collections: @collections)
|
||||
render json: presenter, serializer: REST::CollectionsWithAccountPreviewsSerializer
|
||||
render json: @collections, each_serializer: REST::CollectionSerializer, adapter: :json
|
||||
rescue Mastodon::NotPermittedError
|
||||
render json: { collections: [], partial_accounts: [] }
|
||||
render json: { collections: [] }
|
||||
end
|
||||
|
||||
def show
|
||||
@@ -74,7 +73,6 @@ class Api::V1Alpha::CollectionsController < Api::BaseController
|
||||
def set_collections
|
||||
@collections = @account.collections
|
||||
.with_tag
|
||||
.preload(top_items: :account)
|
||||
.order(created_at: :desc)
|
||||
.offset(offset_param)
|
||||
.limit(limit_param(DEFAULT_COLLECTIONS_LIMIT))
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
class CustomCssController < ActionController::Base # rubocop:disable Rails/ApplicationController
|
||||
def show
|
||||
expires_in 1.month, public: true
|
||||
render content_type: 'text/css'
|
||||
render content_type: :css
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
10
app/controllers/redirect/collections_controller.rb
Normal file
10
app/controllers/redirect/collections_controller.rb
Normal file
@@ -0,0 +1,10 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Redirect::CollectionsController < Redirect::BaseController
|
||||
private
|
||||
|
||||
def set_resource
|
||||
@resource = Collection.find(params[:id])
|
||||
not_found if @resource.local? || @resource&.account&.suspended?
|
||||
end
|
||||
end
|
||||
@@ -48,9 +48,9 @@ module ContextHelper
|
||||
},
|
||||
quote_authorizations: {
|
||||
'gts' => 'https://gotosocial.org/ns#',
|
||||
'quoteAuthorization' => { '@id' => 'https://w3id.org/fep/044f#quoteAuthorization', '@type' => '@id' },
|
||||
'interactingObject' => { '@id' => 'gts:interactingObject' },
|
||||
'interactionTarget' => { '@id' => 'gts:interactionTarget' },
|
||||
'QuoteAuthorization' => 'https://w3id.org/fep/044f#QuoteAuthorization',
|
||||
'interactingObject' => { '@id' => 'gts:interactingObject', '@type' => '@id' },
|
||||
'interactionTarget' => { '@id' => 'gts:interactionTarget', '@type' => '@id' },
|
||||
},
|
||||
}.freeze
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
module JsonLdHelper
|
||||
include ContextHelper
|
||||
|
||||
UNSUPPORTED_JSONLD_KEYWORDS = %w(@graph @included @reverse).freeze
|
||||
|
||||
def equals_or_includes?(haystack, needle)
|
||||
haystack.is_a?(Array) ? haystack.include?(needle) : haystack == needle
|
||||
end
|
||||
@@ -110,6 +112,16 @@ module JsonLdHelper
|
||||
compacted
|
||||
end
|
||||
|
||||
def unsupported_jsonld_features?(json)
|
||||
if json.is_a?(Hash)
|
||||
json.any? { |key, value| UNSUPPORTED_JSONLD_KEYWORDS.include?(key) || unsupported_jsonld_features?(value) }
|
||||
elsif json.is_a?(Array)
|
||||
json.any? { |value| unsupported_jsonld_features?(value) }
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
# Patches a JSON-LD document to avoid compatibility issues on redistribution
|
||||
#
|
||||
# Since compacting a JSON-LD document against Mastodon's built-in vocabulary
|
||||
|
||||
@@ -223,7 +223,14 @@ module LanguagesHelper
|
||||
'zh-YUE': ['Cantonese', '廣東話'].freeze,
|
||||
}.freeze
|
||||
|
||||
SUPPORTED_LOCALES = {}.merge(ISO_639_1).merge(ISO_639_1_REGIONAL).merge(ISO_639_3).freeze
|
||||
# Since nan is not translated but nan-TW is translated,
|
||||
# to enable the ISO-639-3 language-code with the regional variant but no
|
||||
# official name, we use a specific hash for nan-TW
|
||||
ISO_639_3_REGIONAL = {
|
||||
'nan-TW': ['Hokkien (Taiwan)', '臺語 (Hô-ló話)'].freeze,
|
||||
}.freeze
|
||||
|
||||
SUPPORTED_LOCALES = {}.merge(ISO_639_1).merge(ISO_639_1_REGIONAL).merge(ISO_639_3).merge(ISO_639_3_REGIONAL).freeze
|
||||
|
||||
# For ISO-639-1 and ISO-639-3 language codes, we have their official
|
||||
# names, but for some translations, we need the names of the
|
||||
@@ -233,7 +240,6 @@ module LanguagesHelper
|
||||
'es-AR': 'Español (Argentina)',
|
||||
'es-MX': 'Español (México)',
|
||||
'fr-CA': 'Français (Canadien)',
|
||||
'nan-TW': '臺語 (Hô-ló話)',
|
||||
'pt-BR': 'Português (Brasil)',
|
||||
'pt-PT': 'Português (Portugal)',
|
||||
'sr-Latn': 'Srpski (latinica)',
|
||||
|
||||
@@ -69,8 +69,9 @@ on('change', '#batch_checkbox_all', ({ target }) => {
|
||||
'.batch-table__select-all',
|
||||
);
|
||||
|
||||
document
|
||||
.querySelectorAll<HTMLInputElement>(batchCheckboxClassName)
|
||||
target
|
||||
.closest('.batch-table')
|
||||
?.querySelectorAll<HTMLInputElement>(batchCheckboxClassName)
|
||||
.forEach((content) => {
|
||||
content.checked = target.checked;
|
||||
});
|
||||
@@ -112,17 +113,20 @@ on('click', '.batch-table__select-all button', () => {
|
||||
}
|
||||
});
|
||||
|
||||
on('change', batchCheckboxClassName, () => {
|
||||
const checkAllElement = document.querySelector<HTMLInputElement>(
|
||||
on('change', batchCheckboxClassName, (event) => {
|
||||
const targetTable = (event.target as HTMLElement).closest('.batch-table');
|
||||
if (!targetTable) return;
|
||||
|
||||
const checkAllElement = targetTable.querySelector<HTMLInputElement>(
|
||||
'input#batch_checkbox_all',
|
||||
);
|
||||
const selectAllMatchingElement = document.querySelector(
|
||||
const selectAllMatchingElement = targetTable.querySelector(
|
||||
'.batch-table__select-all',
|
||||
);
|
||||
|
||||
if (checkAllElement) {
|
||||
const allCheckboxes = Array.from(
|
||||
document.querySelectorAll<HTMLInputElement>(batchCheckboxClassName),
|
||||
targetTable.querySelectorAll<HTMLInputElement>(batchCheckboxClassName),
|
||||
);
|
||||
checkAllElement.checked = allCheckboxes.every((content) => content.checked);
|
||||
checkAllElement.indeterminate =
|
||||
|
||||
@@ -13,14 +13,17 @@ import axios from 'axios';
|
||||
import { on } from 'delegated-events';
|
||||
import { throttle } from 'lodash';
|
||||
|
||||
import { determineEmojiMode } from '@/mastodon/features/emoji/mode';
|
||||
import { updateHtmlWithEmoji } from '@/mastodon/features/emoji/render';
|
||||
import loadKeyboardExtensions from '@/mastodon/load_keyboard_extensions';
|
||||
import { loadLocale, getLocale } from '@/mastodon/locales';
|
||||
import { loadPolyfills } from '@/mastodon/polyfills';
|
||||
import ready from '@/mastodon/ready';
|
||||
import { assetHost } from '@/mastodon/utils/config';
|
||||
import { getNestedProperty } from '@/mastodon/utils/objects';
|
||||
import { isDarkMode } from '@/mastodon/utils/theme';
|
||||
import { formatTime } from '@/mastodon/utils/time';
|
||||
|
||||
import emojify from '../mastodon/features/emoji/emoji';
|
||||
import loadKeyboardExtensions from '../mastodon/load_keyboard_extensions';
|
||||
import { loadLocale, getLocale } from '../mastodon/locales';
|
||||
import { loadPolyfills } from '../mastodon/polyfills';
|
||||
import ready from '../mastodon/ready';
|
||||
|
||||
import 'cocoon-js-vanilla';
|
||||
|
||||
const messages = defineMessages({
|
||||
@@ -38,7 +41,7 @@ const messages = defineMessages({
|
||||
},
|
||||
});
|
||||
|
||||
function loaded() {
|
||||
async function loaded() {
|
||||
const { messages: localeData } = getLocale();
|
||||
|
||||
const locale = document.documentElement.lang;
|
||||
@@ -75,9 +78,30 @@ function loaded() {
|
||||
return messageFormat.format(values) as string;
|
||||
};
|
||||
|
||||
document.querySelectorAll('.emojify').forEach((content) => {
|
||||
content.innerHTML = emojify(content.innerHTML);
|
||||
});
|
||||
let emojiStyle = 'auto';
|
||||
const initialStateText =
|
||||
document.getElementById('initial-state')?.textContent;
|
||||
if (initialStateText) {
|
||||
const stateEmojiStyle = getNestedProperty(
|
||||
JSON.parse(initialStateText) as unknown,
|
||||
'meta',
|
||||
'emoji_style',
|
||||
);
|
||||
if (typeof stateEmojiStyle === 'string') {
|
||||
emojiStyle = stateEmojiStyle;
|
||||
}
|
||||
}
|
||||
const emojiMode = determineEmojiMode(emojiStyle);
|
||||
const darkTheme = isDarkMode();
|
||||
for (const element of document.querySelectorAll('.emojify')) {
|
||||
await updateHtmlWithEmoji({
|
||||
assetHost,
|
||||
element,
|
||||
locale,
|
||||
mode: emojiMode,
|
||||
darkTheme,
|
||||
});
|
||||
}
|
||||
|
||||
document
|
||||
.querySelectorAll<HTMLTimeElement>('time.formatted')
|
||||
|
||||
@@ -6,9 +6,8 @@ import { throttle } from 'lodash';
|
||||
import api from 'mastodon/api';
|
||||
import { browserHistory } from 'mastodon/components/router';
|
||||
import { countableText } from 'mastodon/features/compose/util/counter';
|
||||
import { search as emojiSearch } from 'mastodon/features/emoji/emoji_mart_search_light';
|
||||
import { tagHistory } from 'mastodon/settings';
|
||||
import { fetchCustomEmojiData } from '@/mastodon/features/emoji/picker';
|
||||
import { emojiMartSearch } from '@/mastodon/features/emoji/picker';
|
||||
|
||||
import { showAlert, showAlertForError } from './alerts';
|
||||
import { useEmoji } from './emojis';
|
||||
@@ -100,7 +99,7 @@ export const ensureComposeIsVisible = (getState) => {
|
||||
|
||||
export function setComposeToStatus(status, text, spoiler_text) {
|
||||
return (dispatch, getState) => {
|
||||
const maxOptions = getState().server.getIn(['server', 'configuration', 'polls', 'max_options']);
|
||||
const maxOptions = getState().server.server.item?.configuration.polls.max_options;
|
||||
|
||||
dispatch({
|
||||
type: COMPOSE_SET_STATUS,
|
||||
@@ -592,8 +591,9 @@ const fetchComposeSuggestionsAccounts = throttle((dispatch, token) => {
|
||||
}, 200, { leading: true, trailing: true });
|
||||
|
||||
const fetchComposeSuggestionsEmojis = async (dispatch, token) => {
|
||||
const custom = await fetchCustomEmojiData();
|
||||
const results = emojiSearch(token.replace(':', ''), { maxResults: 5, custom });
|
||||
// Right now we are hard-coding the locale to English since the picker search only supports English.
|
||||
// Once we replace the legacy picker we can remove this and use the actual locale of the user.
|
||||
const results = await emojiMartSearch(token, 'en', 5);
|
||||
dispatch(readyComposeSuggestionsEmojis(token, results));
|
||||
};
|
||||
|
||||
@@ -671,7 +671,7 @@ export function selectComposeSuggestion(position, token, suggestion, path) {
|
||||
let completion, startPosition;
|
||||
|
||||
if (suggestion.type === 'emoji') {
|
||||
completion = suggestion.native || suggestion.colons;
|
||||
completion = suggestion.native || `:${suggestion.id}:`;
|
||||
startPosition = position - 1;
|
||||
|
||||
dispatch(useEmoji(suggestion));
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import api from '../api';
|
||||
|
||||
import { importFetchedAccount } from './importer';
|
||||
|
||||
export const SERVER_FETCH_REQUEST = 'Server_FETCH_REQUEST';
|
||||
export const SERVER_FETCH_SUCCESS = 'Server_FETCH_SUCCESS';
|
||||
export const SERVER_FETCH_FAIL = 'Server_FETCH_FAIL';
|
||||
|
||||
export const SERVER_TRANSLATION_LANGUAGES_FETCH_REQUEST = 'SERVER_TRANSLATION_LANGUAGES_FETCH_REQUEST';
|
||||
export const SERVER_TRANSLATION_LANGUAGES_FETCH_SUCCESS = 'SERVER_TRANSLATION_LANGUAGES_FETCH_SUCCESS';
|
||||
export const SERVER_TRANSLATION_LANGUAGES_FETCH_FAIL = 'SERVER_TRANSLATION_LANGUAGES_FETCH_FAIL';
|
||||
|
||||
export const EXTENDED_DESCRIPTION_REQUEST = 'EXTENDED_DESCRIPTION_REQUEST';
|
||||
export const EXTENDED_DESCRIPTION_SUCCESS = 'EXTENDED_DESCRIPTION_SUCCESS';
|
||||
export const EXTENDED_DESCRIPTION_FAIL = 'EXTENDED_DESCRIPTION_FAIL';
|
||||
|
||||
export const SERVER_DOMAIN_BLOCKS_FETCH_REQUEST = 'SERVER_DOMAIN_BLOCKS_FETCH_REQUEST';
|
||||
export const SERVER_DOMAIN_BLOCKS_FETCH_SUCCESS = 'SERVER_DOMAIN_BLOCKS_FETCH_SUCCESS';
|
||||
export const SERVER_DOMAIN_BLOCKS_FETCH_FAIL = 'SERVER_DOMAIN_BLOCKS_FETCH_FAIL';
|
||||
|
||||
export const fetchServer = () => (dispatch, getState) => {
|
||||
if (getState().getIn(['server', 'server', 'isLoading'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(fetchServerRequest());
|
||||
|
||||
api()
|
||||
.get('/api/v2/instance').then(({ data }) => {
|
||||
// Only import the account if it doesn't already exist,
|
||||
// because the API is cached even for logged in users.
|
||||
const account = data.contact.account;
|
||||
if (account) {
|
||||
const existingAccount = getState().getIn(['accounts', account.id]);
|
||||
if (!existingAccount) {
|
||||
dispatch(importFetchedAccount(account));
|
||||
}
|
||||
}
|
||||
dispatch(fetchServerSuccess(data));
|
||||
}).catch(err => dispatch(fetchServerFail(err)));
|
||||
};
|
||||
|
||||
const fetchServerRequest = () => ({
|
||||
type: SERVER_FETCH_REQUEST,
|
||||
});
|
||||
|
||||
const fetchServerSuccess = server => ({
|
||||
type: SERVER_FETCH_SUCCESS,
|
||||
server,
|
||||
});
|
||||
|
||||
const fetchServerFail = error => ({
|
||||
type: SERVER_FETCH_FAIL,
|
||||
error,
|
||||
});
|
||||
|
||||
export const fetchServerTranslationLanguages = () => (dispatch) => {
|
||||
dispatch(fetchServerTranslationLanguagesRequest());
|
||||
|
||||
api()
|
||||
.get('/api/v1/instance/translation_languages').then(({ data }) => {
|
||||
dispatch(fetchServerTranslationLanguagesSuccess(data));
|
||||
}).catch(err => dispatch(fetchServerTranslationLanguagesFail(err)));
|
||||
};
|
||||
|
||||
const fetchServerTranslationLanguagesRequest = () => ({
|
||||
type: SERVER_TRANSLATION_LANGUAGES_FETCH_REQUEST,
|
||||
});
|
||||
|
||||
const fetchServerTranslationLanguagesSuccess = translationLanguages => ({
|
||||
type: SERVER_TRANSLATION_LANGUAGES_FETCH_SUCCESS,
|
||||
translationLanguages,
|
||||
});
|
||||
|
||||
const fetchServerTranslationLanguagesFail = error => ({
|
||||
type: SERVER_TRANSLATION_LANGUAGES_FETCH_FAIL,
|
||||
error,
|
||||
});
|
||||
|
||||
export const fetchExtendedDescription = () => (dispatch, getState) => {
|
||||
if (getState().getIn(['server', 'extendedDescription', 'isLoading'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(fetchExtendedDescriptionRequest());
|
||||
|
||||
api()
|
||||
.get('/api/v1/instance/extended_description')
|
||||
.then(({ data }) => dispatch(fetchExtendedDescriptionSuccess(data)))
|
||||
.catch(err => dispatch(fetchExtendedDescriptionFail(err)));
|
||||
};
|
||||
|
||||
const fetchExtendedDescriptionRequest = () => ({
|
||||
type: EXTENDED_DESCRIPTION_REQUEST,
|
||||
});
|
||||
|
||||
const fetchExtendedDescriptionSuccess = description => ({
|
||||
type: EXTENDED_DESCRIPTION_SUCCESS,
|
||||
description,
|
||||
});
|
||||
|
||||
const fetchExtendedDescriptionFail = error => ({
|
||||
type: EXTENDED_DESCRIPTION_FAIL,
|
||||
error,
|
||||
});
|
||||
|
||||
export const fetchDomainBlocks = () => (dispatch, getState) => {
|
||||
if (getState().getIn(['server', 'domainBlocks', 'isLoading'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(fetchDomainBlocksRequest());
|
||||
|
||||
api()
|
||||
.get('/api/v1/instance/domain_blocks')
|
||||
.then(({ data }) => dispatch(fetchDomainBlocksSuccess(true, data)))
|
||||
.catch(err => {
|
||||
if (err.response.status === 404) {
|
||||
dispatch(fetchDomainBlocksSuccess(false, []));
|
||||
} else {
|
||||
dispatch(fetchDomainBlocksFail(err));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const fetchDomainBlocksRequest = () => ({
|
||||
type: SERVER_DOMAIN_BLOCKS_FETCH_REQUEST,
|
||||
});
|
||||
|
||||
const fetchDomainBlocksSuccess = (isAvailable, blocks) => ({
|
||||
type: SERVER_DOMAIN_BLOCKS_FETCH_SUCCESS,
|
||||
isAvailable,
|
||||
blocks,
|
||||
});
|
||||
|
||||
const fetchDomainBlocksFail = error => ({
|
||||
type: SERVER_DOMAIN_BLOCKS_FETCH_FAIL,
|
||||
error,
|
||||
});
|
||||
34
app/javascript/mastodon/actions/server.ts
Normal file
34
app/javascript/mastodon/actions/server.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
apiGetInstance,
|
||||
apiGetExtendedDescription,
|
||||
apiGetDomainBlocks,
|
||||
apiGetTranslationLanguages,
|
||||
} from 'mastodon/api/instance';
|
||||
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
||||
|
||||
import { importFetchedAccount } from './importer';
|
||||
|
||||
export const fetchServer = createDataLoadingThunk(
|
||||
'server/fetch',
|
||||
() => apiGetInstance(),
|
||||
(instance, { dispatch }) => {
|
||||
if (instance.contact.account) {
|
||||
dispatch(importFetchedAccount(instance.contact.account));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const fetchExtendedDescription = createDataLoadingThunk(
|
||||
'server/extended_description',
|
||||
() => apiGetExtendedDescription(),
|
||||
);
|
||||
|
||||
export const fetchServerTranslationLanguages = createDataLoadingThunk(
|
||||
'server/translation_languages',
|
||||
() => apiGetTranslationLanguages(),
|
||||
);
|
||||
|
||||
export const fetchDomainBlocks = createDataLoadingThunk(
|
||||
'server/domain_blocks',
|
||||
() => apiGetDomainBlocks(),
|
||||
);
|
||||
@@ -111,7 +111,7 @@ export function fetchStatusFail(id, error, skipLoading, parentQuotePostId) {
|
||||
|
||||
export function redraft(status, raw_text, quoted_status_id = null) {
|
||||
return (dispatch, getState) => {
|
||||
const maxOptions = getState().server.getIn(['server', 'configuration', 'polls', 'max_options']);
|
||||
const maxOptions = getState().server.server.item?.configuration.polls.max_options;
|
||||
|
||||
dispatch({
|
||||
type: REDRAFT,
|
||||
|
||||
@@ -2,6 +2,10 @@ import { apiRequestGet } from 'mastodon/api';
|
||||
import type {
|
||||
ApiTermsOfServiceJSON,
|
||||
ApiPrivacyPolicyJSON,
|
||||
ApiInstanceJSON,
|
||||
ApiExtendedDescriptionJSON,
|
||||
ApiTranslationLanguagesJSON,
|
||||
ApiDomainBlockJSON,
|
||||
} from 'mastodon/api_types/instance';
|
||||
|
||||
export const apiGetTermsOfService = (version?: string) =>
|
||||
@@ -13,3 +17,17 @@ export const apiGetTermsOfService = (version?: string) =>
|
||||
|
||||
export const apiGetPrivacyPolicy = () =>
|
||||
apiRequestGet<ApiPrivacyPolicyJSON>('v1/instance/privacy_policy');
|
||||
|
||||
export const apiGetInstance = () =>
|
||||
apiRequestGet<ApiInstanceJSON>('v2/instance');
|
||||
|
||||
export const apiGetExtendedDescription = () =>
|
||||
apiRequestGet<ApiExtendedDescriptionJSON>('v1/instance/extended_description');
|
||||
|
||||
export const apiGetTranslationLanguages = () =>
|
||||
apiRequestGet<ApiTranslationLanguagesJSON>(
|
||||
'v1/instance/translation_languages',
|
||||
);
|
||||
|
||||
export const apiGetDomainBlocks = () =>
|
||||
apiRequestGet<ApiDomainBlockJSON[]>('v1/instance/domain_blocks');
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ApiAccountJSON } from './accounts';
|
||||
|
||||
export interface ApiTermsOfServiceJSON {
|
||||
effective_date: string;
|
||||
effective: boolean;
|
||||
@@ -9,3 +11,136 @@ export interface ApiPrivacyPolicyJSON {
|
||||
updated_at: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface ApiBaseRuleJSON {
|
||||
text: string;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
export interface ApiRuleJSON {
|
||||
id: string;
|
||||
text: string;
|
||||
hint: string;
|
||||
translations?: Record<string, ApiBaseRuleJSON>;
|
||||
}
|
||||
|
||||
export interface ApiExtendedDescriptionJSON {
|
||||
updated_at: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ApiDomainBlockJSON {
|
||||
domain: string;
|
||||
digest: string;
|
||||
severity: string;
|
||||
comment: string;
|
||||
}
|
||||
|
||||
export type ApiTranslationLanguagesJSON = Record<string, string[]>;
|
||||
|
||||
export interface ApiInstanceJSON {
|
||||
domain: string;
|
||||
title: string;
|
||||
version: string;
|
||||
source_url: string;
|
||||
description: string;
|
||||
languages: string[];
|
||||
usage: {
|
||||
users: {
|
||||
active_month: number;
|
||||
};
|
||||
};
|
||||
thumbnail: {
|
||||
url: string;
|
||||
blurhash?: string;
|
||||
description: string;
|
||||
versions?: Record<string, string>;
|
||||
};
|
||||
contact: {
|
||||
email: string | null;
|
||||
account: ApiAccountJSON | null;
|
||||
};
|
||||
api_versions: {
|
||||
mastodon: number;
|
||||
};
|
||||
registrations: {
|
||||
enabled: boolean;
|
||||
approval_required: boolean;
|
||||
reason_required: boolean | null;
|
||||
message: string | null;
|
||||
min_age: string | null;
|
||||
url: string | null;
|
||||
};
|
||||
rules: ApiRuleJSON[];
|
||||
configuration: {
|
||||
urls: {
|
||||
streaming: string;
|
||||
status: string | null;
|
||||
about: string;
|
||||
privacy_policy: string | null;
|
||||
terms_of_service: string | null;
|
||||
};
|
||||
|
||||
vapid: {
|
||||
public_key: string;
|
||||
};
|
||||
|
||||
accounts: {
|
||||
max_display_name_length: number;
|
||||
max_note_length: number;
|
||||
max_avatar_description_length: number;
|
||||
max_header_description_length: number;
|
||||
max_featured_tags: number;
|
||||
max_pinned_statuses: number;
|
||||
max_profile_fields: number;
|
||||
profile_field_name_limit: number;
|
||||
profile_field_value_limit: number;
|
||||
};
|
||||
|
||||
statuses: {
|
||||
max_characters: number;
|
||||
max_media_attachments: number;
|
||||
characters_reserved_per_url: number;
|
||||
};
|
||||
|
||||
media_attachments: {
|
||||
description_limit: number;
|
||||
image_matrix_limit: number;
|
||||
image_size_limit: number;
|
||||
supported_mime_types: string[];
|
||||
video_frame_rate_limit: number;
|
||||
video_matrix_limit: number;
|
||||
video_size_limit: number;
|
||||
};
|
||||
|
||||
polls: {
|
||||
max_options: number;
|
||||
max_characters_per_option: number;
|
||||
min_expiration: number;
|
||||
max_expiration: number;
|
||||
};
|
||||
|
||||
translation: {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
timeline_access: {
|
||||
live_feeds: {
|
||||
local: string;
|
||||
remote: string;
|
||||
};
|
||||
|
||||
hashtag_feeds: {
|
||||
local: string;
|
||||
remote: string;
|
||||
};
|
||||
|
||||
trending_link_feeds: {
|
||||
local: string;
|
||||
remote: string;
|
||||
};
|
||||
};
|
||||
|
||||
limited_federation: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<AutosuggestEmoji /> > renders emoji with custom url 1`] = `
|
||||
<div
|
||||
className="autosuggest-emoji"
|
||||
>
|
||||
<img
|
||||
alt="foobar"
|
||||
className="emojione"
|
||||
src="http://example.com/emoji.png"
|
||||
/>
|
||||
<div
|
||||
className="autosuggest-emoji__name"
|
||||
>
|
||||
:foobar:
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<AutosuggestEmoji /> > renders native emoji 1`] = `
|
||||
<div
|
||||
className="autosuggest-emoji"
|
||||
>
|
||||
<img
|
||||
alt="💙"
|
||||
className="emojione"
|
||||
src="/emoji/1f499.svg"
|
||||
/>
|
||||
<div
|
||||
className="autosuggest-emoji__name"
|
||||
>
|
||||
:foobar:
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -1,29 +0,0 @@
|
||||
import renderer from 'react-test-renderer';
|
||||
|
||||
import AutosuggestEmoji from '../autosuggest_emoji';
|
||||
|
||||
describe('<AutosuggestEmoji />', () => {
|
||||
it('renders native emoji', () => {
|
||||
const emoji = {
|
||||
native: '💙',
|
||||
colons: ':foobar:',
|
||||
};
|
||||
const component = renderer.create(<AutosuggestEmoji emoji={emoji} />);
|
||||
const tree = component.toJSON();
|
||||
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders emoji with custom url', () => {
|
||||
const emoji = {
|
||||
custom: true,
|
||||
imageUrl: 'http://example.com/emoji.png',
|
||||
native: 'foobar',
|
||||
colons: ':foobar:',
|
||||
};
|
||||
const component = renderer.create(<AutosuggestEmoji emoji={emoji} />);
|
||||
const tree = component.toJSON();
|
||||
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
140
app/javascript/mastodon/components/account_header/banners.tsx
Normal file
140
app/javascript/mastodon/components/account_header/banners.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useCallback } from 'react';
|
||||
import type { FC, ReactElement, ReactNode } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import {
|
||||
authorizeFollowRequest,
|
||||
rejectFollowRequest,
|
||||
} from '@/mastodon/actions/accounts';
|
||||
import { useAccountVisibility } from '@/mastodon/hooks/useAccountVisibility';
|
||||
import { useRelationship } from '@/mastodon/hooks/useRelationship';
|
||||
import type { Account } from '@/mastodon/models/account';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
|
||||
import { AvatarOverlay } from '../avatar_overlay';
|
||||
import { Button } from '../button';
|
||||
import { DisplayName } from '../display_name';
|
||||
import { Icon } from '../icon';
|
||||
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
export const AccountBanners: FC<{ account: Account }> = ({ account }) => {
|
||||
const { suspended, hidden } = useAccountVisibility(account.id);
|
||||
const relationship = useRelationship(account.id);
|
||||
|
||||
if (hidden) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let banner: ReactNode = null;
|
||||
|
||||
if (account.memorial) {
|
||||
banner = (
|
||||
<MessageText>
|
||||
<FormattedMessage
|
||||
id='account.in_memoriam'
|
||||
defaultMessage='In Memoriam.'
|
||||
/>
|
||||
</MessageText>
|
||||
);
|
||||
}
|
||||
|
||||
if (account.moved) {
|
||||
banner = <MovedNote account={account} targetAccountId={account.moved} />;
|
||||
}
|
||||
|
||||
if (!suspended && relationship?.requested_by) {
|
||||
banner = <FollowRequestNote account={account} />;
|
||||
}
|
||||
|
||||
if (!banner) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className={classes.bannerWrapper}>{banner}</div>;
|
||||
};
|
||||
|
||||
const FollowRequestNote: FC<{ account: Account }> = ({ account }) => {
|
||||
const accountId = account.id;
|
||||
const dispatch = useAppDispatch();
|
||||
const handleAuthorize = useCallback(() => {
|
||||
dispatch(authorizeFollowRequest(accountId));
|
||||
}, [accountId, dispatch]);
|
||||
const handleReject = useCallback(() => {
|
||||
dispatch(rejectFollowRequest(accountId));
|
||||
}, [accountId, dispatch]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MessageText>
|
||||
<FormattedMessage
|
||||
id='account.requested_follow'
|
||||
defaultMessage='{name} has requested to follow you'
|
||||
values={{ name: <DisplayName account={account} variant='simple' /> }}
|
||||
/>
|
||||
</MessageText>
|
||||
|
||||
<div className={classes.bannerActions}>
|
||||
<Button secondary onClick={handleAuthorize}>
|
||||
<Icon id='check' icon={CheckIcon} />
|
||||
<FormattedMessage
|
||||
id='follow_request.authorize'
|
||||
defaultMessage='Authorize'
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Button secondary onClick={handleReject}>
|
||||
<Icon id='times' icon={CloseIcon} />
|
||||
<FormattedMessage
|
||||
id='follow_request.reject'
|
||||
defaultMessage='Reject'
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const MovedNote: React.FC<{
|
||||
account: Account;
|
||||
targetAccountId: string;
|
||||
}> = ({ account: from, targetAccountId }) => {
|
||||
const to = useAppSelector((state) => state.accounts.get(targetAccountId));
|
||||
|
||||
return (
|
||||
<>
|
||||
<MessageText>
|
||||
<FormattedMessage
|
||||
id='account.moved_to'
|
||||
defaultMessage='{name} has indicated that their new account is now:'
|
||||
values={{
|
||||
name: <DisplayName account={from} variant='simple' />,
|
||||
}}
|
||||
/>
|
||||
</MessageText>
|
||||
|
||||
<div className={classes.bannerActions}>
|
||||
<Link to={`/@${to?.acct}`} className={classes.bannerActionsDisplayName}>
|
||||
<AvatarOverlay account={to} friend={from} />
|
||||
<DisplayName account={to} />
|
||||
</Link>
|
||||
|
||||
<Link to={`/@${to?.acct}`} className='button'>
|
||||
<FormattedMessage
|
||||
id='account.go_to_profile'
|
||||
defaultMessage='Go to profile'
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const MessageText: React.FC<{ children: ReactElement }> = ({ children }) => (
|
||||
<div className={classes.bannerText}>{children}</div>
|
||||
);
|
||||
@@ -107,8 +107,13 @@ const FieldCard: FC<{
|
||||
field: AccountField;
|
||||
}> = ({ htmlHandlers, field }) => {
|
||||
const intl = useIntl();
|
||||
const { name, nameHasEmojis, value_plain, valueHasEmojis, verified_at } =
|
||||
field;
|
||||
const {
|
||||
name_emojified,
|
||||
nameHasEmojis,
|
||||
value_emojified,
|
||||
valueHasEmojis,
|
||||
verified_at,
|
||||
} = field;
|
||||
|
||||
const { wrapperRef, isLabelOverflowing, isValueOverflowing } =
|
||||
useFieldOverflow();
|
||||
@@ -131,7 +136,7 @@ const FieldCard: FC<{
|
||||
)}
|
||||
label={
|
||||
<FieldHTML
|
||||
text={name}
|
||||
text={name_emojified}
|
||||
textHasCustomEmoji={nameHasEmojis}
|
||||
className='translate'
|
||||
isOverflowing={isLabelOverflowing}
|
||||
@@ -141,7 +146,7 @@ const FieldCard: FC<{
|
||||
}
|
||||
value={
|
||||
<FieldHTML
|
||||
text={value_plain}
|
||||
text={value_emojified}
|
||||
textHasCustomEmoji={valueHasEmojis}
|
||||
isOverflowing={isValueOverflowing}
|
||||
onOverflowClick={handleOverflowClick}
|
||||
@@ -312,9 +317,10 @@ function useColumnWrap() {
|
||||
if (element) {
|
||||
listRef.current = element;
|
||||
observer.observe(element);
|
||||
handleRecalculate();
|
||||
}
|
||||
},
|
||||
[observer],
|
||||
[handleRecalculate, observer],
|
||||
);
|
||||
|
||||
return { wrapperRef: wrapperRefCallback };
|
||||
|
||||
@@ -5,7 +5,6 @@ import classNames from 'classnames';
|
||||
import { Helmet } from '@unhead/react/helmet';
|
||||
|
||||
import { openModal } from '@/mastodon/actions/modal';
|
||||
import FollowRequestNoteContainer from '@/mastodon/features/account/containers/follow_request_note_container';
|
||||
import { useLayout } from '@/mastodon/hooks/useLayout';
|
||||
import { useVisibility } from '@/mastodon/hooks/useVisibility';
|
||||
import {
|
||||
@@ -22,10 +21,9 @@ import { Avatar } from '../avatar';
|
||||
import { AnimateEmojiProvider } from '../emoji/context';
|
||||
import { FamiliarFollowers } from '../familiar_followers';
|
||||
|
||||
import { AccountBanners } from './banners';
|
||||
import { AccountButtons } from './buttons';
|
||||
import { AccountHeaderFields } from './fields';
|
||||
import { MemorialNote } from './memorial_note';
|
||||
import { MovedNote } from './moved_note';
|
||||
import { AccountName } from './name';
|
||||
import { AccountNote } from './note';
|
||||
import { AccountNumberFields } from './number_fields';
|
||||
@@ -51,9 +49,6 @@ export const AccountHeader: React.FC<{
|
||||
}> = ({ accountId, hideTabs }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const account = useAppSelector((state) => state.accounts.get(accountId));
|
||||
const relationship = useAppSelector((state) =>
|
||||
state.relationships.get(accountId),
|
||||
);
|
||||
const hidden = useAppSelector((state) => getAccountHidden(state, accountId));
|
||||
|
||||
const handleOpenAvatar = useCallback(
|
||||
@@ -98,18 +93,11 @@ export const AccountHeader: React.FC<{
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!hidden && account.memorial && <MemorialNote />}
|
||||
{!hidden && account.moved && (
|
||||
<MovedNote accountId={account.id} targetAccountId={account.moved} />
|
||||
)}
|
||||
<AccountBanners account={account} />
|
||||
|
||||
<AnimateEmojiProvider
|
||||
className={classNames(!!account.moved && classes.moved)}
|
||||
>
|
||||
{!suspendedOrHidden && !account.moved && relationship?.requested_by && (
|
||||
<FollowRequestNoteContainer account={account} />
|
||||
)}
|
||||
|
||||
<div className={classes.header}>
|
||||
{!suspendedOrHidden && (
|
||||
<img
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
export const MemorialNote: React.FC = () => (
|
||||
<div className='account-memorial-banner'>
|
||||
<div className='account-memorial-banner__message'>
|
||||
<FormattedMessage
|
||||
id='account.in_memoriam'
|
||||
defaultMessage='In Memoriam.'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -21,6 +21,10 @@ import {
|
||||
import { openModal } from '@/mastodon/actions/modal';
|
||||
import { initMuteModal } from '@/mastodon/actions/mutes';
|
||||
import { initReport } from '@/mastodon/actions/reports';
|
||||
import {
|
||||
canAccountBeAdded,
|
||||
canAccountBeAddedByFollowers,
|
||||
} from '@/mastodon/features/collections/utils';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import { useIdentity } from '@/mastodon/identity_context';
|
||||
import type { Account } from '@/mastodon/models/account';
|
||||
@@ -214,6 +218,10 @@ const redesignMessages = defineMessages({
|
||||
id: 'account.menu.add_to_list',
|
||||
defaultMessage: 'Add to list…',
|
||||
},
|
||||
addToCollection: {
|
||||
id: 'account.menu.add_to_collection',
|
||||
defaultMessage: 'Add to collection…',
|
||||
},
|
||||
openOriginalPage: {
|
||||
id: 'account.menu.open_original_page',
|
||||
defaultMessage: 'View on {domain}',
|
||||
@@ -294,35 +302,57 @@ function getMenuItems({
|
||||
return items;
|
||||
}
|
||||
|
||||
// List and featuring options
|
||||
// Add to list
|
||||
if (relationship?.following) {
|
||||
items.push(
|
||||
{
|
||||
text: intl.formatMessage(redesignMessages.addToList),
|
||||
action: () => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'LIST_ADDER',
|
||||
modalProps: {
|
||||
accountId: account.id,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
items.push({
|
||||
text: intl.formatMessage(redesignMessages.addToList),
|
||||
action: () => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'LIST_ADDER',
|
||||
modalProps: {
|
||||
accountId: account.id,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage(
|
||||
relationship.endorsed ? messages.unendorse : messages.endorse,
|
||||
),
|
||||
action: () => {
|
||||
if (relationship.endorsed) {
|
||||
dispatch(unpinAccount(account.id));
|
||||
} else {
|
||||
dispatch(pinAccount(account.id));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Add to collection
|
||||
if (
|
||||
canAccountBeAdded(account) ||
|
||||
(canAccountBeAddedByFollowers(account) && relationship?.following)
|
||||
) {
|
||||
items.push({
|
||||
text: intl.formatMessage(redesignMessages.addToCollection),
|
||||
action: () => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'COLLECTION_ADDER',
|
||||
modalProps: {
|
||||
accountId: account.id,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Feature on profile
|
||||
if (relationship?.following) {
|
||||
items.push({
|
||||
text: intl.formatMessage(
|
||||
relationship.endorsed ? messages.unendorse : messages.endorse,
|
||||
),
|
||||
action: () => {
|
||||
if (relationship.endorsed) {
|
||||
dispatch(unpinAccount(account.id));
|
||||
} else {
|
||||
dispatch(pinAccount(account.id));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
items.push(
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { useAppSelector } from '@/mastodon/store';
|
||||
|
||||
import { AvatarOverlay } from '../avatar_overlay';
|
||||
import { DisplayName } from '../display_name';
|
||||
|
||||
export const MovedNote: React.FC<{
|
||||
accountId: string;
|
||||
targetAccountId: string;
|
||||
}> = ({ accountId, targetAccountId }) => {
|
||||
const from = useAppSelector((state) => state.accounts.get(accountId));
|
||||
const to = useAppSelector((state) => state.accounts.get(targetAccountId));
|
||||
|
||||
return (
|
||||
<div className='moved-account-banner'>
|
||||
<div className='moved-account-banner__message'>
|
||||
<FormattedMessage
|
||||
id='account.moved_to'
|
||||
defaultMessage='{name} has indicated that their new account is now:'
|
||||
values={{
|
||||
name: <DisplayName account={from} variant='simple' />,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='moved-account-banner__action'>
|
||||
<Link to={`/@${to?.acct}`} className='detailed-status__display-name'>
|
||||
<div className='detailed-status__display-avatar'>
|
||||
<AvatarOverlay account={to} friend={from} />
|
||||
</div>
|
||||
<DisplayName account={to} />
|
||||
</Link>
|
||||
|
||||
<Link to={`/@${to?.acct}`} className='button'>
|
||||
<FormattedMessage
|
||||
id='account.go_to_profile'
|
||||
defaultMessage='Go to profile'
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -11,6 +11,8 @@ import { FormattedDateWrapper } from '../formatted_date';
|
||||
import { NumberFields, NumberFieldsItem } from '../number_fields';
|
||||
import { ShortNumber } from '../short_number';
|
||||
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
export const AccountNumberFields: FC<{ accountId: string }> = ({
|
||||
accountId,
|
||||
}) => {
|
||||
@@ -33,7 +35,7 @@ export const AccountNumberFields: FC<{ accountId: string }> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<NumberFields>
|
||||
<NumberFields className={classes.numberFields}>
|
||||
<NumberFieldsItem
|
||||
label={
|
||||
<FormattedMessage id='account.followers' defaultMessage='Followers' />
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
height: 160px;
|
||||
}
|
||||
|
||||
:global(.inactive) & {
|
||||
.moved & {
|
||||
filter: grayscale(100%);
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,8 @@
|
||||
|
||||
.nameWrapper {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.name {
|
||||
@@ -263,9 +265,32 @@ $button-fallback-breakpoint: $button-breakpoint + 55px;
|
||||
}
|
||||
}
|
||||
|
||||
.numberFields {
|
||||
@container (width >= #{$button-breakpoint}) {
|
||||
--number-fields-gap: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.bio {
|
||||
font-size: 15px;
|
||||
color: var(--color-text-primary);
|
||||
unicode-bidi: plaintext;
|
||||
|
||||
p {
|
||||
margin-bottom: 20px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:any-link {
|
||||
color: var(--color-text-status-links);
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.familiarFollowers {
|
||||
@@ -433,13 +458,24 @@ $button-fallback-breakpoint: $button-breakpoint + 55px;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
// Banners
|
||||
|
||||
.bannerWrapper,
|
||||
.bannerBase {
|
||||
box-sizing: border-box;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
background: var(--color-bg-secondary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.bannerWrapper {
|
||||
background: var(--color-bg-tertiary);
|
||||
padding: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bannerBase {
|
||||
border-radius: 12px;
|
||||
background: var(--color-bg-secondary);
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
@@ -455,6 +491,13 @@ $button-fallback-breakpoint: $button-breakpoint + 55px;
|
||||
}
|
||||
}
|
||||
|
||||
.bannerText {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bannerTextAndActions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -469,6 +512,19 @@ $button-fallback-breakpoint: $button-breakpoint + 55px;
|
||||
}
|
||||
}
|
||||
|
||||
.bannerActions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.bannerDisclaimer {
|
||||
a {
|
||||
color: inherit;
|
||||
@@ -499,6 +555,32 @@ $button-fallback-breakpoint: $button-breakpoint + 55px;
|
||||
}
|
||||
}
|
||||
|
||||
.bannerActionsDisplayName {
|
||||
color: var(--color-text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 15px;
|
||||
line-height: 22px;
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover strong {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
strong,
|
||||
span {
|
||||
display: block;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
// Buttons
|
||||
|
||||
.followButton {
|
||||
|
||||
@@ -104,8 +104,6 @@ export const AccountSubscriptionForm: React.FC<{ accountId: string }> = ({
|
||||
.then(() => {
|
||||
setSubmitting(false);
|
||||
setSubmitted(true);
|
||||
|
||||
return '';
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setSubmitting(false);
|
||||
@@ -133,12 +131,11 @@ export const AccountSubscriptionForm: React.FC<{ accountId: string }> = ({
|
||||
className={classNames(classes.bannerBase, classes.bannerBaseCentered)}
|
||||
>
|
||||
<div className={classes.bannerTextAndActions}>
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
id='email_subscriptions.submitted.title'
|
||||
defaultMessage='One more step'
|
||||
/>
|
||||
</h2>
|
||||
<FormattedMessage
|
||||
id='email_subscriptions.submitted.title'
|
||||
defaultMessage='One more step'
|
||||
tagName='h2'
|
||||
/>
|
||||
<FormattedMessage
|
||||
id='email_subscriptions.submitted.lead'
|
||||
defaultMessage='Check your inbox for an email to finish signing up for email updates.'
|
||||
@@ -151,15 +148,14 @@ export const AccountSubscriptionForm: React.FC<{ accountId: string }> = ({
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className={classes.bannerBase} noValidate>
|
||||
<div className={classes.bannerTextAndActions}>
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
id='email_subscriptions.form.title'
|
||||
defaultMessage='Sign up for email updates from {name}'
|
||||
values={{
|
||||
name: <DisplayName account={account} variant='simple' />,
|
||||
}}
|
||||
/>
|
||||
</h2>
|
||||
<FormattedMessage
|
||||
id='email_subscriptions.form.title'
|
||||
defaultMessage='Sign up for email updates from {name}'
|
||||
tagName='h2'
|
||||
values={{
|
||||
name: <DisplayName account={account} variant='simple' />,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={classes.bannerInputButton}>
|
||||
|
||||
@@ -90,7 +90,7 @@ export const AccountListItem: React.FC<Props> = ({
|
||||
<ListItemLink
|
||||
to={`/@${account.acct}`}
|
||||
data-hover-card-account={accountId}
|
||||
subtitle={handle}
|
||||
subtitle={<span className={classes.handle}>{handle}</span>}
|
||||
>
|
||||
<DisplayNameSimple
|
||||
account={account}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
|
||||
@@ -24,6 +23,13 @@
|
||||
vertical-align: -4px;
|
||||
}
|
||||
|
||||
.handle {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.button {
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { assetHost } from 'mastodon/utils/config';
|
||||
|
||||
import { unicodeMapping } from '../features/emoji/emoji_unicode_mapping_light';
|
||||
|
||||
export default class AutosuggestEmoji extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
emoji: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { emoji } = this.props;
|
||||
let url;
|
||||
|
||||
if (emoji.custom) {
|
||||
url = emoji.imageUrl;
|
||||
} else {
|
||||
const mapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')];
|
||||
|
||||
if (!mapping) {
|
||||
return null;
|
||||
}
|
||||
|
||||
url = `${assetHost}/emoji/${mapping.filename}.svg`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='autosuggest-emoji'>
|
||||
<img
|
||||
className='emojione'
|
||||
src={url}
|
||||
alt={emoji.native || emoji.colons}
|
||||
/>
|
||||
|
||||
<div className='autosuggest-emoji__name'>{emoji.colons}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
20
app/javascript/mastodon/components/autosuggest_emoji.tsx
Normal file
20
app/javascript/mastodon/components/autosuggest_emoji.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { Emoji } from './emoji';
|
||||
|
||||
interface LegacyEmoji {
|
||||
id: string;
|
||||
custom?: boolean;
|
||||
native?: string;
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
export const AutosuggestEmoji: FC<{ emoji: LegacyEmoji }> = ({ emoji }) => {
|
||||
const colons = `:${emoji.id}:`;
|
||||
return (
|
||||
<div className='autosuggest-emoji'>
|
||||
<Emoji code={emoji.native ?? colons} />
|
||||
<div className='autosuggest-emoji__name'>{colons}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -9,8 +9,9 @@ import Overlay from 'react-overlays/Overlay';
|
||||
|
||||
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
|
||||
|
||||
import AutosuggestEmoji from './autosuggest_emoji';
|
||||
import { AutosuggestEmoji } from './autosuggest_emoji';
|
||||
import { AutosuggestHashtag } from './autosuggest_hashtag';
|
||||
import { LocalCustomEmojiProvider } from './emoji/context';
|
||||
|
||||
const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => {
|
||||
let word;
|
||||
@@ -219,15 +220,17 @@ export default class AutosuggestInput extends ImmutablePureComponent {
|
||||
spellCheck={spellCheck}
|
||||
/>
|
||||
|
||||
<Overlay show={!(suggestionsHidden || suggestions.isEmpty())} offset={[0, 0]} placement='bottom' target={this.input} popperConfig={{ strategy: 'fixed' }}>
|
||||
{({ props }) => (
|
||||
<div {...props}>
|
||||
<div className='autosuggest-textarea__suggestions' style={{ width: this.input?.clientWidth }}>
|
||||
{suggestions.map(this.renderSuggestion)}
|
||||
<LocalCustomEmojiProvider>
|
||||
<Overlay show={!(suggestionsHidden || suggestions.isEmpty())} offset={[0, 0]} placement='bottom' target={this.input} popperConfig={{ strategy: 'fixed' }}>
|
||||
{({ props }) => (
|
||||
<div {...props}>
|
||||
<div className='autosuggest-textarea__suggestions' style={{ width: this.input?.clientWidth }}>
|
||||
{suggestions.map(this.renderSuggestion)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Overlay>
|
||||
)}
|
||||
</Overlay>
|
||||
</LocalCustomEmojiProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,8 +10,9 @@ import Textarea from 'react-textarea-autosize';
|
||||
|
||||
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
|
||||
|
||||
import AutosuggestEmoji from './autosuggest_emoji';
|
||||
import { AutosuggestEmoji } from './autosuggest_emoji';
|
||||
import { AutosuggestHashtag } from './autosuggest_hashtag';
|
||||
import { LocalCustomEmojiProvider } from './emoji/context';
|
||||
|
||||
const textAtCursorMatchesToken = (str, caretPosition) => {
|
||||
let word;
|
||||
@@ -218,15 +219,17 @@ const AutosuggestTextarea = forwardRef(({
|
||||
lang={lang}
|
||||
/>
|
||||
|
||||
<Overlay show={!(suggestionsHidden || suggestions.isEmpty())} offset={[0, 0]} placement='bottom' target={textareaRef} popperConfig={{ strategy: 'fixed' }}>
|
||||
{({ props }) => (
|
||||
<div {...props}>
|
||||
<div className='autosuggest-textarea__suggestions' style={{ width: textareaRef.current?.clientWidth }}>
|
||||
{suggestions.map(renderSuggestion)}
|
||||
<LocalCustomEmojiProvider>
|
||||
<Overlay show={!(suggestionsHidden || suggestions.isEmpty())} offset={[0, 0]} placement='bottom' target={textareaRef} popperConfig={{ strategy: 'fixed' }}>
|
||||
{({ props }) => (
|
||||
<div {...props}>
|
||||
<div className='autosuggest-textarea__suggestions' style={{ width: textareaRef.current?.clientWidth }}>
|
||||
{suggestions.map(renderSuggestion)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Overlay>
|
||||
)}
|
||||
</Overlay>
|
||||
</LocalCustomEmojiProvider>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -74,6 +74,24 @@ export const Compact: Story = {
|
||||
play: buttonTest,
|
||||
};
|
||||
|
||||
export const CompactSecondary: Story = {
|
||||
args: {
|
||||
compact: true,
|
||||
secondary: true,
|
||||
children: 'Compact secondary button',
|
||||
},
|
||||
play: buttonTest,
|
||||
};
|
||||
|
||||
export const CompactPlain: Story = {
|
||||
args: {
|
||||
compact: true,
|
||||
plain: true,
|
||||
children: 'Compact plain button',
|
||||
},
|
||||
play: buttonTest,
|
||||
};
|
||||
|
||||
export const Dangerous: Story = {
|
||||
args: {
|
||||
dangerous: true,
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { MouseEventHandler, PropsWithChildren } from 'react';
|
||||
import type {
|
||||
FC,
|
||||
MouseEventHandler,
|
||||
PropsWithChildren,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
@@ -8,6 +13,7 @@ import {
|
||||
} from 'react';
|
||||
|
||||
import { cleanExtraEmojis } from '@/mastodon/features/emoji/normalize';
|
||||
import { useCustomEmojis } from '@/mastodon/hooks/useCustomEmojis';
|
||||
import { autoPlayGif } from '@/mastodon/initial_state';
|
||||
import { polymorphicForwardRef } from '@/types/polymorphic';
|
||||
import type {
|
||||
@@ -103,3 +109,10 @@ export const CustomEmojiProvider = ({
|
||||
</CustomEmojiContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const LocalCustomEmojiProvider: FC<{ children: ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const emojis = useCustomEmojis();
|
||||
return <CustomEmojiProvider emojis={emojis}>{children}</CustomEmojiProvider>;
|
||||
};
|
||||
|
||||
@@ -35,6 +35,10 @@
|
||||
color: var(--color-text-secondary);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-text-status-links);
|
||||
}
|
||||
}
|
||||
|
||||
[data-color-scheme='dark'] .defaultImage {
|
||||
|
||||
@@ -48,10 +48,14 @@
|
||||
|
||||
.status {
|
||||
// If there's no content, we need to compensate for the parent's
|
||||
// flex gap to avoid extra spacing below the field.
|
||||
// flex gap to avoid extra spacing below or next to the field.
|
||||
&:empty {
|
||||
margin-top: calc(-1 * var(--form-field-label-gap));
|
||||
}
|
||||
|
||||
[data-input-placement^='inline'] &:empty {
|
||||
margin-inline-start: calc(-1 * var(--form-field-label-gap));
|
||||
}
|
||||
}
|
||||
|
||||
.inputWrapper {
|
||||
|
||||
@@ -24,7 +24,7 @@ export interface FieldStatus {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface FieldWrapperProps {
|
||||
export interface FieldWrapperProps {
|
||||
label: ReactNode;
|
||||
hint?: ReactNode;
|
||||
required?: boolean;
|
||||
|
||||
@@ -4,11 +4,17 @@ import { forwardRef } from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { FormFieldWrapper } from './form_field_wrapper';
|
||||
import type { CommonFieldWrapperProps } from './form_field_wrapper';
|
||||
import type {
|
||||
CommonFieldWrapperProps,
|
||||
FieldWrapperProps,
|
||||
} from './form_field_wrapper';
|
||||
import classes from './select.module.scss';
|
||||
|
||||
interface Props
|
||||
extends ComponentPropsWithoutRef<'select'>, CommonFieldWrapperProps {}
|
||||
extends
|
||||
ComponentPropsWithoutRef<'select'>,
|
||||
CommonFieldWrapperProps,
|
||||
Pick<FieldWrapperProps, 'inputPlacement'> {}
|
||||
|
||||
/**
|
||||
* A simple form field for single-item selections.
|
||||
@@ -19,13 +25,28 @@ interface Props
|
||||
*/
|
||||
|
||||
export const SelectField = forwardRef<HTMLSelectElement, Props>(
|
||||
({ id, label, hint, required, status, children, ...otherProps }, ref) => (
|
||||
(
|
||||
{
|
||||
id,
|
||||
label,
|
||||
hint,
|
||||
required,
|
||||
status,
|
||||
inputPlacement,
|
||||
children,
|
||||
wrapperClassName,
|
||||
...otherProps
|
||||
},
|
||||
ref,
|
||||
) => (
|
||||
<FormFieldWrapper
|
||||
label={label}
|
||||
hint={hint}
|
||||
required={required}
|
||||
status={status}
|
||||
inputId={id}
|
||||
inputPlacement={inputPlacement}
|
||||
className={wrapperClassName}
|
||||
>
|
||||
{(inputProps) => (
|
||||
<Select {...otherProps} {...inputProps} ref={ref}>
|
||||
|
||||
@@ -30,7 +30,7 @@ export const ListItemWrapper: React.FC<WrapperProps> = ({
|
||||
return (
|
||||
<div {...otherProps} className={classNames(classes.wrapper, className)}>
|
||||
{icon}
|
||||
<div>{children}</div>
|
||||
<div className={classes.main}>{children}</div>
|
||||
{sideContent && (
|
||||
<span className={classes.sideContent}>{sideContent}</span>
|
||||
)}
|
||||
@@ -67,9 +67,14 @@ interface LinkProps
|
||||
extends React.ComponentPropsWithoutRef<typeof Link>, ContentProps {}
|
||||
|
||||
export const ListItemLink = polymorphicForwardRef<'h3', LinkProps>(
|
||||
({ as, subtitle, children, className, ...otherProps }, ref) => {
|
||||
({ as, subtitle, subtitleId, children, className, ...otherProps }, ref) => {
|
||||
return (
|
||||
<ListItemContent ref={ref} as={as} subtitle={subtitle}>
|
||||
<ListItemContent
|
||||
ref={ref}
|
||||
as={as}
|
||||
subtitle={subtitle}
|
||||
subtitleId={subtitleId}
|
||||
>
|
||||
<Link className={classNames(className, 'focusable')} {...otherProps}>
|
||||
{children}
|
||||
</Link>
|
||||
@@ -82,9 +87,14 @@ interface ButtonProps
|
||||
extends React.ComponentPropsWithoutRef<'button'>, ContentProps {}
|
||||
|
||||
export const ListItemButton = polymorphicForwardRef<'h3', ButtonProps>(
|
||||
({ as, subtitle, children, className, ...otherProps }, ref) => {
|
||||
({ as, subtitle, subtitleId, children, className, ...otherProps }, ref) => {
|
||||
return (
|
||||
<ListItemContent as={as} ref={ref} subtitle={subtitle}>
|
||||
<ListItemContent
|
||||
as={as}
|
||||
ref={ref}
|
||||
subtitle={subtitle}
|
||||
subtitleId={subtitleId}
|
||||
>
|
||||
<button
|
||||
type='button'
|
||||
className={classNames(className, 'focusable')}
|
||||
|
||||
@@ -14,6 +14,11 @@
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.main {
|
||||
min-width: 0;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 500;
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
.list {
|
||||
--_item-gap: var(--number-fields-gap, 24px);
|
||||
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
gap: 4px 24px;
|
||||
gap: 4px var(--_item-gap);
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ const messages = defineMessages({
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
server: state.getIn(['server', 'server']),
|
||||
server: state.server.server,
|
||||
});
|
||||
|
||||
class ServerBanner extends PureComponent {
|
||||
@@ -40,7 +40,7 @@ class ServerBanner extends PureComponent {
|
||||
|
||||
render () {
|
||||
const { server, intl } = this.props;
|
||||
const isLoading = server.get('isLoading');
|
||||
const isLoading = server.isLoading;
|
||||
|
||||
return (
|
||||
<div className='server-banner'>
|
||||
@@ -50,8 +50,8 @@ class ServerBanner extends PureComponent {
|
||||
|
||||
<NavLink to='/about'>
|
||||
<ServerHeroImage
|
||||
blurhash={server.getIn(['thumbnail', 'blurhash'])}
|
||||
src={server.getIn(['thumbnail', 'url'])}
|
||||
blurhash={server.item?.thumbnail.blurhash}
|
||||
src={server.item?.thumbnail.url}
|
||||
alt={intl.formatMessage(messages.aboutThisServer)}
|
||||
className='server-banner__hero'
|
||||
/>
|
||||
@@ -66,14 +66,14 @@ class ServerBanner extends PureComponent {
|
||||
<br />
|
||||
<Skeleton width='70%' />
|
||||
</>
|
||||
) : server.get('description')}
|
||||
) : server.item?.description}
|
||||
</div>
|
||||
|
||||
<div className='server-banner__meta'>
|
||||
<div className='server-banner__meta__column'>
|
||||
<h4><FormattedMessage id='server_banner.administered_by' defaultMessage='Administered by:' /></h4>
|
||||
|
||||
<Account id={server.getIn(['contact', 'account', 'id'])} size={36} minimal />
|
||||
<Account id={server.item?.contact.account?.id} size={36} minimal />
|
||||
</div>
|
||||
|
||||
<div className='server-banner__meta__column'>
|
||||
@@ -87,7 +87,7 @@ class ServerBanner extends PureComponent {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<strong className='server-banner__number'><ShortNumber value={server.getIn(['usage', 'users', 'active_month'])} /></strong>
|
||||
<strong className='server-banner__number'><ShortNumber value={server.item?.usage.users.active_month} /></strong>
|
||||
<br />
|
||||
<span className='server-banner__number-label' title={intl.formatMessage(messages.aboutActiveUsers)}><FormattedMessage id='server_banner.active_users' defaultMessage='active users' /></span>
|
||||
</>
|
||||
|
||||
@@ -32,6 +32,7 @@ import StatusActionBar from './status_action_bar';
|
||||
import StatusContent from './status_content';
|
||||
import { StatusThreadLabel } from './status_thread_label';
|
||||
import { CollectionPreviewCard } from '../features/collections/components/collection_preview_card';
|
||||
import { compareUrls } from '../utils/compare_urls';
|
||||
|
||||
const domParser = new DOMParser();
|
||||
|
||||
@@ -555,7 +556,7 @@ class Status extends ImmutablePureComponent {
|
||||
).find((item) => compareUrls(item.get('url'), cardUrl));
|
||||
|
||||
if (taggedCollection) {
|
||||
media = <CollectionPreviewCard collection={taggedCollection} />;
|
||||
media = <CollectionPreviewCard collection={taggedCollection.toJS()} />;
|
||||
} else {
|
||||
media = (
|
||||
<Card
|
||||
@@ -565,7 +566,7 @@ class Status extends ImmutablePureComponent {
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else if (status.get('tagged_collections').size) {
|
||||
} else if (status.get('tagged_collections').size && !status.get('quote')) {
|
||||
const firstLinkedCollection = status.get('tagged_collections').first();
|
||||
if (firstLinkedCollection) {
|
||||
media = (
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { ComponentProps, FC } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import type { ApiCollectionJSON } from '@/mastodon/api_types/collections';
|
||||
import type { ApiMentionJSON } from '@/mastodon/api_types/statuses';
|
||||
import { getCollectionPath } from '@/mastodon/features/collections/utils';
|
||||
import type { OnElementHandler } from '@/mastodon/utils/html';
|
||||
@@ -15,7 +14,7 @@ export interface HandledLinkProps {
|
||||
prevText?: string;
|
||||
hashtagAccountId?: string;
|
||||
mention?: Pick<ApiMentionJSON, 'id' | 'acct'>;
|
||||
collection?: Pick<ApiCollectionJSON, 'id'>;
|
||||
collectionId?: string;
|
||||
}
|
||||
|
||||
export const HandledLink: FC<HandledLinkProps & ComponentProps<'a'>> = ({
|
||||
@@ -24,7 +23,7 @@ export const HandledLink: FC<HandledLinkProps & ComponentProps<'a'>> = ({
|
||||
prevText,
|
||||
hashtagAccountId,
|
||||
mention,
|
||||
collection,
|
||||
collectionId,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
@@ -61,11 +60,11 @@ export const HandledLink: FC<HandledLinkProps & ComponentProps<'a'>> = ({
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
} else if (collection) {
|
||||
} else if (collectionId) {
|
||||
return (
|
||||
<Link
|
||||
className={classNames(className)}
|
||||
to={getCollectionPath(collection.id)}
|
||||
to={getCollectionPath(collectionId)}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
@@ -98,15 +97,18 @@ export const HandledLink: FC<HandledLinkProps & ComponentProps<'a'>> = ({
|
||||
|
||||
export const useElementHandledLink = ({
|
||||
hashtagAccountId,
|
||||
hrefToCollectionId: hrefToCollection,
|
||||
hrefToMention,
|
||||
}: {
|
||||
hashtagAccountId?: string;
|
||||
hrefToCollectionId?: (href: string) => string | undefined;
|
||||
hrefToMention?: (href: string) => ApiMentionJSON | undefined;
|
||||
} = {}) => {
|
||||
const onElement = useCallback<OnElementHandler>(
|
||||
(element, { key, ...props }, children) => {
|
||||
if (element instanceof HTMLAnchorElement) {
|
||||
const mention = hrefToMention?.(element.href);
|
||||
const collectionId = hrefToCollection?.(element.href);
|
||||
return (
|
||||
<HandledLink
|
||||
{...props}
|
||||
@@ -116,6 +118,7 @@ export const useElementHandledLink = ({
|
||||
prevText={element.previousSibling?.textContent ?? undefined}
|
||||
hashtagAccountId={hashtagAccountId}
|
||||
mention={mention}
|
||||
collectionId={collectionId}
|
||||
>
|
||||
{children}
|
||||
</HandledLink>
|
||||
@@ -123,7 +126,7 @@ export const useElementHandledLink = ({
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
[hashtagAccountId, hrefToMention],
|
||||
[hashtagAccountId, hrefToCollection, hrefToMention],
|
||||
);
|
||||
return { onElement };
|
||||
};
|
||||
|
||||
@@ -69,7 +69,7 @@ class TranslateButton extends PureComponent {
|
||||
}
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
languages: state.getIn(['server', 'translationLanguages', 'items']),
|
||||
languages: state.server.translationLanguages.items,
|
||||
});
|
||||
|
||||
class StatusContent extends PureComponent {
|
||||
@@ -170,7 +170,7 @@ class StatusContent extends PureComponent {
|
||||
text={element.innerText}
|
||||
hashtagAccountId={this.props.status.getIn(['account', 'id'])}
|
||||
mention={mention?.toJSON()}
|
||||
collection={taggedCollection?.toJSON()}
|
||||
collectionId={taggedCollection?.get('id')}
|
||||
key={key}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { IntlShape } from 'react-intl';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import type { List as ImmutableList } from 'immutable';
|
||||
|
||||
import type { SelectItem } from '@/mastodon/components/dropdown_selector';
|
||||
import { Select } from '@/mastodon/components/form_fields';
|
||||
@@ -123,14 +122,13 @@ export const RulesSection: FC<RulesSectionProps> = ({ isLoading = false }) => {
|
||||
};
|
||||
|
||||
const selectRules = (state: RootState) => {
|
||||
const rules = state.server.getIn([
|
||||
'server',
|
||||
'rules',
|
||||
]) as ImmutableList<Rule> | null;
|
||||
const rules = state.server.server.item?.rules;
|
||||
|
||||
if (!rules) {
|
||||
return [];
|
||||
}
|
||||
return rules.toJS() as Rule[];
|
||||
|
||||
return rules;
|
||||
};
|
||||
|
||||
const rulesSelector = createSelector(
|
||||
|
||||
@@ -41,10 +41,10 @@ const severityMessages = {
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
server: state.getIn(['server', 'server']),
|
||||
server: state.server.server,
|
||||
locale: state.getIn(['meta', 'locale']),
|
||||
extendedDescription: state.getIn(['server', 'extendedDescription']),
|
||||
domainBlocks: state.getIn(['server', 'domainBlocks']),
|
||||
extendedDescription: state.server.extendedDescription,
|
||||
domainBlocks: state.server.domainBlocks,
|
||||
});
|
||||
|
||||
class About extends PureComponent {
|
||||
@@ -76,7 +76,7 @@ class About extends PureComponent {
|
||||
|
||||
render () {
|
||||
const { multiColumn, intl, server, extendedDescription, domainBlocks, locale } = this.props;
|
||||
const isLoading = server.get('isLoading');
|
||||
const isLoading = server.isLoading;
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn} label={intl.formatMessage(messages.title)}>
|
||||
@@ -84,13 +84,13 @@ class About extends PureComponent {
|
||||
<div className='about__header'>
|
||||
<ServerHeroImage
|
||||
withAltBadge
|
||||
alt={server.getIn(['thumbnail', 'description']) ?? ''}
|
||||
blurhash={server.getIn(['thumbnail', 'blurhash'])}
|
||||
src={server.getIn(['thumbnail', 'url'])}
|
||||
srcSet={server.getIn(['thumbnail', 'versions'])?.map((value, key) => `${value} ${key.replace('@', '')}`).join(', ')}
|
||||
alt={server.item?.thumbnail.description ?? ''}
|
||||
blurhash={server.item?.thumbnail.blurhash}
|
||||
src={server.item?.thumbnail.url}
|
||||
srcSet={Object.keys(server.item?.thumbnail.versions ?? {}).map((key) => `${server.item?.thumbnail.versions && server.item.thumbnail.versions[key]} ${key.replace('@', '')}`).join(', ')}
|
||||
className='about__header__hero'
|
||||
/>
|
||||
<h1>{isLoading ? <Skeleton width='10ch' /> : server.get('domain')}</h1>
|
||||
<h1>{isLoading ? <Skeleton width='10ch' /> : server.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' rel='noopener'>Mastodon</a> }} /></p>
|
||||
</div>
|
||||
|
||||
@@ -98,7 +98,7 @@ class About extends PureComponent {
|
||||
<div className='about__meta__column'>
|
||||
<h4><FormattedMessage id='server_banner.administered_by' defaultMessage='Administered by:' /></h4>
|
||||
|
||||
<Account id={server.getIn(['contact', 'account', 'id'])} size={36} minimal />
|
||||
<Account id={server.item?.contact?.account?.id} size={36} minimal />
|
||||
</div>
|
||||
|
||||
<hr className='about__meta__divider' />
|
||||
@@ -106,12 +106,12 @@ class About extends PureComponent {
|
||||
<div className='about__meta__column'>
|
||||
<h4><FormattedMessage id='about.contact' defaultMessage='Contact:' /></h4>
|
||||
|
||||
{isLoading ? <Skeleton width='10ch' /> : <a className='about__mail' href={`mailto:${server.getIn(['contact', 'email'])}`}>{server.getIn(['contact', 'email'])}</a>}
|
||||
{isLoading ? <Skeleton width='10ch' /> : <a className='about__mail' href={`mailto:${server.item?.contact?.email}`}>{server.item?.contact?.email}</a>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Section open title={intl.formatMessage(messages.title)}>
|
||||
{extendedDescription.get('isLoading') ? (
|
||||
{extendedDescription.isLoading ? (
|
||||
<>
|
||||
<Skeleton width='100%' />
|
||||
<br />
|
||||
@@ -121,10 +121,10 @@ class About extends PureComponent {
|
||||
<br />
|
||||
<Skeleton width='70%' />
|
||||
</>
|
||||
) : (extendedDescription.get('content')?.length > 0 ? (
|
||||
) : (extendedDescription.item?.content?.length > 0 ? (
|
||||
<div
|
||||
className='prose'
|
||||
dangerouslySetInnerHTML={{ __html: extendedDescription.get('content') }}
|
||||
dangerouslySetInnerHTML={{ __html: extendedDescription.item?.content }}
|
||||
/>
|
||||
) : (
|
||||
<p><FormattedMessage id='about.not_available' defaultMessage='This information has not been made available on this server.' /></p>
|
||||
@@ -134,26 +134,26 @@ class About extends PureComponent {
|
||||
<RulesSection />
|
||||
|
||||
<Section title={intl.formatMessage(messages.blocks)} onOpen={this.handleDomainBlocksOpen}>
|
||||
{domainBlocks.get('isLoading') ? (
|
||||
{domainBlocks.isLoading ? (
|
||||
<>
|
||||
<Skeleton width='100%' />
|
||||
<br />
|
||||
<Skeleton width='70%' />
|
||||
</>
|
||||
) : (domainBlocks.get('isAvailable') ? (
|
||||
) : (domainBlocks.isAvailable ? (
|
||||
<>
|
||||
<p><FormattedMessage id='about.domain_blocks.preamble' defaultMessage='Mastodon generally allows you to view content from and interact with users from any other server in the fediverse. These are the exceptions that have been made on this particular server.' /></p>
|
||||
|
||||
{domainBlocks.get('items').size > 0 && (
|
||||
{domainBlocks.items.length > 0 && (
|
||||
<div className='about__domain-blocks'>
|
||||
{domainBlocks.get('items').map(block => (
|
||||
<div className='about__domain-blocks__domain' key={block.get('domain')}>
|
||||
{domainBlocks.items.map(block => (
|
||||
<div className='about__domain-blocks__domain' key={block.domain}>
|
||||
<div className='about__domain-blocks__domain__header'>
|
||||
<h6><span title={`SHA-256: ${block.get('digest')}`}>{block.get('domain')}</span></h6>
|
||||
<span className='about__domain-blocks__domain__type' title={intl.formatMessage(severityMessages[block.get('severity')].explanation)}>{intl.formatMessage(severityMessages[block.get('severity')].title)}</span>
|
||||
<h6><span title={`SHA-256: ${block.digest}`}>{block.domain}</span></h6>
|
||||
<span className='about__domain-blocks__domain__type' title={intl.formatMessage(severityMessages[block.severity].explanation)}>{intl.formatMessage(severityMessages[block.severity].title)}</span>
|
||||
</div>
|
||||
|
||||
<p>{(block.get('comment') || '').length > 0 ? block.get('comment') : <FormattedMessage id='about.domain_blocks.no_reason_available' defaultMessage='Reason not available' />}</p>
|
||||
<p>{(block.comment ?? '').length > 0 ? block.comment : <FormattedMessage id='about.domain_blocks.no_reason_available' defaultMessage='Reason not available' />}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
import type { ChangeEventHandler, KeyboardEventHandler } from 'react';
|
||||
import { useState, useRef, useCallback, useId } from 'react';
|
||||
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import Textarea from 'react-textarea-autosize';
|
||||
|
||||
import { submitAccountNote } from '@/mastodon/actions/account_notes';
|
||||
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
placeholder: {
|
||||
id: 'account_note.placeholder',
|
||||
defaultMessage: 'Click to add a note',
|
||||
},
|
||||
});
|
||||
|
||||
const AccountNoteUI: React.FC<{
|
||||
initialValue: string | undefined;
|
||||
onSubmit: (newNote: string) => void;
|
||||
wasSaved: boolean;
|
||||
}> = ({ initialValue, onSubmit, wasSaved }) => {
|
||||
const intl = useIntl();
|
||||
const uniqueId = useId();
|
||||
const [value, setValue] = useState(initialValue ?? '');
|
||||
const isLoading = initialValue === undefined;
|
||||
const canSubmitOnBlurRef = useRef(true);
|
||||
|
||||
const handleChange = useCallback<ChangeEventHandler<HTMLTextAreaElement>>(
|
||||
(e) => {
|
||||
setValue(e.target.value);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback<KeyboardEventHandler<HTMLTextAreaElement>>(
|
||||
(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
|
||||
setValue(initialValue ?? '');
|
||||
|
||||
canSubmitOnBlurRef.current = false;
|
||||
e.currentTarget.blur();
|
||||
} else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
|
||||
onSubmit(value);
|
||||
|
||||
canSubmitOnBlurRef.current = false;
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
},
|
||||
[initialValue, onSubmit, value],
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
if (initialValue !== value && canSubmitOnBlurRef.current) {
|
||||
onSubmit(value);
|
||||
}
|
||||
canSubmitOnBlurRef.current = true;
|
||||
}, [initialValue, onSubmit, value]);
|
||||
|
||||
return (
|
||||
<div className='account__header__account-note'>
|
||||
<label htmlFor={`account-note-${uniqueId}`}>
|
||||
<FormattedMessage
|
||||
id='account.account_note_header'
|
||||
defaultMessage='Personal note'
|
||||
/>{' '}
|
||||
<span
|
||||
aria-live='polite'
|
||||
role='status'
|
||||
className='inline-alert'
|
||||
style={{ opacity: wasSaved ? 1 : 0 }}
|
||||
>
|
||||
{wasSaved && (
|
||||
<FormattedMessage id='generic.saved' defaultMessage='Saved' />
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
{isLoading ? (
|
||||
<div className='account__header__account-note__loading-indicator-wrapper'>
|
||||
<LoadingIndicator />
|
||||
</div>
|
||||
) : (
|
||||
<Textarea
|
||||
id={`account-note-${uniqueId}`}
|
||||
className='account__header__account-note__content'
|
||||
placeholder={intl.formatMessage(messages.placeholder)}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AccountNote: React.FC<{
|
||||
accountId: string;
|
||||
}> = ({ accountId }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const initialValue = useAppSelector((state) =>
|
||||
state.relationships.get(accountId)?.get('note'),
|
||||
);
|
||||
const [wasSaved, setWasSaved] = useState(false);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(note: string) => {
|
||||
setWasSaved(true);
|
||||
void dispatch(submitAccountNote({ accountId, note }));
|
||||
|
||||
setTimeout(() => {
|
||||
setWasSaved(false);
|
||||
}, 2000);
|
||||
},
|
||||
[dispatch, accountId],
|
||||
);
|
||||
|
||||
return (
|
||||
<AccountNoteUI
|
||||
key={`${accountId}-${initialValue}`}
|
||||
initialValue={initialValue}
|
||||
wasSaved={wasSaved}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,210 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState, useRef, useCallback, useId } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import Overlay from 'react-overlays/Overlay';
|
||||
|
||||
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
||||
import BadgeIcon from '@/material-icons/400-24px/badge.svg?react';
|
||||
import GlobeIcon from '@/material-icons/400-24px/globe.svg?react';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
|
||||
export const DomainPill: React.FC<{
|
||||
domain: string;
|
||||
username: string;
|
||||
isSelf: boolean;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}> = ({ domain, username, isSelf, children, className }) => {
|
||||
const accessibilityId = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const triggerRef = useRef(null);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
setOpen(!open);
|
||||
}, [open, setOpen]);
|
||||
|
||||
const handleExpandClick = useCallback(() => {
|
||||
setExpanded(!expanded);
|
||||
}, [expanded, setExpanded]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className={classNames('account__domain-pill', className, {
|
||||
active: open,
|
||||
})}
|
||||
ref={triggerRef}
|
||||
onClick={handleClick}
|
||||
aria-expanded={open}
|
||||
aria-controls={accessibilityId}
|
||||
type='button'
|
||||
>
|
||||
{children ?? domain}
|
||||
</button>
|
||||
|
||||
<Overlay
|
||||
show={open}
|
||||
rootClose
|
||||
onHide={handleClick}
|
||||
offset={[5, 5]}
|
||||
target={triggerRef}
|
||||
>
|
||||
{({ props }) => (
|
||||
<div
|
||||
{...props}
|
||||
role='region'
|
||||
id={accessibilityId}
|
||||
className='account__domain-pill__popout dropdown-animation'
|
||||
>
|
||||
<div className='account__domain-pill__popout__header'>
|
||||
<div className='account__domain-pill__popout__header__icon'>
|
||||
<Icon id='' icon={BadgeIcon} />
|
||||
</div>
|
||||
<h3>
|
||||
<FormattedMessage
|
||||
id='domain_pill.whats_in_a_handle'
|
||||
defaultMessage="What's in a handle?"
|
||||
/>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className='account__domain-pill__popout__handle'>
|
||||
<div className='account__domain-pill__popout__handle__label'>
|
||||
{isSelf ? (
|
||||
<FormattedMessage
|
||||
id='domain_pill.your_handle'
|
||||
defaultMessage='Your handle:'
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='domain_pill.their_handle'
|
||||
defaultMessage='Their handle:'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className='account__domain-pill__popout__handle__handle'>
|
||||
@{username}@{domain}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='account__domain-pill__popout__parts'>
|
||||
<div>
|
||||
<div className='account__domain-pill__popout__parts__icon'>
|
||||
<Icon id='' icon={AlternateEmailIcon} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h6>
|
||||
<FormattedMessage
|
||||
id='domain_pill.username'
|
||||
defaultMessage='Username'
|
||||
/>
|
||||
</h6>
|
||||
<p>
|
||||
{isSelf ? (
|
||||
<FormattedMessage
|
||||
id='domain_pill.your_username'
|
||||
defaultMessage='Your unique identifier on this server. It’s possible to find users with the same username on different servers.'
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='domain_pill.their_username'
|
||||
defaultMessage='Their unique identifier on their server. It’s possible to find users with the same username on different servers.'
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className='account__domain-pill__popout__parts__icon'>
|
||||
<Icon id='' icon={GlobeIcon} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h6>
|
||||
<FormattedMessage
|
||||
id='domain_pill.server'
|
||||
defaultMessage='Server'
|
||||
/>
|
||||
</h6>
|
||||
<p>
|
||||
{isSelf ? (
|
||||
<FormattedMessage
|
||||
id='domain_pill.your_server'
|
||||
defaultMessage='Your digital home, where all of your posts live. Don’t like this one? Transfer servers at any time and bring your followers, too.'
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='domain_pill.their_server'
|
||||
defaultMessage='Their digital home, where all of their posts live.'
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
{isSelf ? (
|
||||
<FormattedMessage
|
||||
id='domain_pill.who_you_are'
|
||||
defaultMessage='Because your handle says who you are and where you are, people can interact with you across the social web of <button>ActivityPub-powered platforms</button>.'
|
||||
values={{
|
||||
button: (x) => (
|
||||
<button
|
||||
onClick={handleExpandClick}
|
||||
className='link-button'
|
||||
type='button'
|
||||
>
|
||||
{x}
|
||||
</button>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='domain_pill.who_they_are'
|
||||
defaultMessage='Since handles say who someone is and where they are, you can interact with people across the social web of <button>ActivityPub-powered platforms</button>.'
|
||||
values={{
|
||||
button: (x) => (
|
||||
<button
|
||||
onClick={handleExpandClick}
|
||||
className='link-button'
|
||||
type='button'
|
||||
>
|
||||
{x}
|
||||
</button>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{expanded && (
|
||||
<>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id='domain_pill.activitypub_like_language'
|
||||
defaultMessage='ActivityPub is like the language Mastodon speaks with other social networks.'
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id='domain_pill.activitypub_lets_connect'
|
||||
defaultMessage='It lets you connect and interact with people not just on Mastodon, but across different social apps too.'
|
||||
/>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Overlay>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,41 +0,0 @@
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { DisplayName } from '@/mastodon/components/display_name';
|
||||
|
||||
export default class FollowRequestNote extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.record.isRequired,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { account, onAuthorize, onReject } = this.props;
|
||||
|
||||
return (
|
||||
<div className='follow-request-banner'>
|
||||
<div className='follow-request-banner__message'>
|
||||
<FormattedMessage id='account.requested_follow' defaultMessage='{name} has requested to follow you' values={{ name: <DisplayName account={account} variant='simple' /> }} />
|
||||
</div>
|
||||
|
||||
<div className='follow-request-banner__action'>
|
||||
<button type='button' className='button button-secondary button--confirmation' onClick={onAuthorize}>
|
||||
<Icon id='check' icon={CheckIcon} />
|
||||
<FormattedMessage id='follow_request.authorize' defaultMessage='Authorize' />
|
||||
</button>
|
||||
|
||||
<button type='button' className='button button-secondary button--destructive' onClick={onReject}>
|
||||
<Icon id='times' icon={CloseIcon} />
|
||||
<FormattedMessage id='follow_request.reject' defaultMessage='Reject' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { authorizeFollowRequest, rejectFollowRequest } from 'mastodon/actions/accounts';
|
||||
|
||||
import FollowRequestNote from '../components/follow_request_note';
|
||||
|
||||
const mapDispatchToProps = (dispatch, { account }) => ({
|
||||
onAuthorize () {
|
||||
dispatch(authorizeFollowRequest(account.get('id')));
|
||||
},
|
||||
|
||||
onReject () {
|
||||
dispatch(rejectFollowRequest(account.get('id')));
|
||||
},
|
||||
});
|
||||
|
||||
export default connect(null, mapDispatchToProps)(FollowRequestNote);
|
||||
@@ -37,10 +37,7 @@ const selectTags = createAppSelector(
|
||||
[
|
||||
(state) => state.profileEdit,
|
||||
(state) =>
|
||||
state.server.getIn(
|
||||
['server', 'accounts', 'max_featured_tags'],
|
||||
10,
|
||||
) as number,
|
||||
state.server.server.item?.configuration.accounts.max_featured_tags ?? 0,
|
||||
],
|
||||
(profileEdit, maxTags) => ({
|
||||
tags: profileEdit.profile?.featuredTags ?? [],
|
||||
|
||||
@@ -133,12 +133,7 @@ export const AccountEdit: FC = () => {
|
||||
|
||||
const maxFieldCount = useAppSelector(
|
||||
(state) =>
|
||||
(state.server.getIn([
|
||||
'server',
|
||||
'configuration',
|
||||
'accounts',
|
||||
'max_profile_fields',
|
||||
]) as number | undefined) ?? 4,
|
||||
state.server.server.item?.configuration.accounts.max_profile_fields ?? 4,
|
||||
);
|
||||
|
||||
const handleOpenModal = useCallback(
|
||||
|
||||
@@ -36,13 +36,7 @@ export const BioModal: FC<BaseConfirmationModalProps> = ({ onClose }) => {
|
||||
);
|
||||
const [newBio, setNewBio] = useState(bio ?? '');
|
||||
const maxLength = useAppSelector(
|
||||
(state) =>
|
||||
state.server.getIn([
|
||||
'server',
|
||||
'configuration',
|
||||
'accounts',
|
||||
'max_note_length',
|
||||
]) as number | undefined,
|
||||
(state) => state.server.server.item?.configuration.accounts.max_note_length,
|
||||
);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
@@ -9,8 +9,6 @@ import type { FC, FocusEventHandler } from 'react';
|
||||
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import type { Map as ImmutableMap } from 'immutable';
|
||||
|
||||
import { closeModal } from '@/mastodon/actions/modal';
|
||||
import { Button } from '@/mastodon/components/button';
|
||||
import type { FieldStatus } from '@/mastodon/components/form_fields';
|
||||
@@ -97,15 +95,10 @@ const messages = defineMessages({
|
||||
// We have two different values- the hard limit set by the server,
|
||||
// and the soft limit for mobile display.
|
||||
const selectFieldLimits = createAppSelector(
|
||||
[
|
||||
(state) =>
|
||||
state.server.getIn(['server', 'configuration', 'accounts']) as
|
||||
| ImmutableMap<string, number>
|
||||
| undefined,
|
||||
],
|
||||
[(state) => state.server.server.item?.configuration.accounts],
|
||||
(accounts) => ({
|
||||
nameLimit: accounts?.get('profile_field_name_limit'),
|
||||
valueLimit: accounts?.get('profile_field_value_limit'),
|
||||
nameLimit: accounts?.profile_field_name_limit,
|
||||
valueLimit: accounts?.profile_field_value_limit,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -84,15 +84,8 @@ export const ImageAltTextField: FC<{
|
||||
}> = ({ imageSrc, altText, onChange, hideTip }) => {
|
||||
const altLimit = useAppSelector(
|
||||
(state) =>
|
||||
state.server.getIn(
|
||||
[
|
||||
'server',
|
||||
'configuration',
|
||||
'accounts',
|
||||
'max_header_description_length',
|
||||
],
|
||||
150,
|
||||
) as number,
|
||||
state.server.server.item?.configuration.accounts
|
||||
.max_header_description_length ?? 0,
|
||||
);
|
||||
|
||||
const handleChange: ChangeEventHandler<HTMLTextAreaElement> = useCallback(
|
||||
|
||||
@@ -33,12 +33,7 @@ export const NameModal: FC<BaseConfirmationModalProps> = ({ onClose }) => {
|
||||
);
|
||||
const maxLength = useAppSelector(
|
||||
(state) =>
|
||||
state.server.getIn([
|
||||
'server',
|
||||
'configuration',
|
||||
'accounts',
|
||||
'max_display_name_length',
|
||||
]) as number | undefined,
|
||||
state.server.server.item?.configuration.accounts.max_display_name_length,
|
||||
);
|
||||
|
||||
const [newName, setNewName] = useState(displayName ?? '');
|
||||
|
||||
@@ -2,14 +2,15 @@ import { useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { useParams } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { openModal } from '@/mastodon/actions/modal';
|
||||
import { Button } from '@/mastodon/components/button';
|
||||
import { DisplayName } from '@/mastodon/components/display_name';
|
||||
import { EmptyState } from '@/mastodon/components/empty_state';
|
||||
import { LimitedAccountHint } from '@/mastodon/components/limited_account_hint';
|
||||
import { areCollectionsEnabled } from '@/mastodon/features/collections/utils';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
|
||||
import { useAppDispatch } from '@/mastodon/store';
|
||||
|
||||
@@ -28,8 +29,8 @@ export const EmptyMessage: React.FC<EmptyMessageProps> = ({
|
||||
blockedBy,
|
||||
withoutAddCollectionButton,
|
||||
}) => {
|
||||
const { acct } = useParams<{ acct?: string }>();
|
||||
const me = useCurrentAccountId();
|
||||
const account = useAccount(accountId);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
@@ -116,12 +117,12 @@ export const EmptyMessage: React.FC<EmptyMessageProps> = ({
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
if (acct) {
|
||||
if (account) {
|
||||
title = (
|
||||
<FormattedMessage
|
||||
id='empty_column.account_featured.other'
|
||||
defaultMessage='{acct} has not featured anything yet.'
|
||||
values={{ acct }}
|
||||
values={{ acct: <DisplayName variant='simple' account={account} /> }}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -24,6 +24,7 @@ import Column from '@/mastodon/features/ui/components/column';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import { useAccountId } from '@/mastodon/hooks/useAccountId';
|
||||
import { useAccountVisibility } from '@/mastodon/hooks/useAccountVisibility';
|
||||
import { me } from '@/mastodon/initial_state';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
import AddIcon from '@/material-icons/400-24px/add.svg?react';
|
||||
|
||||
@@ -173,12 +174,14 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
|
||||
defaultMessage='Collections'
|
||||
/>
|
||||
</h2>
|
||||
<SubheadingLink to='/collections/new' icon={AddIcon}>
|
||||
<FormattedMessage
|
||||
id='account.featured.new_collection'
|
||||
defaultMessage='New collection'
|
||||
/>
|
||||
</SubheadingLink>
|
||||
{accountId === me && (
|
||||
<SubheadingLink to='/collections/new' icon={AddIcon}>
|
||||
<FormattedMessage
|
||||
id='account.featured.new_collection'
|
||||
defaultMessage='New collection'
|
||||
/>
|
||||
</SubheadingLink>
|
||||
)}
|
||||
</Subheading>
|
||||
{hasCollections ? (
|
||||
<ItemList>
|
||||
|
||||
@@ -25,6 +25,7 @@ export function usePinnedStatusIds({
|
||||
tagged,
|
||||
pinned: true,
|
||||
replies: true,
|
||||
boosts: true,
|
||||
});
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
@@ -31,7 +31,7 @@ const selectServerName = createAppSelector(
|
||||
[
|
||||
(state) => state.accounts,
|
||||
(_, accountId: string) => accountId,
|
||||
(state) => state.server.getIn(['server', 'domain']) as string | undefined,
|
||||
(state) => state.server.server.item?.domain,
|
||||
],
|
||||
(accounts, accountId, serverDomain) => {
|
||||
const acct = accounts.getIn([accountId, 'acct']) as string | undefined;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
.wrapper {
|
||||
--list-item-gap: 16px;
|
||||
--list-item-padding-block: 12px;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
&:has(input:disabled) {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useId } from 'react';
|
||||
|
||||
import type { ApiCollectionJSON } from '@/mastodon/api_types/collections';
|
||||
import { Toggle } from '@/mastodon/components/form_fields';
|
||||
import {
|
||||
ListItemContent,
|
||||
ListItemWrapper,
|
||||
} from '@/mastodon/components/list_item';
|
||||
import {
|
||||
AvatarGrid,
|
||||
CollectionInfo,
|
||||
} from 'mastodon/features/collections/components/collection_lockup';
|
||||
|
||||
import classes from './collection_toggle.module.scss';
|
||||
|
||||
export interface CollectionToggleProps {
|
||||
collection: ApiCollectionJSON;
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
subtitle?: React.ReactNode;
|
||||
onChange: React.ChangeEventHandler<HTMLInputElement>;
|
||||
}
|
||||
|
||||
export const CollectionToggle: React.FC<CollectionToggleProps> = ({
|
||||
collection,
|
||||
checked,
|
||||
disabled,
|
||||
subtitle,
|
||||
onChange,
|
||||
}) => {
|
||||
const uniqueId = useId();
|
||||
const toggleId = `${uniqueId}-toggle`;
|
||||
const infoId = `${uniqueId}-info`;
|
||||
|
||||
return (
|
||||
<ListItemWrapper
|
||||
className={classes.wrapper}
|
||||
icon={
|
||||
<AvatarGrid
|
||||
accountIds={collection.items.map((item) => item.account_id)}
|
||||
sensitive={collection.sensitive}
|
||||
/>
|
||||
}
|
||||
sideContent={
|
||||
<Toggle
|
||||
id={toggleId}
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={onChange}
|
||||
aria-describedby={infoId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ListItemContent
|
||||
as='label'
|
||||
htmlFor={toggleId}
|
||||
subtitle={
|
||||
subtitle ?? (
|
||||
<CollectionInfo
|
||||
collection={collection}
|
||||
withTimestamp={false}
|
||||
withAuthorHandle={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
{collection.name}
|
||||
</ListItemContent>
|
||||
</ListItemWrapper>
|
||||
);
|
||||
};
|
||||
143
app/javascript/mastodon/features/collection_adder/index.tsx
Normal file
143
app/javascript/mastodon/features/collection_adder/index.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { useCallback, useId, useState } from 'react';
|
||||
|
||||
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import type { ApiCollectionJSON } from '@/mastodon/api_types/collections';
|
||||
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
||||
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
|
||||
import type { Account } from '@/mastodon/models/account';
|
||||
import {
|
||||
addCollectionItem,
|
||||
removeCollectionItem,
|
||||
} from '@/mastodon/reducers/slices/collections';
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
import { MAX_COLLECTION_ACCOUNT_COUNT } from '../collections/editor/accounts';
|
||||
import { useCollectionsCreatedBy } from '../collections/overview/created_by_you';
|
||||
|
||||
import { CollectionToggle } from './collection_toggle';
|
||||
|
||||
const messages = defineMessages({
|
||||
close: {
|
||||
id: 'lightbox.close',
|
||||
defaultMessage: 'Close',
|
||||
},
|
||||
});
|
||||
|
||||
const ListItem: React.FC<{
|
||||
collection: ApiCollectionJSON;
|
||||
account: Account;
|
||||
}> = ({ collection, account }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
const accountItemInCollection = collection.items.find(
|
||||
(item) => item.account_id === account.id,
|
||||
);
|
||||
const isAccountInCollection = !!accountItemInCollection;
|
||||
|
||||
const addOrRemove = useCallback(
|
||||
async (shouldAdd: boolean) => {
|
||||
setIsUpdating(true);
|
||||
|
||||
if (shouldAdd) {
|
||||
await dispatch(
|
||||
addCollectionItem({
|
||||
collectionId: collection.id,
|
||||
accountId: account.id,
|
||||
}),
|
||||
);
|
||||
} else if (accountItemInCollection) {
|
||||
await dispatch(
|
||||
removeCollectionItem({
|
||||
collectionId: collection.id,
|
||||
itemId: accountItemInCollection.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
setIsUpdating(false);
|
||||
},
|
||||
[account.id, collection.id, accountItemInCollection, dispatch],
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
void addOrRemove(e.target.checked);
|
||||
},
|
||||
[addOrRemove],
|
||||
);
|
||||
|
||||
const hasMaxItemCount =
|
||||
!isAccountInCollection &&
|
||||
collection.item_count >= MAX_COLLECTION_ACCOUNT_COUNT;
|
||||
|
||||
return (
|
||||
<CollectionToggle
|
||||
key={collection.id}
|
||||
collection={collection}
|
||||
disabled={isUpdating || hasMaxItemCount}
|
||||
subtitle={
|
||||
hasMaxItemCount ? (
|
||||
<FormattedMessage
|
||||
id='collections.search_accounts_max_reached'
|
||||
defaultMessage='You have added the maximum number of accounts'
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
checked={isAccountInCollection}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const CollectionAdder: React.FC<{
|
||||
accountId: string;
|
||||
onClose: () => void;
|
||||
}> = ({ accountId, onClose }) => {
|
||||
const intl = useIntl();
|
||||
const titleId = useId();
|
||||
const account = useAppSelector((state) => state.accounts.get(accountId));
|
||||
const currentAccountId = useCurrentAccountId();
|
||||
const { collections, status } = useCollectionsCreatedBy(currentAccountId);
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal dialog-modal'>
|
||||
<div className='dialog-modal__header'>
|
||||
<IconButton
|
||||
className='dialog-modal__header__close'
|
||||
title={intl.formatMessage(messages.close)}
|
||||
icon='times'
|
||||
iconComponent={CloseIcon}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
<span className='dialog-modal__header__title' id={titleId}>
|
||||
<FormattedMessage
|
||||
id='collections.add_to_collection'
|
||||
defaultMessage='Add {name} to collections'
|
||||
values={{ name: <strong>@{account?.acct}</strong> }}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='dialog-modal__content'>
|
||||
<div
|
||||
className='lists-scrollable'
|
||||
role='group'
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
{status === 'loading' || !account ? (
|
||||
<LoadingIndicator />
|
||||
) : (
|
||||
collections.map((item) => (
|
||||
<ListItem key={item.id} collection={item} account={account} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -57,10 +57,45 @@ export const CollectionLockup: React.FC<CollectionLockupProps> = ({
|
||||
className,
|
||||
}) => {
|
||||
const { id, name } = collection;
|
||||
|
||||
return (
|
||||
<ListItemWrapper
|
||||
className={classNames(classes.wrapper, className)}
|
||||
icon={
|
||||
<AvatarGrid
|
||||
accountIds={collection.items.map((item) => item.account_id)}
|
||||
sensitive={collection.sensitive}
|
||||
/>
|
||||
}
|
||||
sideContent={sideContent}
|
||||
>
|
||||
<ListItemLink
|
||||
as='h3'
|
||||
to={getCollectionPath(id)}
|
||||
subtitle={
|
||||
<CollectionInfo
|
||||
collection={collection}
|
||||
withAuthorHandle={withAuthorHandle}
|
||||
withTimestamp={withTimestamp}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{name}
|
||||
</ListItemLink>
|
||||
</ListItemWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export const CollectionInfo: React.FC<
|
||||
Pick<
|
||||
CollectionLockupProps,
|
||||
'collection' | 'withAuthorHandle' | 'withTimestamp'
|
||||
>
|
||||
> = ({ collection, withAuthorHandle, withTimestamp }) => {
|
||||
const authorAccount = useAccount(collection.account_id);
|
||||
const authorHandle = useAccountHandle(authorAccount, domain);
|
||||
|
||||
const collectionInfo = (
|
||||
return (
|
||||
<ul>
|
||||
{collection.sensitive && (
|
||||
<li className='sr-only'>
|
||||
@@ -98,25 +133,4 @@ export const CollectionLockup: React.FC<CollectionLockupProps> = ({
|
||||
)}
|
||||
</ul>
|
||||
);
|
||||
|
||||
return (
|
||||
<ListItemWrapper
|
||||
className={classNames(classes.wrapper, className)}
|
||||
icon={
|
||||
<AvatarGrid
|
||||
accountIds={collection.items.map((item) => item.account_id)}
|
||||
sensitive={collection.sensitive}
|
||||
/>
|
||||
}
|
||||
sideContent={sideContent}
|
||||
>
|
||||
<ListItemLink
|
||||
as='h3'
|
||||
to={getCollectionPath(id)}
|
||||
subtitle={collectionInfo}
|
||||
>
|
||||
{name}
|
||||
</ListItemLink>
|
||||
</ListItemWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -24,11 +24,13 @@ import classes from './share_modal.module.scss';
|
||||
const messages = defineMessages({
|
||||
shareTextOwn: {
|
||||
id: 'collection.share_template_own',
|
||||
defaultMessage: 'Check out my new collection: {link}',
|
||||
defaultMessage: 'Check out my new collection:',
|
||||
description: 'Collection links are appended after a new line',
|
||||
},
|
||||
shareTextOther: {
|
||||
id: 'collection.share_template_other',
|
||||
defaultMessage: 'Check out this cool collection: {link}',
|
||||
defaultMessage: 'Check out this cool collection:',
|
||||
description: 'Collection links are appended after a new line',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -51,13 +53,10 @@ export const CollectionShareModal: React.FC<{
|
||||
}, [collectionLink]);
|
||||
|
||||
const handleShareViaPost = useCallback(() => {
|
||||
const shareMessage = isOwnCollection
|
||||
? intl.formatMessage(messages.shareTextOwn, {
|
||||
link: collectionLink,
|
||||
})
|
||||
: intl.formatMessage(messages.shareTextOther, {
|
||||
link: collectionLink,
|
||||
});
|
||||
let shareMessage = isOwnCollection
|
||||
? intl.formatMessage(messages.shareTextOwn)
|
||||
: intl.formatMessage(messages.shareTextOther);
|
||||
shareMessage += `\n\n${collectionLink}`;
|
||||
|
||||
onClose();
|
||||
dispatch(changeCompose(shareMessage));
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { PendingBadge } from '@/mastodon/components/badge';
|
||||
import { SelectField } from '@/mastodon/components/form_fields';
|
||||
import { useSearchParam } from '@/mastodon/hooks/useSearchParam';
|
||||
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
|
||||
import type {
|
||||
ApiCollectionJSON,
|
||||
@@ -14,7 +16,6 @@ import {
|
||||
AccountListItemFollowButton,
|
||||
} from 'mastodon/components/account_list_item';
|
||||
import { Button } from 'mastodon/components/button';
|
||||
import { Callout } from 'mastodon/components/callout';
|
||||
import {
|
||||
Article,
|
||||
ItemList,
|
||||
@@ -35,50 +36,6 @@ const messages = defineMessages({
|
||||
},
|
||||
});
|
||||
|
||||
const SensitiveScreen: React.FC<{
|
||||
sensitive: boolean | undefined;
|
||||
focusTargetRef: React.RefObject<HTMLHeadingElement>;
|
||||
children: React.ReactNode;
|
||||
}> = ({ sensitive, focusTargetRef, children }) => {
|
||||
const [isVisible, setIsVisible] = useState(!sensitive);
|
||||
|
||||
const showAnyway = useCallback(() => {
|
||||
setIsVisible(true);
|
||||
setTimeout(() => {
|
||||
focusTargetRef.current?.focus();
|
||||
}, 0);
|
||||
}, [focusTargetRef]);
|
||||
|
||||
if (isVisible) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<Callout
|
||||
variant='warning'
|
||||
title={
|
||||
<FormattedMessage
|
||||
id='collections.detail.sensitive_content'
|
||||
defaultMessage='Sensitive content'
|
||||
/>
|
||||
}
|
||||
primaryLabel={
|
||||
<FormattedMessage
|
||||
id='content_warning.show_short'
|
||||
defaultMessage='Show'
|
||||
/>
|
||||
}
|
||||
onPrimary={showAnyway}
|
||||
className={classes.sensitiveScreen}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='collections.detail.sensitive_note'
|
||||
defaultMessage='The description and accounts may not be suitable for all viewers.'
|
||||
/>
|
||||
</Callout>
|
||||
);
|
||||
};
|
||||
|
||||
type CollectionItemWithAccount = CollectionAccountItem & {
|
||||
account?: Account | null;
|
||||
};
|
||||
@@ -98,6 +55,43 @@ const getCollectionItems = createAppSelector(
|
||||
),
|
||||
);
|
||||
|
||||
function sortAccounts(
|
||||
accounts: CollectionItemWithAccount[],
|
||||
sortBy?: string,
|
||||
): CollectionItemWithAccount[] {
|
||||
if (!sortBy || sortBy === 'date_added') {
|
||||
return accounts;
|
||||
}
|
||||
|
||||
const sorted = [...accounts];
|
||||
|
||||
switch (sortBy) {
|
||||
case 'alphabetical':
|
||||
return sorted.sort((a, b) => {
|
||||
const nameA = a.account?.display_name ?? '';
|
||||
const nameB = b.account?.display_name ?? '';
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
|
||||
case 'last_active':
|
||||
return sorted.sort((a, b) => {
|
||||
const dateA = a.account?.last_status_at ?? '';
|
||||
const dateB = b.account?.last_status_at ?? '';
|
||||
return new Date(dateB).getTime() - new Date(dateA).getTime();
|
||||
});
|
||||
|
||||
case 'most_followers':
|
||||
return sorted.sort((a, b) => {
|
||||
const followersA = a.account?.followers_count ?? 0;
|
||||
const followersB = b.account?.followers_count ?? 0;
|
||||
return followersB - followersA;
|
||||
});
|
||||
|
||||
default:
|
||||
return accounts;
|
||||
}
|
||||
}
|
||||
|
||||
export const CollectionAccountsList: React.FC<{
|
||||
collection: ApiCollectionJSON;
|
||||
}> = ({ collection }) => {
|
||||
@@ -113,11 +107,20 @@ export const CollectionAccountsList: React.FC<{
|
||||
getCollectionItems(state, id),
|
||||
);
|
||||
|
||||
const [sortBy, setSortBy] = useSearchParam('sort', 'date_added');
|
||||
const changeSortBy = useCallback(
|
||||
(event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
setSortBy(event.target.value);
|
||||
},
|
||||
[setSortBy],
|
||||
);
|
||||
const sortedAccounts = sortAccounts(collectionAccounts, sortBy);
|
||||
|
||||
const { visibleAccounts, hiddenAccounts } = useMemo(() => {
|
||||
const visibleAccounts: CollectionItemWithAccount[] = [];
|
||||
const hiddenAccounts: CollectionItemWithAccount[] = [];
|
||||
|
||||
collectionAccounts.forEach((item) => {
|
||||
sortedAccounts.forEach((item) => {
|
||||
const { account, account_id } = item;
|
||||
|
||||
if (!isOwnCollection && !account) {
|
||||
@@ -134,7 +137,7 @@ export const CollectionAccountsList: React.FC<{
|
||||
});
|
||||
|
||||
return { visibleAccounts, hiddenAccounts };
|
||||
}, [collectionAccounts, isOwnCollection, relationships]);
|
||||
}, [sortedAccounts, isOwnCollection, relationships]);
|
||||
|
||||
const renderAccountItemButton = useCallback(
|
||||
({ relationship, accountId }: RenderButtonOptions) => {
|
||||
@@ -192,46 +195,81 @@ export const CollectionAccountsList: React.FC<{
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3
|
||||
className={classes.columnSubheading}
|
||||
tabIndex={-1}
|
||||
ref={listHeadingRef}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='collections.account_count'
|
||||
defaultMessage='{count, plural, one {# account} other {# accounts}}'
|
||||
values={{ count: collection.item_count }}
|
||||
/>
|
||||
</h3>
|
||||
<SensitiveScreen
|
||||
sensitive={!isOwnCollection && collection.sensitive}
|
||||
focusTargetRef={listHeadingRef}
|
||||
>
|
||||
<ItemList emptyMessage={intl.formatMessage(messages.empty)}>
|
||||
<TruncatedListItems
|
||||
visibleItems={visibleAccounts}
|
||||
truncatedItems={hiddenAccounts}
|
||||
toggleButton={{
|
||||
icon: VisibilityOffIcon,
|
||||
title: (
|
||||
<FormattedMessage
|
||||
id='collections.hidden_accounts_link'
|
||||
defaultMessage='{count, plural, one {# hidden account} other {# hidden accounts}}'
|
||||
values={{ count: hiddenAccounts.length }}
|
||||
/>
|
||||
),
|
||||
subtitle: (
|
||||
<FormattedMessage
|
||||
id='collections.hidden_accounts_description'
|
||||
defaultMessage='You’ve blocked or muted {count, plural, one {this user} other {these users}}'
|
||||
values={{ count: hiddenAccounts.length }}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
renderListItem={renderListItem}
|
||||
<div className={classes.subheadingWithSelect}>
|
||||
<h3
|
||||
className={classes.columnSubheading}
|
||||
tabIndex={-1}
|
||||
ref={listHeadingRef}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='collections.account_count'
|
||||
defaultMessage='{count, plural, one {# account} other {# accounts}}'
|
||||
values={{ count: collection.item_count }}
|
||||
/>
|
||||
</ItemList>
|
||||
</SensitiveScreen>
|
||||
</h3>
|
||||
<SelectField
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='collections.sort_by'
|
||||
defaultMessage='Sort by:'
|
||||
/>
|
||||
}
|
||||
value={sortBy}
|
||||
onChange={changeSortBy}
|
||||
inputPlacement='inline-end'
|
||||
className={classes.select}
|
||||
wrapperClassName={classes.selectWrapper}
|
||||
>
|
||||
<option value='alphabetical'>
|
||||
<FormattedMessage
|
||||
id='collections.sort_alphabetical'
|
||||
defaultMessage='Alphabetical'
|
||||
/>
|
||||
</option>
|
||||
<option value='last_active'>
|
||||
<FormattedMessage
|
||||
id='collections.sort_last_active'
|
||||
defaultMessage='Last active'
|
||||
/>
|
||||
</option>
|
||||
<option value='most_followers'>
|
||||
<FormattedMessage
|
||||
id='collections.sort_most_followers'
|
||||
defaultMessage='Most followers'
|
||||
/>
|
||||
</option>
|
||||
<option value='date_added'>
|
||||
<FormattedMessage
|
||||
id='collections.sort_date_added'
|
||||
defaultMessage='Date added'
|
||||
/>
|
||||
</option>
|
||||
</SelectField>
|
||||
</div>
|
||||
<ItemList emptyMessage={intl.formatMessage(messages.empty)}>
|
||||
<TruncatedListItems
|
||||
visibleItems={visibleAccounts}
|
||||
truncatedItems={hiddenAccounts}
|
||||
toggleButton={{
|
||||
icon: VisibilityOffIcon,
|
||||
title: (
|
||||
<FormattedMessage
|
||||
id='collections.hidden_accounts_link'
|
||||
defaultMessage='{count, plural, one {# hidden account} other {# hidden accounts}}'
|
||||
values={{ count: hiddenAccounts.length }}
|
||||
/>
|
||||
),
|
||||
subtitle: (
|
||||
<FormattedMessage
|
||||
id='collections.hidden_accounts_description'
|
||||
defaultMessage='You’ve blocked or muted {count, plural, one {this user} other {these users}}'
|
||||
values={{ count: hiddenAccounts.length }}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
renderListItem={renderListItem}
|
||||
/>
|
||||
</ItemList>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
@@ -138,9 +138,40 @@ export const PendingNote: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
|
||||
collection,
|
||||
const SensitiveContentNote: React.FC<{ onReveal: () => void }> = ({
|
||||
onReveal,
|
||||
}) => {
|
||||
return (
|
||||
<Callout
|
||||
variant='warning'
|
||||
title={
|
||||
<FormattedMessage
|
||||
id='collections.detail.sensitive_content'
|
||||
defaultMessage='Sensitive content'
|
||||
/>
|
||||
}
|
||||
primaryLabel={
|
||||
<FormattedMessage
|
||||
id='content_warning.show_short'
|
||||
defaultMessage='Show'
|
||||
/>
|
||||
}
|
||||
onPrimary={onReveal}
|
||||
className={classes.sensitiveScreen}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='collections.detail.sensitive_note'
|
||||
defaultMessage='The description and accounts may not be suitable for all viewers.'
|
||||
/>
|
||||
</Callout>
|
||||
);
|
||||
};
|
||||
|
||||
const CollectionHeader: React.FC<{
|
||||
collection: ApiCollectionJSON;
|
||||
withDescription: boolean;
|
||||
headingRef: React.RefObject<HTMLHeadingElement>;
|
||||
}> = ({ collection, withDescription, headingRef }) => {
|
||||
const intl = useIntl();
|
||||
const { name, description, tag, account_id, items } = collection;
|
||||
const dispatch = useAppDispatch();
|
||||
@@ -181,7 +212,9 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
|
||||
<div className={classes.titleWithMenu}>
|
||||
<div className={classes.titleWrapper}>
|
||||
{tag && <Badge label={`#${tag.name}`} icon={null} />}
|
||||
<h2 className={classes.name}>{name}</h2>
|
||||
<h2 className={classes.name} ref={headingRef} tabIndex={-1}>
|
||||
{name}
|
||||
</h2>
|
||||
<AuthorNote id={account_id} />
|
||||
</div>
|
||||
<div className={classes.headerButtonWrapper}>
|
||||
@@ -199,7 +232,9 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{description && <p className={classes.description}>{description}</p>}
|
||||
{withDescription && description && (
|
||||
<p className={classes.description}>{description}</p>
|
||||
)}
|
||||
{hasPendingAccounts && <PendingNote />}
|
||||
{isCurrentUserInCollection && (
|
||||
<RevokeControls
|
||||
@@ -211,6 +246,52 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
|
||||
);
|
||||
};
|
||||
|
||||
function useRevealSensitiveContent({
|
||||
sensitive,
|
||||
}: {
|
||||
sensitive: boolean | undefined;
|
||||
}) {
|
||||
const postRevealFocusTargetRef = useRef<HTMLHeadingElement>(null);
|
||||
const [isContentVisible, setIsContentVisible] = useState(!sensitive);
|
||||
|
||||
const revealContent = useCallback(() => {
|
||||
setIsContentVisible(true);
|
||||
setTimeout(() => {
|
||||
postRevealFocusTargetRef.current?.focus();
|
||||
}, 0);
|
||||
}, [postRevealFocusTargetRef]);
|
||||
|
||||
return {
|
||||
isContentVisible,
|
||||
revealContent,
|
||||
postRevealFocusTargetRef,
|
||||
};
|
||||
}
|
||||
|
||||
const ColumnContent: React.FC<{
|
||||
collection: ApiCollectionJSON;
|
||||
}> = ({ collection }) => {
|
||||
const { isContentVisible, revealContent, postRevealFocusTargetRef } =
|
||||
useRevealSensitiveContent({
|
||||
sensitive: collection.sensitive && collection.account_id !== me,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<CollectionHeader
|
||||
collection={collection}
|
||||
headingRef={postRevealFocusTargetRef}
|
||||
withDescription={isContentVisible}
|
||||
/>
|
||||
{isContentVisible ? (
|
||||
<CollectionAccountsList collection={collection} />
|
||||
) : (
|
||||
<SensitiveContentNote onReveal={revealContent} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const CollectionDetailPage: React.FC<{
|
||||
multiColumn?: boolean;
|
||||
}> = ({ multiColumn }) => {
|
||||
@@ -241,10 +322,7 @@ export const CollectionDetailPage: React.FC<{
|
||||
|
||||
<Scrollable>
|
||||
{collection ? (
|
||||
<>
|
||||
<CollectionHeader collection={collection} />
|
||||
<CollectionAccountsList collection={collection} />
|
||||
</>
|
||||
<ColumnContent collection={collection} />
|
||||
) : (
|
||||
<LoadingIndicator />
|
||||
)}
|
||||
|
||||
@@ -64,11 +64,30 @@
|
||||
}
|
||||
|
||||
.sensitiveScreen {
|
||||
margin: 16px;
|
||||
margin-inline: 16px;
|
||||
}
|
||||
|
||||
.subheadingWithSelect {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 16px;
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
.selectWrapper {
|
||||
// Align to right edge even when wrapped to new line
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.select {
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
background-color: var(--color-bg-primary);
|
||||
}
|
||||
|
||||
.columnSubheading {
|
||||
padding-inline: 16px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -39,11 +39,12 @@ import {
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
import { PendingNote } from '../detail';
|
||||
import { canAccountBeAdded, canAccountBeAddedByFollowers } from '../utils';
|
||||
|
||||
import classes from './styles.module.scss';
|
||||
import { WizardStepTitle } from './wizard_step_title';
|
||||
|
||||
const MAX_ACCOUNT_COUNT = 25;
|
||||
export const MAX_COLLECTION_ACCOUNT_COUNT = 25;
|
||||
|
||||
const AddedAccountItem: React.FC<{
|
||||
accountId: string;
|
||||
@@ -99,9 +100,6 @@ const renderAccountItem = (account: ApiMutedAccountJSON) => (
|
||||
|
||||
type GroupKey = 'available' | 'mustFollow' | 'disabled';
|
||||
|
||||
const canAccountBeAdded = (account: ApiMutedAccountJSON) =>
|
||||
['automatic', 'manual'].includes(account.feature_approval.current_user);
|
||||
|
||||
function groupSuggestions(
|
||||
accounts: ApiMutedAccountJSON[],
|
||||
relationships: ImmutableMap<string, Relationship>,
|
||||
@@ -113,12 +111,8 @@ function groupSuggestions(
|
||||
return 'available';
|
||||
}
|
||||
|
||||
const canAccountBeAddedByFollowers =
|
||||
account.feature_approval.automatic.includes('followers') ||
|
||||
account.feature_approval.manual.includes('followers');
|
||||
|
||||
if (
|
||||
canAccountBeAddedByFollowers &&
|
||||
canAccountBeAddedByFollowers(account) &&
|
||||
!relationships.get(account.id)?.following
|
||||
) {
|
||||
return 'mustFollow';
|
||||
@@ -212,7 +206,7 @@ export const CollectionAccounts: React.FC<{
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
|
||||
const hasItems = editorItems.length > 0;
|
||||
const hasMaxItems = editorItems.length === MAX_ACCOUNT_COUNT;
|
||||
const hasMaxItems = editorItems.length === MAX_COLLECTION_ACCOUNT_COUNT;
|
||||
|
||||
const {
|
||||
accounts: suggestedAccounts,
|
||||
@@ -406,7 +400,10 @@ export const CollectionAccounts: React.FC<{
|
||||
<FormattedMessage
|
||||
id='collections.hints.accounts_counter'
|
||||
defaultMessage='{count}/{max} accounts'
|
||||
values={{ count: editorItems.length, max: MAX_ACCOUNT_COUNT }}
|
||||
values={{
|
||||
count: editorItems.length,
|
||||
max: MAX_COLLECTION_ACCOUNT_COUNT,
|
||||
}}
|
||||
/>
|
||||
</AccountsHeadingElement>
|
||||
)}
|
||||
@@ -426,7 +423,7 @@ export const CollectionAccounts: React.FC<{
|
||||
id='collections.accounts.empty_description'
|
||||
defaultMessage='Add up to {count} accounts'
|
||||
values={{
|
||||
count: MAX_ACCOUNT_COUNT,
|
||||
count: MAX_COLLECTION_ACCOUNT_COUNT,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -46,8 +46,16 @@ import { WizardStepTitle } from './wizard_step_title';
|
||||
export const CollectionDetails: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const history = useHistory();
|
||||
const { id, name, description, topic, discoverable, sensitive, items } =
|
||||
useAppSelector((state) => state.collections.editor);
|
||||
const {
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
topic,
|
||||
language,
|
||||
discoverable,
|
||||
sensitive,
|
||||
items,
|
||||
} = useAppSelector((state) => state.collections.editor);
|
||||
|
||||
const handleNameChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -110,6 +118,7 @@ export const CollectionDetails: React.FC = () => {
|
||||
name,
|
||||
description,
|
||||
tag_name: topic || null,
|
||||
language: language || null,
|
||||
discoverable,
|
||||
sensitive,
|
||||
};
|
||||
@@ -125,6 +134,9 @@ export const CollectionDetails: React.FC = () => {
|
||||
sensitive,
|
||||
account_ids: items.map((item) => item.account_id),
|
||||
};
|
||||
if (language) {
|
||||
payload.language = language;
|
||||
}
|
||||
if (topic) {
|
||||
payload.tag_name = topic;
|
||||
}
|
||||
@@ -149,6 +161,7 @@ export const CollectionDetails: React.FC = () => {
|
||||
description,
|
||||
topic,
|
||||
discoverable,
|
||||
language,
|
||||
sensitive,
|
||||
dispatch,
|
||||
history,
|
||||
@@ -392,13 +405,8 @@ const renderTagItem = (item: TagSearchResult) => (
|
||||
|
||||
const LanguageField: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const initialLanguage = useAppSelector(
|
||||
(state) => state.compose.get('default_language') as string,
|
||||
);
|
||||
const { language } = useAppSelector((state) => state.collections.editor);
|
||||
|
||||
const selectedLanguage = language ?? initialLanguage;
|
||||
|
||||
const handleLanguageChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
dispatch(
|
||||
@@ -419,7 +427,7 @@ const LanguageField: React.FC = () => {
|
||||
defaultMessage='Language'
|
||||
/>
|
||||
}
|
||||
value={selectedLanguage}
|
||||
value={language}
|
||||
onChange={handleLanguageChange}
|
||||
>
|
||||
<option value=''>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import { EmptyState } from 'mastodon/components/empty_state';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
import { ItemList } from 'mastodon/components/scrollable_list/components';
|
||||
@@ -34,6 +35,7 @@ function useCollectionsFeaturing(accountId: string | null | undefined) {
|
||||
|
||||
export const CollectionsFeaturingYou: React.FC = () => {
|
||||
const accountId = useAccountId();
|
||||
const account = useAccount(accountId);
|
||||
|
||||
const { collections, status } = useCollectionsFeaturing(accountId);
|
||||
|
||||
@@ -46,16 +48,43 @@ export const CollectionsFeaturingYou: React.FC = () => {
|
||||
}
|
||||
|
||||
if (collections.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
message={
|
||||
<FormattedMessage
|
||||
id='empty_column.collections.featured_in'
|
||||
defaultMessage='You have not been added to any collections yet.'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
if (account?.discoverable) {
|
||||
return (
|
||||
<EmptyState
|
||||
message={
|
||||
<FormattedMessage
|
||||
id='empty_column.collections.featured_in'
|
||||
defaultMessage='You have not been added to any collections yet.'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<EmptyState
|
||||
message={
|
||||
<>
|
||||
<FormattedMessage
|
||||
id='empty_column.collections.featured_in'
|
||||
defaultMessage='You have not been added to any collections yet.'
|
||||
/>
|
||||
<br />
|
||||
<FormattedMessage
|
||||
id='empty_column.collections.featured_in_undiscoverable'
|
||||
defaultMessage='In order for people to add you to collections, you need to allow featuring in discovery experiences from <link>Preferences > Privacy and reach</link>'
|
||||
values={{
|
||||
link: (chunks) => (
|
||||
<a href='/settings/privacy#account_discoverable'>
|
||||
{chunks}
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ApiMutedAccountJSON } from '@/mastodon/api_types/accounts';
|
||||
import type { Account } from '@/mastodon/models/account';
|
||||
import { isServerFeatureEnabled } from '@/mastodon/utils/environment';
|
||||
|
||||
export function areCollectionsEnabled() {
|
||||
@@ -5,3 +7,12 @@ export function areCollectionsEnabled() {
|
||||
}
|
||||
|
||||
export const getCollectionPath = (id: string) => `/collections/${id}`;
|
||||
|
||||
export const canAccountBeAdded = (account: ApiMutedAccountJSON | Account) =>
|
||||
['automatic', 'manual'].includes(account.feature_approval.current_user);
|
||||
|
||||
export const canAccountBeAddedByFollowers = (
|
||||
account: ApiMutedAccountJSON | Account,
|
||||
) =>
|
||||
account.feature_approval.automatic.includes('followers') ||
|
||||
account.feature_approval.manual.includes('followers');
|
||||
|
||||
@@ -82,10 +82,6 @@ class ComposeForm extends ImmutablePureComponent {
|
||||
autoFocus: false,
|
||||
};
|
||||
|
||||
state = {
|
||||
highlighted: false,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.textareaRef = createRef(null);
|
||||
@@ -220,8 +216,6 @@ class ComposeForm extends ImmutablePureComponent {
|
||||
Promise.resolve().then(() => {
|
||||
this.textareaRef.current.setSelectionRange(selectionStart, selectionEnd);
|
||||
this.textareaRef.current.focus();
|
||||
this.setState({ highlighted: true });
|
||||
this.timeout = setTimeout(() => this.setState({ highlighted: false }), 700);
|
||||
}).catch(console.error);
|
||||
} else if(prevProps.isSubmitting && !this.props.isSubmitting) {
|
||||
this.textareaRef.current.focus();
|
||||
@@ -252,7 +246,6 @@ class ComposeForm extends ImmutablePureComponent {
|
||||
|
||||
render () {
|
||||
const { intl, onPaste, onDrop, autoFocus, withoutNavigation, maxChars, isSubmitting } = this.props;
|
||||
const { highlighted } = this.state;
|
||||
|
||||
return (
|
||||
<form className='compose-form' onSubmit={this.handleSubmit}>
|
||||
@@ -260,7 +253,7 @@ class ComposeForm extends ImmutablePureComponent {
|
||||
{!withoutNavigation && <NavigationBar />}
|
||||
<Warning />
|
||||
|
||||
<div className={classNames('compose-form__highlightable', { active: highlighted })} ref={this.setRef}>
|
||||
<div className='compose-form__highlightable' ref={this.setRef}>
|
||||
<EditIndicator />
|
||||
|
||||
<div className='compose-form__dropdowns'>
|
||||
|
||||
@@ -228,7 +228,7 @@ class EmojiPickerMenuImpl extends PureComponent {
|
||||
|
||||
handleClick = (emoji, event) => {
|
||||
if (!emoji.native) {
|
||||
emoji.native = emoji.colons;
|
||||
emoji.native = `:${emoji.id}:`;
|
||||
}
|
||||
if (!(event.ctrlKey || event.metaKey)) {
|
||||
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import emojify from '../emoji';
|
||||
|
||||
describe('emoji', () => {
|
||||
describe('.emojify', () => {
|
||||
it('ignores unknown shortcodes', () => {
|
||||
expect(emojify(':foobarbazfake:')).toEqual(':foobarbazfake:');
|
||||
});
|
||||
|
||||
it('ignores shortcodes inside of tags', () => {
|
||||
expect(emojify('<p data-foo=":smile:"></p>')).toEqual('<p data-foo=":smile:"></p>');
|
||||
});
|
||||
|
||||
it('works with unclosed tags', () => {
|
||||
expect(emojify('hello>')).toEqual('hello>');
|
||||
expect(emojify('<hello')).toEqual('');
|
||||
});
|
||||
|
||||
it('works with unclosed shortcodes', () => {
|
||||
expect(emojify('smile:')).toEqual('smile:');
|
||||
expect(emojify(':smile')).toEqual(':smile');
|
||||
});
|
||||
|
||||
it('does unicode', () => {
|
||||
expect(emojify('\uD83D\uDC69\u200D\uD83D\uDC69\u200D\uD83D\uDC66\u200D\uD83D\uDC66')).toEqual(
|
||||
'<img draggable="false" class="emojione" alt="👩👩👦👦" title=":woman-woman-boy-boy:" src="/emoji/1f469-200d-1f469-200d-1f466-200d-1f466.svg">');
|
||||
expect(emojify('👨👩👧👧')).toEqual(
|
||||
'<img draggable="false" class="emojione" alt="👨👩👧👧" title=":man-woman-girl-girl:" src="/emoji/1f468-200d-1f469-200d-1f467-200d-1f467.svg">');
|
||||
expect(emojify('👩👩👦')).toEqual('<img draggable="false" class="emojione" alt="👩👩👦" title=":woman-woman-boy:" src="/emoji/1f469-200d-1f469-200d-1f466.svg">');
|
||||
expect(emojify('\u2757')).toEqual(
|
||||
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg">');
|
||||
});
|
||||
|
||||
it('does multiple unicode', () => {
|
||||
expect(emojify('\u2757 #\uFE0F\u20E3')).toEqual(
|
||||
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg"> <img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg">');
|
||||
expect(emojify('\u2757#\uFE0F\u20E3')).toEqual(
|
||||
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg"><img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg">');
|
||||
expect(emojify('\u2757 #\uFE0F\u20E3 \u2757')).toEqual(
|
||||
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg"> <img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg"> <img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg">');
|
||||
expect(emojify('foo \u2757 #\uFE0F\u20E3 bar')).toEqual(
|
||||
'foo <img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg"> <img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg"> bar');
|
||||
});
|
||||
|
||||
it('ignores unicode inside of tags', () => {
|
||||
expect(emojify('<p data-foo="\uD83D\uDC69\uD83D\uDC69\uD83D\uDC66"></p>')).toEqual('<p data-foo="\uD83D\uDC69\uD83D\uDC69\uD83D\uDC66"></p>');
|
||||
});
|
||||
|
||||
it('does multiple emoji properly (issue 5188)', () => {
|
||||
expect(emojify('👌🌈💕')).toEqual('<img draggable="false" class="emojione" alt="👌" title=":ok_hand:" src="/emoji/1f44c.svg"><img draggable="false" class="emojione" alt="🌈" title=":rainbow:" src="/emoji/1f308.svg"><img draggable="false" class="emojione" alt="💕" title=":two_hearts:" src="/emoji/1f495.svg">');
|
||||
expect(emojify('👌 🌈 💕')).toEqual('<img draggable="false" class="emojione" alt="👌" title=":ok_hand:" src="/emoji/1f44c.svg"> <img draggable="false" class="emojione" alt="🌈" title=":rainbow:" src="/emoji/1f308.svg"> <img draggable="false" class="emojione" alt="💕" title=":two_hearts:" src="/emoji/1f495.svg">');
|
||||
});
|
||||
|
||||
it('does an emoji that has no shortcode', () => {
|
||||
expect(emojify('👁🗨')).toEqual('<img draggable="false" class="emojione" alt="👁🗨" title="" src="/emoji/1f441-200d-1f5e8.svg">');
|
||||
});
|
||||
|
||||
it('does an emoji whose filename is irregular', () => {
|
||||
expect(emojify('↙️')).toEqual('<img draggable="false" class="emojione" alt="↙️" title=":arrow_lower_left:" src="/emoji/2199.svg">');
|
||||
});
|
||||
|
||||
it('avoid emojifying on invisible text', () => {
|
||||
expect(emojify('<a href="http://example.com/test%F0%9F%98%84"><span class="invisible">http://</span><span class="ellipsis">example.com/te</span><span class="invisible">st😄</span></a>'))
|
||||
.toEqual('<a href="http://example.com/test%F0%9F%98%84"><span class="invisible">http://</span><span class="ellipsis">example.com/te</span><span class="invisible">st😄</span></a>');
|
||||
expect(emojify('<span class="invisible">:luigi:</span>', { ':luigi:': { static_url: 'luigi.exe' } }))
|
||||
.toEqual('<span class="invisible">:luigi:</span>');
|
||||
});
|
||||
|
||||
it('avoid emojifying on invisible text with nested tags', () => {
|
||||
expect(emojify('<span class="invisible">😄<span class="foo">bar</span>😴</span>😇'))
|
||||
.toEqual('<span class="invisible">😄<span class="foo">bar</span>😴</span><img draggable="false" class="emojione" alt="😇" title=":innocent:" src="/emoji/1f607.svg">');
|
||||
expect(emojify('<span class="invisible">😄<span class="invisible">😕</span>😴</span>😇'))
|
||||
.toEqual('<span class="invisible">😄<span class="invisible">😕</span>😴</span><img draggable="false" class="emojione" alt="😇" title=":innocent:" src="/emoji/1f607.svg">');
|
||||
expect(emojify('<span class="invisible">😄<br>😴</span>😇'))
|
||||
.toEqual('<span class="invisible">😄<br>😴</span><img draggable="false" class="emojione" alt="😇" title=":innocent:" src="/emoji/1f607.svg">');
|
||||
});
|
||||
|
||||
it('does not emojify emojis with textual presentation VS15 character', () => {
|
||||
expect(emojify('✴︎')) // This is U+2734 EIGHT POINTED BLACK STAR then U+FE0E VARIATION SELECTOR-15
|
||||
.toEqual('✴︎');
|
||||
});
|
||||
|
||||
it('does a simple emoji properly', () => {
|
||||
expect(emojify('♀♂'))
|
||||
.toEqual('<img draggable="false" class="emojione" alt="♀" title=":female_sign:" src="/emoji/2640.svg"><img draggable="false" class="emojione" alt="♂" title=":male_sign:" src="/emoji/2642.svg">');
|
||||
});
|
||||
|
||||
it('does an emoji containing ZWJ properly', () => {
|
||||
expect(emojify('💂♀️💂♂️'))
|
||||
.toEqual('<img draggable="false" class="emojione" alt="💂♀️" title=":female-guard:" src="/emoji/1f482-200d-2640-fe0f.svg"><img draggable="false" class="emojione" alt="💂♂️" title=":male-guard:" src="/emoji/1f482-200d-2642-fe0f.svg">');
|
||||
});
|
||||
|
||||
it('keeps ordering as expected (issue fixed by PR 20677)', () => {
|
||||
expect(emojify('<p>💕 <a class="hashtag" href="https://example.com/tags/foo" rel="nofollow noopener" target="_blank">#<span>foo</span></a> test: foo.</p>'))
|
||||
.toEqual('<p><img draggable="false" class="emojione" alt="💕" title=":two_hearts:" src="/emoji/1f495.svg"> <a class="hashtag" href="https://example.com/tags/foo" rel="nofollow noopener" target="_blank">#<span>foo</span></a> test: foo.</p>');
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user