Compare commits
132 Commits
dion/logge
...
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 | ||
|
|
53d0499254 | ||
|
|
27e90864ac | ||
|
|
9c8e1855a5 | ||
|
|
11803e3d04 | ||
|
|
c47922602f | ||
|
|
60a437e045 | ||
|
|
f24f98ce40 | ||
|
|
2fed2edd5e | ||
|
|
92c9fda9e6 | ||
|
|
d0c8eb2f1b | ||
|
|
90c812ed16 | ||
|
|
1a2038775c | ||
|
|
65b7ddb3e8 | ||
|
|
f6f45c43a9 | ||
|
|
0f753038c4 | ||
|
|
fee4c262d2 | ||
|
|
a2d04ee7b4 | ||
|
|
194b889873 | ||
|
|
1e3b089eb6 | ||
|
|
07ce066d68 | ||
|
|
b653660a5c | ||
|
|
b04f7e7411 | ||
|
|
9ef8df569e | ||
|
|
d243ba36ce | ||
|
|
aee0025ca3 | ||
|
|
cb2e770584 | ||
|
|
5e3e11bbfa | ||
|
|
3bc27b9b64 | ||
|
|
127de5bb3d | ||
|
|
0aae54dfd9 | ||
|
|
066456ecdf | ||
|
|
e715531dd3 | ||
|
|
ba83509ff4 | ||
|
|
a23b3c7c25 |
@@ -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
|
||||
|
||||
2
.github/actions/setup-javascript/action.yml
vendored
2
.github/actions/setup-javascript/action.yml
vendored
@@ -16,7 +16,7 @@ runs:
|
||||
# The following is needed because we can not use `cache: true` for `setup-node`, as it does not support Corepack yet and mess up with the cache location if ran after Node is installed
|
||||
- name: Enable corepack
|
||||
shell: bash
|
||||
run: corepack enable
|
||||
run: npm i -g corepack
|
||||
|
||||
- name: Get yarn cache directory path
|
||||
id: yarn-cache-dir-path
|
||||
|
||||
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
|
||||
|
||||
6
.github/workflows/build-container-image.yml
vendored
6
.github/workflows/build-container-image.yml
vendored
@@ -76,7 +76,7 @@ jobs:
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
with:
|
||||
context: .
|
||||
file: ${{ inputs.file_to_build }}
|
||||
@@ -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@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # 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@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # 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@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # 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
|
||||
|
||||
15
Dockerfile
15
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"
|
||||
@@ -234,7 +234,7 @@ FROM media-build AS ffmpeg
|
||||
|
||||
# ffmpeg version to compile, change with [--build-arg FFMPEG_VERSION="7.0.x"]
|
||||
# renovate: datasource=github-tags depName=FFmpeg/FFmpeg extractVersion=^n(?<version>\d+\.\d+(\.\d+)?)$
|
||||
ARG FFMPEG_VERSION=8.1
|
||||
ARG FFMPEG_VERSION=8.1.1
|
||||
# ffmpeg download URL, change with [--build-arg FFMPEG_URL="https://ffmpeg.org/releases"]
|
||||
ARG FFMPEG_URL=https://github.com/FFmpeg/FFmpeg/archive/refs/tags
|
||||
|
||||
@@ -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*; \
|
||||
corepack enable; \
|
||||
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 \
|
||||
|
||||
5
Gemfile
5
Gemfile
@@ -58,6 +58,7 @@ gem 'httplog', '~> 1.8.0', require: false
|
||||
gem 'i18n'
|
||||
gem 'idn-ruby', require: 'idn'
|
||||
gem 'inline_svg'
|
||||
gem 'ipaddr', '~> 1.2'
|
||||
gem 'irb', '~> 1.8'
|
||||
gem 'kaminari', '~> 1.2'
|
||||
gem 'link_header', '~> 0.0'
|
||||
@@ -101,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
|
||||
|
||||
60
Gemfile.lock
60
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)
|
||||
@@ -343,6 +343,7 @@ GEM
|
||||
activesupport (>= 3.0)
|
||||
nokogiri (>= 1.6)
|
||||
io-console (0.8.2)
|
||||
ipaddr (1.2.9)
|
||||
irb (1.18.0)
|
||||
pp (>= 0.6.0)
|
||||
prism (>= 1.3.0)
|
||||
@@ -353,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)
|
||||
@@ -411,15 +412,13 @@ GEM
|
||||
rexml
|
||||
link_header (0.0.8)
|
||||
lint_roller (1.1.0)
|
||||
linzer (0.7.8)
|
||||
linzer (0.7.9)
|
||||
cgi (>= 0.4.2, < 0.6.0)
|
||||
forwardable (~> 1.3, >= 1.3.3)
|
||||
logger (~> 1.7, >= 1.7.0)
|
||||
net-http (>= 0.6, < 0.10)
|
||||
openssl (>= 3, < 5)
|
||||
rack (>= 2.2, < 4.0)
|
||||
starry (~> 0.2)
|
||||
stringio (~> 3.1, >= 3.1.2)
|
||||
uri (~> 1.0, >= 1.0.2)
|
||||
llhttp-ffi (0.5.1)
|
||||
ffi-compiler (~> 1.0)
|
||||
@@ -450,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)
|
||||
@@ -508,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)
|
||||
@@ -575,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)
|
||||
@@ -710,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)
|
||||
@@ -803,7 +802,7 @@ GEM
|
||||
ruby-vips (2.3.0)
|
||||
ffi (~> 1.12)
|
||||
logger
|
||||
rubyzip (3.2.2)
|
||||
rubyzip (3.3.0)
|
||||
rufus-scheduler (3.9.2)
|
||||
fugit (~> 1.1, >= 1.11.1)
|
||||
safety_net_attestation (0.5.0)
|
||||
@@ -817,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)
|
||||
@@ -852,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)
|
||||
@@ -994,6 +993,7 @@ DEPENDENCIES
|
||||
i18n-tasks (~> 1.0)
|
||||
idn-ruby
|
||||
inline_svg
|
||||
ipaddr (~> 1.2)
|
||||
irb (~> 1.8)
|
||||
jd-paperclip-azure (~> 3.0)
|
||||
json
|
||||
@@ -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.
|
||||
|
||||
4
Vagrantfile
vendored
4
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
|
||||
|
||||
@@ -115,7 +115,7 @@ gem install bundler foreman
|
||||
bundle install
|
||||
|
||||
# Install node modules
|
||||
sudo corepack enable
|
||||
sudo npm i -g corepack
|
||||
corepack prepare
|
||||
yarn install
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,7 +5,7 @@ module Admin
|
||||
def index
|
||||
authorize :email_domain_block, :index?
|
||||
|
||||
@email_domain_blocks = EmailDomainBlock.parents.includes(:children).order(id: :desc).page(params[:page])
|
||||
@email_domain_blocks = filter_by_domain.page(params[:page])
|
||||
@form = Form::EmailDomainBlockBatch.new
|
||||
end
|
||||
|
||||
@@ -57,6 +57,12 @@ module Admin
|
||||
|
||||
private
|
||||
|
||||
def filter_by_domain
|
||||
scope = EmailDomainBlock.parents.includes(:children).order(id: :desc)
|
||||
scope.merge!(EmailDomainBlock.matches_domain(params[:domain])) if params[:domain].present?
|
||||
scope
|
||||
end
|
||||
|
||||
def set_resolved_records
|
||||
@resolved_records = DomainResource.new(@email_domain_block.domain).mx
|
||||
end
|
||||
|
||||
@@ -6,7 +6,7 @@ class Admin::EmailSubscriptionsController < Admin::BaseController
|
||||
|
||||
@enabled = Setting.email_subscriptions
|
||||
@roles = UserRole.where('permissions & ? != 0', UserRole::FLAGS[:manage_email_subscriptions] | UserRole::FLAGS[:administrator])
|
||||
@accounts = Account.local.joins(:email_subscriptions).where.associated(:email_subscriptions).includes(:user)
|
||||
@accounts = Account.local.where.associated(:email_subscriptions).includes(:user)
|
||||
end
|
||||
|
||||
def disable
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
@@ -3,8 +3,6 @@ import type { FC } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { followAccount } from '@/mastodon/actions/accounts';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import { getAccountHidden } from '@/mastodon/selectors/accounts';
|
||||
@@ -18,6 +16,7 @@ import { FollowButton } from '../follow_button';
|
||||
import { IconButton } from '../icon_button';
|
||||
|
||||
import { AccountMenu } from './menu';
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
const messages = defineMessages({
|
||||
enableNotifications: {
|
||||
@@ -49,7 +48,7 @@ export const AccountButtons: FC<AccountButtonsProps> = ({
|
||||
const me = useAppSelector((state) => state.meta.get('me') as string);
|
||||
|
||||
return (
|
||||
<div className={classNames('account__header__buttons', className)}>
|
||||
<div className={className}>
|
||||
{!hidden && (
|
||||
<AccountButtonsOther accountId={accountId} noShare={noShare} />
|
||||
)}
|
||||
@@ -94,7 +93,7 @@ const AccountButtonsOther: FC<
|
||||
{!isMovedAndUnfollowedAccount && (
|
||||
<FollowButton
|
||||
accountId={accountId}
|
||||
className='account__header__follow-button'
|
||||
className={classes.followButton}
|
||||
labelLength='long'
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -108,11 +108,9 @@ const FieldCard: FC<{
|
||||
}> = ({ htmlHandlers, field }) => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
name,
|
||||
name_emojified,
|
||||
nameHasEmojis,
|
||||
value_emojified,
|
||||
value_plain,
|
||||
valueHasEmojis,
|
||||
verified_at,
|
||||
} = field;
|
||||
@@ -138,8 +136,7 @@ const FieldCard: FC<{
|
||||
)}
|
||||
label={
|
||||
<FieldHTML
|
||||
text={name}
|
||||
textEmojified={name_emojified}
|
||||
text={name_emojified}
|
||||
textHasCustomEmoji={nameHasEmojis}
|
||||
className='translate'
|
||||
isOverflowing={isLabelOverflowing}
|
||||
@@ -149,8 +146,7 @@ const FieldCard: FC<{
|
||||
}
|
||||
value={
|
||||
<FieldHTML
|
||||
text={value_plain}
|
||||
textEmojified={value_emojified}
|
||||
text={value_emojified}
|
||||
textHasCustomEmoji={valueHasEmojis}
|
||||
isOverflowing={isValueOverflowing}
|
||||
onOverflowClick={handleOverflowClick}
|
||||
@@ -175,7 +171,6 @@ const FieldCard: FC<{
|
||||
|
||||
type FieldHTMLProps = {
|
||||
text: string;
|
||||
textEmojified: string;
|
||||
textHasCustomEmoji: boolean;
|
||||
isOverflowing?: boolean;
|
||||
onOverflowClick?: () => void;
|
||||
@@ -183,9 +178,7 @@ type FieldHTMLProps = {
|
||||
|
||||
const FieldHTML: FC<FieldHTMLProps> = ({
|
||||
className,
|
||||
extraEmojis,
|
||||
text,
|
||||
textEmojified,
|
||||
textHasCustomEmoji,
|
||||
isOverflowing,
|
||||
onOverflowClick,
|
||||
@@ -198,7 +191,7 @@ const FieldHTML: FC<FieldHTMLProps> = ({
|
||||
const html = (
|
||||
<EmojiHTML
|
||||
as='span'
|
||||
htmlString={textEmojified}
|
||||
htmlString={text}
|
||||
className={className}
|
||||
onElement={handleElement}
|
||||
data-contents
|
||||
@@ -324,9 +317,10 @@ function useColumnWrap() {
|
||||
if (element) {
|
||||
listRef.current = element;
|
||||
observer.observe(element);
|
||||
handleRecalculate();
|
||||
}
|
||||
},
|
||||
[observer],
|
||||
[handleRecalculate, observer],
|
||||
);
|
||||
|
||||
return { wrapperRef: wrapperRefCallback };
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
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 {
|
||||
@@ -21,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';
|
||||
@@ -50,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(
|
||||
@@ -96,22 +92,13 @@ export const AccountHeader: React.FC<{
|
||||
const isMe = me && account.id === me;
|
||||
|
||||
return (
|
||||
<div className='account-timeline__header'>
|
||||
{!hidden && account.memorial && <MemorialNote />}
|
||||
{!hidden && account.moved && (
|
||||
<MovedNote accountId={account.id} targetAccountId={account.moved} />
|
||||
)}
|
||||
<div>
|
||||
<AccountBanners account={account} />
|
||||
|
||||
<AnimateEmojiProvider
|
||||
className={classNames('account__header', {
|
||||
inactive: !!account.moved,
|
||||
})}
|
||||
className={classNames(!!account.moved && classes.moved)}
|
||||
>
|
||||
{!suspendedOrHidden && !account.moved && relationship?.requested_by && (
|
||||
<FollowRequestNoteContainer account={account} />
|
||||
)}
|
||||
|
||||
<div className={classNames('account__header__image', classes.header)}>
|
||||
<div className={classes.header}>
|
||||
{!suspendedOrHidden && (
|
||||
<img
|
||||
src={autoPlayGif ? account.header : account.header_static}
|
||||
@@ -121,21 +108,16 @@ export const AccountHeader: React.FC<{
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={classNames('account__header__bar', classes.barWrapper)}>
|
||||
<div
|
||||
className={classNames(
|
||||
'account__header__tabs',
|
||||
classes.avatarWrapper,
|
||||
)}
|
||||
>
|
||||
<div className={classes.barWrapper}>
|
||||
<div className={classes.avatarWrapper}>
|
||||
<a
|
||||
className='avatar'
|
||||
href={account.avatar}
|
||||
rel='noopener'
|
||||
target='_blank'
|
||||
onClick={handleOpenAvatar}
|
||||
>
|
||||
<Avatar
|
||||
className={classes.avatar}
|
||||
account={suspendedOrHidden ? undefined : account}
|
||||
alt={account.avatar_description}
|
||||
size={80}
|
||||
@@ -143,12 +125,7 @@ export const AccountHeader: React.FC<{
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
'account__header__tabs__name',
|
||||
classes.displayNameWrapper,
|
||||
)}
|
||||
>
|
||||
<div className={classes.displayNameWrapper}>
|
||||
<AccountName accountId={accountId} />
|
||||
<AccountButtons
|
||||
accountId={accountId}
|
||||
@@ -168,23 +145,16 @@ export const AccountHeader: React.FC<{
|
||||
)}
|
||||
|
||||
{!suspendedOrHidden && (
|
||||
<div className='account__header__extra'>
|
||||
<div className='account__header__bio'>
|
||||
{me && account.id !== me && (
|
||||
<AccountNote accountId={accountId} />
|
||||
)}
|
||||
<div className={classes.bioButtonsWrapper}>
|
||||
{me && account.id !== me && <AccountNote accountId={accountId} />}
|
||||
|
||||
<AccountBio
|
||||
showDropdown
|
||||
accountId={accountId}
|
||||
className={classNames(
|
||||
'account__header__content',
|
||||
classes.bio,
|
||||
)}
|
||||
/>
|
||||
<AccountBio
|
||||
showDropdown
|
||||
accountId={accountId}
|
||||
className={classes.bio}
|
||||
/>
|
||||
|
||||
<AccountHeaderFields accountId={accountId} />
|
||||
</div>
|
||||
<AccountHeaderFields accountId={accountId} />
|
||||
|
||||
{!me && account.email_subscriptions && (
|
||||
<AccountSubscriptionForm accountId={accountId} />
|
||||
|
||||
@@ -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' />
|
||||
|
||||
@@ -1,30 +1,85 @@
|
||||
.moved {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
// Account header
|
||||
.header {
|
||||
height: 120px;
|
||||
overflow: hidden;
|
||||
background: var(--color-bg-secondary);
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
|
||||
img {
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@container (width >= 500px) {
|
||||
height: 160px;
|
||||
}
|
||||
|
||||
.moved & {
|
||||
filter: grayscale(100%);
|
||||
}
|
||||
}
|
||||
|
||||
// Wraps everything except the header image.
|
||||
.barWrapper {
|
||||
border-bottom: none;
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
// Avatar
|
||||
.avatarWrapper {
|
||||
margin-top: -64px;
|
||||
padding-top: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
margin-inline-start: -2px; // aligns the pfp with content below
|
||||
}
|
||||
|
||||
.avatar {
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--avatar-border-radius);
|
||||
|
||||
.moved & {
|
||||
filter: grayscale(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.displayNameWrapper {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h1 {
|
||||
font-size: 17px;
|
||||
line-height: 22px;
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
:global(.emojione) {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
.nameWrapper {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.name {
|
||||
@@ -112,21 +167,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
$button-breakpoint: 420px;
|
||||
$button-fallback-breakpoint: $button-breakpoint + 55px;
|
||||
|
||||
.buttonsDesktop {
|
||||
@container (width < #{$button-breakpoint}) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@supports (not (container-type: inline-size)) {
|
||||
@media (max-width: #{$button-fallback-breakpoint}) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.handleCopy {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: 8px;
|
||||
@@ -147,6 +187,45 @@ $button-fallback-breakpoint: $button-breakpoint + 55px;
|
||||
}
|
||||
}
|
||||
|
||||
$button-breakpoint: 420px;
|
||||
$button-fallback-breakpoint: $button-breakpoint + 55px;
|
||||
|
||||
.buttonsDesktop,
|
||||
.buttonsMobile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
:global(.button) {
|
||||
flex-shrink: 1;
|
||||
white-space: nowrap;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
:global(.icon-button) {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: 4px;
|
||||
box-sizing: content-box;
|
||||
padding: 5px;
|
||||
|
||||
&:global(.copied) {
|
||||
border-color: var(--color-text-success);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.buttonsDesktop {
|
||||
@container (width < #{$button-breakpoint}) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@supports (not (container-type: inline-size)) {
|
||||
@media (max-width: #{$button-fallback-breakpoint}) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.buttonsMobile {
|
||||
position: sticky;
|
||||
bottom: var(--mobile-bottom-nav-height);
|
||||
@@ -186,8 +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 {
|
||||
@@ -355,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;
|
||||
@@ -377,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;
|
||||
@@ -391,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;
|
||||
@@ -420,3 +554,39 @@ $button-fallback-breakpoint: $button-breakpoint + 55px;
|
||||
background: var(--color-bg-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.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 {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.bioButtonsWrapper {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -90,7 +90,7 @@ export const DropdownMenuItemContent: React.FC<{ item: MenuItem }> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const DropdownMenu = <Item = MenuItem>({
|
||||
export const DropdownMenu = <Item = MenuItem,>({
|
||||
items,
|
||||
loading,
|
||||
scrollable,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { PureComponent } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { Helmet } from '@unhead/react/helmet';
|
||||
|
||||
import StackTrace from 'stacktrace-js';
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -154,17 +154,19 @@ export const HoverCardAccount = forwardRef<
|
||||
{(isMutual || isFollower) && (
|
||||
<>
|
||||
·
|
||||
{isMutual ? (
|
||||
<FormattedMessage
|
||||
id='account.mutual'
|
||||
defaultMessage='You follow each other'
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='account.follows_you'
|
||||
defaultMessage='Follows you'
|
||||
/>
|
||||
)}
|
||||
<span>
|
||||
{isMutual ? (
|
||||
<FormattedMessage
|
||||
id='account.mutual'
|
||||
defaultMessage='You follow each other'
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='account.follows_you'
|
||||
defaultMessage='Follows you'
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { Helmet } from '@unhead/react/helmet';
|
||||
import { Route } from 'react-router-dom';
|
||||
|
||||
import { Provider as ReduxProvider } from 'react-redux';
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -3,7 +3,7 @@ import { PureComponent } from 'react';
|
||||
|
||||
import { defineMessages, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { Helmet } from '@unhead/react/helmet';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { connect } from 'react-redux';
|
||||
@@ -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);
|
||||
@@ -2,9 +2,10 @@ import type { FC } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Helmet } from '@unhead/react/helmet';
|
||||
|
||||
import { Column } from '@/mastodon/components/column';
|
||||
import { ColumnHeader } from '@/mastodon/components/column_header';
|
||||
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { Helmet } from '@unhead/react/helmet';
|
||||
|
||||
import BookmarksIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react';
|
||||
import {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -131,7 +131,7 @@ export const CollectionMenu: React.FC<{
|
||||
{
|
||||
text: intl.formatMessage(messages.copyLink),
|
||||
action: () => {
|
||||
void navigator.clipboard.writeText(getCollectionPath(id));
|
||||
void navigator.clipboard.writeText(collection.url);
|
||||
dispatch(showAlert({ message: messages.copyLinkConfirmation }));
|
||||
},
|
||||
},
|
||||
@@ -196,13 +196,14 @@ export const CollectionMenu: React.FC<{
|
||||
id,
|
||||
openShareModal,
|
||||
isOwnCollection,
|
||||
collection.url,
|
||||
dispatch,
|
||||
openDeleteConfirmation,
|
||||
context,
|
||||
currentAccountInCollection,
|
||||
openReportModal,
|
||||
openBlockModal,
|
||||
openRevokeConfirmation,
|
||||
openBlockModal,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -42,7 +44,7 @@ export const CollectionShareModal: React.FC<{
|
||||
const isNew = !!location.state?.newCollection;
|
||||
const isOwnCollection = collection.account_id === me;
|
||||
|
||||
const collectionLink = `${window.location.origin}/collections/${collection.id}`;
|
||||
const collectionLink = collection.url;
|
||||
|
||||
const handleShareOnDevice = useCallback(() => {
|
||||
void navigator.share({
|
||||
@@ -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));
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user