Compare commits
56 Commits
renovate/r
...
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 |
@@ -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-ruby/action.yml
vendored
2
.github/actions/setup-ruby/action.yml
vendored
@@ -23,7 +23,7 @@ runs:
|
||||
${{ 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
|
||||
|
||||
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
|
||||
|
||||
|
||||
6
.github/workflows/codeql.yml
vendored
6
.github/workflows/codeql.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4
|
||||
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@e46ed2cbd01164d986452f91f178727624ae40d7 # v4
|
||||
uses: github/codeql-action/autobuild@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
@@ -61,6 +61,6 @@ jobs:
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4
|
||||
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
|
||||
2
.github/workflows/crowdin-download.yml
vendored
2
.github/workflows/crowdin-download.yml
vendored
@@ -52,7 +52,7 @@ jobs:
|
||||
|
||||
# Create or update the pull request
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8
|
||||
with:
|
||||
commit-message: 'New Crowdin translations'
|
||||
title: 'New Crowdin Translations (automated)'
|
||||
|
||||
2
.github/workflows/lint-css.yml
vendored
2
.github/workflows/lint-css.yml
vendored
@@ -13,7 +13,6 @@ on:
|
||||
- '**/*.css'
|
||||
- '**/*.scss'
|
||||
- '.github/workflows/lint-css.yml'
|
||||
- '.github/stylelint-matcher.json'
|
||||
|
||||
pull_request:
|
||||
paths:
|
||||
@@ -24,7 +23,6 @@ on:
|
||||
- '**/*.css'
|
||||
- '**/*.scss'
|
||||
- '.github/workflows/lint-css.yml'
|
||||
- '.github/stylelint-matcher.json'
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
|
||||
4
.github/workflows/lint-haml.yml
vendored
4
.github/workflows/lint-haml.yml
vendored
@@ -6,7 +6,6 @@ on:
|
||||
- 'main'
|
||||
- 'stable-*'
|
||||
paths:
|
||||
- '.github/workflows/haml-lint-problem-matcher.json'
|
||||
- '.github/workflows/lint-haml.yml'
|
||||
- '.haml-lint*.yml'
|
||||
- '.rubocop*.yml'
|
||||
@@ -16,7 +15,6 @@ on:
|
||||
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/haml-lint-problem-matcher.json'
|
||||
- '.github/workflows/lint-haml.yml'
|
||||
- '.haml-lint*.yml'
|
||||
- '.rubocop*.yml'
|
||||
@@ -36,7 +34,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@94e4d89d3e6c1c7599e0210d114c5ffb23f1a866 # v1
|
||||
uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
|
||||
4
.github/workflows/lint-ruby.yml
vendored
4
.github/workflows/lint-ruby.yml
vendored
@@ -10,7 +10,6 @@ on:
|
||||
- '.rubocop*.yml'
|
||||
- '.ruby-version'
|
||||
- 'bin/rubocop'
|
||||
- 'config/brakeman.ignore'
|
||||
- '**/*.rb'
|
||||
- '**/*.rake'
|
||||
- '.github/workflows/lint-ruby.yml'
|
||||
@@ -21,7 +20,6 @@ on:
|
||||
- '.rubocop*.yml'
|
||||
- '.ruby-version'
|
||||
- 'bin/rubocop'
|
||||
- 'config/brakeman.ignore'
|
||||
- '**/*.rb'
|
||||
- '**/*.rake'
|
||||
- '.github/workflows/lint-ruby.yml'
|
||||
@@ -38,7 +36,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@94e4d89d3e6c1c7599e0210d114c5ffb23f1a866 # v1
|
||||
uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
4.0.3
|
||||
4.0.5
|
||||
|
||||
16
CHANGELOG.md
16
CHANGELOG.md
@@ -2,6 +2,22 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [4.5.10] - 2026-05-20
|
||||
|
||||
### Security
|
||||
|
||||
- Fix SSRF protection bypass ([GHSA-crr4-7rm4-8gpw](https://github.com/mastodon/mastodon/security/advisories/GHSA-crr4-7rm4-8gpw), [GHSA-xx55-4rrg-8xg6](https://github.com/mastodon/mastodon/security/advisories/GHSA-xx55-4rrg-8xg6))
|
||||
- Fix Linked-Data Signature bypass through JSON-LD graph restructuring features ([GHSA-53m7-2wrh-q839](https://github.com/mastodon/mastodon/security/advisories/GHSA-53m7-2wrh-q839), [GHSA-chgx-jx3p-rf73](https://github.com/mastodon/mastodon/security/advisories/GHSA-chgx-jx3p-rf73))
|
||||
- Updated dependencies
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix type of `interactingObject`, `interactionTarget` and add missing `QuoteAuthorization` (#38940 by @ClearlyClaire)
|
||||
|
||||
### Removed
|
||||
|
||||
- Remove unused devise strategies (#38795 by @ClearlyClaire)
|
||||
|
||||
## [4.5.9] - 2026-04-15
|
||||
|
||||
### Security
|
||||
|
||||
@@ -13,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"
|
||||
|
||||
4
Gemfile
4
Gemfile
@@ -102,10 +102,10 @@ gem 'rdf-normalize', '~> 0.5'
|
||||
|
||||
gem 'prometheus_exporter', '~> 2.2', require: false
|
||||
|
||||
gem 'opentelemetry-api', '~> 1.9.0'
|
||||
gem 'opentelemetry-api', '~> 1.10.0'
|
||||
|
||||
group :opentelemetry do
|
||||
gem 'opentelemetry-exporter-otlp', '~> 0.33.0', require: false
|
||||
gem 'opentelemetry-exporter-otlp', '~> 0.34.0', require: false
|
||||
gem 'opentelemetry-instrumentation-active_job', '~> 0.12.0', require: false
|
||||
gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.25.0', require: false
|
||||
gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.25.0', require: false
|
||||
|
||||
32
Gemfile.lock
32
Gemfile.lock
@@ -99,7 +99,7 @@ GEM
|
||||
ast (2.4.3)
|
||||
attr_required (1.0.2)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1247.0)
|
||||
aws-partitions (1.1249.0)
|
||||
aws-sdk-core (3.247.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
@@ -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.221.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
|
||||
@@ -507,11 +507,11 @@ GEM
|
||||
openssl (4.0.1)
|
||||
openssl-signature_algorithm (1.3.0)
|
||||
openssl (> 2.0)
|
||||
opentelemetry-api (1.9.0)
|
||||
opentelemetry-api (1.10.0)
|
||||
logger
|
||||
opentelemetry-common (0.24.0)
|
||||
opentelemetry-common (0.25.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-exporter-otlp (0.33.0)
|
||||
opentelemetry-exporter-otlp (0.34.0)
|
||||
google-protobuf (>= 3.18)
|
||||
googleapis-common-protos-types (~> 1.3)
|
||||
opentelemetry-api (~> 1.1)
|
||||
@@ -574,19 +574,19 @@ GEM
|
||||
opentelemetry-instrumentation-base (~> 0.25)
|
||||
opentelemetry-instrumentation-sidekiq (0.29.0)
|
||||
opentelemetry-instrumentation-base (~> 0.25)
|
||||
opentelemetry-registry (0.5.0)
|
||||
opentelemetry-registry (0.6.0)
|
||||
opentelemetry-api (~> 1.1)
|
||||
opentelemetry-sdk (1.11.0)
|
||||
opentelemetry-sdk (1.12.0)
|
||||
logger
|
||||
opentelemetry-api (~> 1.1)
|
||||
opentelemetry-common (~> 0.20)
|
||||
opentelemetry-registry (~> 0.2)
|
||||
opentelemetry-semantic_conventions
|
||||
opentelemetry-semantic_conventions (1.37.0)
|
||||
opentelemetry-semantic_conventions (1.37.1)
|
||||
opentelemetry-api (~> 1.0)
|
||||
orm_adapter (0.5.0)
|
||||
ostruct (0.6.3)
|
||||
ox (2.14.25)
|
||||
ox (2.14.26)
|
||||
bigdecimal (>= 3.0)
|
||||
parallel (1.28.0)
|
||||
parser (3.3.11.1)
|
||||
@@ -1020,8 +1020,8 @@ DEPENDENCIES
|
||||
omniauth-rails_csrf_protection (~> 2.0)
|
||||
omniauth-saml (~> 2.0)
|
||||
omniauth_openid_connect (~> 0.8.0)
|
||||
opentelemetry-api (~> 1.9.0)
|
||||
opentelemetry-exporter-otlp (~> 0.33.0)
|
||||
opentelemetry-api (~> 1.10.0)
|
||||
opentelemetry-exporter-otlp (~> 0.34.0)
|
||||
opentelemetry-instrumentation-active_job (~> 0.12.0)
|
||||
opentelemetry-instrumentation-active_model_serializers (~> 0.25.0)
|
||||
opentelemetry-instrumentation-concurrent_ruby (~> 0.25.0)
|
||||
@@ -1097,7 +1097,7 @@ DEPENDENCIES
|
||||
xorcist (~> 1.1)
|
||||
|
||||
RUBY VERSION
|
||||
ruby 4.0.3
|
||||
ruby 4.0.5
|
||||
|
||||
BUNDLED WITH
|
||||
4.0.11
|
||||
|
||||
@@ -59,7 +59,7 @@ Mastodon is a **free, open-source social network server** based on [ActivityPub]
|
||||
- **Ruby** 3.3+
|
||||
- **PostgreSQL** 14+
|
||||
- **Redis** 7.0+
|
||||
- **Node.js** 20+
|
||||
- **Node.js** 22+
|
||||
- **FFmpeg** 5.1+
|
||||
|
||||
This repository includes deployment configurations for **Docker and docker-compose**, as well as for other environments like Heroku and Scalingo. For Helm charts, reference the [mastodon/chart repository](https://github.com/mastodon/chart). A [**standalone** installation guide](https://docs.joinmastodon.org/admin/install/) is available in the main documentation.
|
||||
|
||||
2
Vagrantfile
vendored
2
Vagrantfile
vendored
@@ -12,7 +12,7 @@ sudo apt-add-repository 'deb https://dl.yarnpkg.com/debian/ stable main'
|
||||
# Add repo for NodeJS
|
||||
sudo mkdir -p /etc/apt/keyrings
|
||||
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
|
||||
NODE_MAJOR=20
|
||||
NODE_MAJOR=24
|
||||
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | sudo tee /etc/apt/sources.list.d/nodesource.list
|
||||
sudo apt-get update
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ module Admin
|
||||
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: 'report'))
|
||||
@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
|
||||
@@ -57,5 +57,13 @@ module Admin
|
||||
def set_collections
|
||||
@collections = @account.collections.includes(accepted_collection_items: :account).page(params[:page]).per(PER_PAGE)
|
||||
end
|
||||
|
||||
def action_from_button
|
||||
if params[:report]
|
||||
'report'
|
||||
elsif params[:remove_from_report]
|
||||
'remove_from_report'
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -16,6 +16,8 @@ module Admin
|
||||
@report_notes = @report.notes.chronological.includes(:account)
|
||||
@action_logs = @report.history.includes(:target)
|
||||
@form = Admin::StatusBatchAction.new
|
||||
@collection_form = Admin::CollectionBatchAction.new
|
||||
@collections = @report.collections
|
||||
@statuses = @report.statuses.with_includes
|
||||
end
|
||||
|
||||
|
||||
67
app/controllers/api/v1/statuses/contexts_controller.rb
Normal file
67
app/controllers/api/v1/statuses/contexts_controller.rb
Normal file
@@ -0,0 +1,67 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Statuses::ContextsController < Api::BaseController
|
||||
include Authorization
|
||||
include AsyncRefreshesConcern
|
||||
|
||||
before_action -> { authorize_if_got_token! :read, :'read:statuses' }
|
||||
before_action :set_status
|
||||
|
||||
# This API was originally unlimited, pagination cannot be introduced without
|
||||
# breaking backwards-compatibility. Arbitrarily high number to cover most
|
||||
# conversations as quasi-unlimited, it would be too much work to render more
|
||||
# than this anyway
|
||||
CONTEXT_LIMIT = 4_096
|
||||
|
||||
# This remains expensive and we don't want to show everything to logged-out users
|
||||
ANCESTORS_LIMIT = 40
|
||||
DESCENDANTS_LIMIT = 60
|
||||
DESCENDANTS_DEPTH_LIMIT = 20
|
||||
|
||||
def show
|
||||
cache_if_unauthenticated!
|
||||
|
||||
ancestors_limit = CONTEXT_LIMIT
|
||||
descendants_limit = CONTEXT_LIMIT
|
||||
descendants_depth_limit = nil
|
||||
|
||||
if current_account.nil?
|
||||
ancestors_limit = ANCESTORS_LIMIT
|
||||
descendants_limit = DESCENDANTS_LIMIT
|
||||
descendants_depth_limit = DESCENDANTS_DEPTH_LIMIT
|
||||
end
|
||||
|
||||
ancestors_results = @status.in_reply_to_id.nil? ? [] : @status.ancestors(ancestors_limit, current_account)
|
||||
descendants_results = @status.descendants(descendants_limit, current_account, descendants_depth_limit)
|
||||
loaded_ancestors = preload_collection(ancestors_results, Status)
|
||||
loaded_descendants = preload_collection(descendants_results, Status)
|
||||
|
||||
@context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants)
|
||||
statuses = [@status] + @context.ancestors + @context.descendants
|
||||
|
||||
refresh_key = "context:#{@status.id}:refresh"
|
||||
async_refresh = AsyncRefresh.new(refresh_key)
|
||||
|
||||
if async_refresh.running?
|
||||
add_async_refresh_header(async_refresh)
|
||||
elsif !current_account.nil? && @status.should_fetch_replies?
|
||||
add_async_refresh_header(AsyncRefresh.create(refresh_key, count_results: true))
|
||||
|
||||
WorkerBatch.new.within do |batch|
|
||||
batch.connect(refresh_key, threshold: 1.0)
|
||||
ActivityPub::FetchAllRepliesWorker.perform_async(@status.id, { 'batch_id' => batch.id })
|
||||
end
|
||||
end
|
||||
|
||||
render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_status
|
||||
@status = Status.find(params[:status_id])
|
||||
authorize @status, :show?
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
end
|
||||
@@ -2,14 +2,13 @@
|
||||
|
||||
class Api::V1::StatusesController < Api::BaseController
|
||||
include Authorization
|
||||
include AsyncRefreshesConcern
|
||||
include Api::InteractionPoliciesConcern
|
||||
|
||||
before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [:create, :update, :destroy]
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:create, :update, :destroy]
|
||||
before_action :require_user!, except: [:index, :show, :context]
|
||||
before_action :require_user!, except: [:index, :show]
|
||||
before_action :set_statuses, only: [:index]
|
||||
before_action :set_status, only: [:show, :context]
|
||||
before_action :set_status, only: [:show]
|
||||
before_action :set_thread, only: [:create]
|
||||
before_action :set_quoted_status, only: [:create]
|
||||
before_action :check_statuses_limit, only: [:index]
|
||||
@@ -17,17 +16,6 @@ class Api::V1::StatusesController < Api::BaseController
|
||||
override_rate_limit_headers :create, family: :statuses
|
||||
override_rate_limit_headers :update, family: :statuses
|
||||
|
||||
# This API was originally unlimited, pagination cannot be introduced without
|
||||
# breaking backwards-compatibility. Arbitrarily high number to cover most
|
||||
# conversations as quasi-unlimited, it would be too much work to render more
|
||||
# than this anyway
|
||||
CONTEXT_LIMIT = 4_096
|
||||
|
||||
# This remains expensive and we don't want to show everything to logged-out users
|
||||
ANCESTORS_LIMIT = 40
|
||||
DESCENDANTS_LIMIT = 60
|
||||
DESCENDANTS_DEPTH_LIMIT = 20
|
||||
|
||||
def index
|
||||
@statuses = preload_collection(@statuses, Status)
|
||||
render json: @statuses, each_serializer: REST::StatusSerializer
|
||||
@@ -39,44 +27,6 @@ class Api::V1::StatusesController < Api::BaseController
|
||||
render json: @status, serializer: REST::StatusSerializer
|
||||
end
|
||||
|
||||
def context
|
||||
cache_if_unauthenticated!
|
||||
|
||||
ancestors_limit = CONTEXT_LIMIT
|
||||
descendants_limit = CONTEXT_LIMIT
|
||||
descendants_depth_limit = nil
|
||||
|
||||
if current_account.nil?
|
||||
ancestors_limit = ANCESTORS_LIMIT
|
||||
descendants_limit = DESCENDANTS_LIMIT
|
||||
descendants_depth_limit = DESCENDANTS_DEPTH_LIMIT
|
||||
end
|
||||
|
||||
ancestors_results = @status.in_reply_to_id.nil? ? [] : @status.ancestors(ancestors_limit, current_account)
|
||||
descendants_results = @status.descendants(descendants_limit, current_account, descendants_depth_limit)
|
||||
loaded_ancestors = preload_collection(ancestors_results, Status)
|
||||
loaded_descendants = preload_collection(descendants_results, Status)
|
||||
|
||||
@context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants)
|
||||
statuses = [@status] + @context.ancestors + @context.descendants
|
||||
|
||||
refresh_key = "context:#{@status.id}:refresh"
|
||||
async_refresh = AsyncRefresh.new(refresh_key)
|
||||
|
||||
if async_refresh.running?
|
||||
add_async_refresh_header(async_refresh)
|
||||
elsif !current_account.nil? && @status.should_fetch_replies?
|
||||
add_async_refresh_header(AsyncRefresh.create(refresh_key, count_results: true))
|
||||
|
||||
WorkerBatch.new.within do |batch|
|
||||
batch.connect(refresh_key, threshold: 1.0)
|
||||
ActivityPub::FetchAllRepliesWorker.perform_async(@status.id, { 'batch_id' => batch.id })
|
||||
end
|
||||
end
|
||||
|
||||
render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id)
|
||||
end
|
||||
|
||||
def create
|
||||
@status = PostStatusService.new.call(
|
||||
current_user.account,
|
||||
|
||||
@@ -28,10 +28,9 @@ class Api::V1Alpha::CollectionsController < Api::BaseController
|
||||
cache_if_unauthenticated!
|
||||
authorize @account, :index_collections?
|
||||
|
||||
presenter = CollectionsPresenter.new(collections: @collections)
|
||||
render json: presenter, serializer: REST::CollectionsWithAccountPreviewsSerializer
|
||||
render json: @collections, each_serializer: REST::CollectionSerializer, adapter: :json
|
||||
rescue Mastodon::NotPermittedError
|
||||
render json: { collections: [], partial_accounts: [] }
|
||||
render json: { collections: [] }
|
||||
end
|
||||
|
||||
def show
|
||||
@@ -74,7 +73,6 @@ class Api::V1Alpha::CollectionsController < Api::BaseController
|
||||
def set_collections
|
||||
@collections = @account.collections
|
||||
.with_tag
|
||||
.preload(top_items: :account)
|
||||
.order(created_at: :desc)
|
||||
.offset(offset_param)
|
||||
.limit(limit_param(DEFAULT_COLLECTIONS_LIMIT))
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
class CustomCssController < ActionController::Base # rubocop:disable Rails/ApplicationController
|
||||
def show
|
||||
expires_in 1.month, public: true
|
||||
render content_type: 'text/css'
|
||||
render content_type: :css
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -7,7 +7,7 @@ import api from 'mastodon/api';
|
||||
import { browserHistory } from 'mastodon/components/router';
|
||||
import { countableText } from 'mastodon/features/compose/util/counter';
|
||||
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';
|
||||
@@ -99,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,
|
||||
@@ -591,9 +591,9 @@ const fetchComposeSuggestionsAccounts = throttle((dispatch, token) => {
|
||||
}, 200, { leading: true, trailing: true });
|
||||
|
||||
const fetchComposeSuggestionsEmojis = async (dispatch, token) => {
|
||||
const custom = await fetchCustomEmojiData();
|
||||
const { search } = await import('@/mastodon/features/emoji/emoji_mart_search_light');
|
||||
const results = search(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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -317,9 +317,10 @@ function useColumnWrap() {
|
||||
if (element) {
|
||||
listRef.current = element;
|
||||
observer.observe(element);
|
||||
handleRecalculate();
|
||||
}
|
||||
},
|
||||
[observer],
|
||||
[handleRecalculate, observer],
|
||||
);
|
||||
|
||||
return { wrapperRef: wrapperRefCallback };
|
||||
|
||||
@@ -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,22 +1,20 @@
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { useCustomEmojis } from '@/mastodon/hooks/useCustomEmojis';
|
||||
|
||||
import { Emoji } from './emoji';
|
||||
|
||||
interface LegacyEmoji {
|
||||
colons: string;
|
||||
id: string;
|
||||
custom?: boolean;
|
||||
native?: string;
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
export const AutosuggestEmoji: FC<{ emoji: LegacyEmoji }> = ({ emoji }) => {
|
||||
const emojis = useCustomEmojis();
|
||||
const colons = `:${emoji.id}:`;
|
||||
return (
|
||||
<div className='autosuggest-emoji'>
|
||||
<Emoji code={emoji.native ?? emoji.colons} customEmoji={emojis} />
|
||||
<div className='autosuggest-emoji__name'>{emoji.colons}</div>
|
||||
<Emoji code={emoji.native ?? colons} />
|
||||
<div className='autosuggest-emoji__name'>{colons}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ import AutosuggestAccountContainer from '../features/compose/containers/autosugg
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import AutosuggestAccountContainer from '../features/compose/containers/autosugg
|
||||
|
||||
import { AutosuggestEmoji } from './autosuggest_emoji';
|
||||
import { AutosuggestHashtag } from './autosuggest_hashtag';
|
||||
import { LocalCustomEmojiProvider } from './emoji/context';
|
||||
|
||||
const textAtCursorMatchesToken = (str, caretPosition) => {
|
||||
let word;
|
||||
@@ -218,15 +219,17 @@ const AutosuggestTextarea = forwardRef(({
|
||||
lang={lang}
|
||||
/>
|
||||
|
||||
<Overlay show={!(suggestionsHidden || suggestions.isEmpty())} offset={[0, 0]} placement='bottom' target={textareaRef} popperConfig={{ strategy: 'fixed' }}>
|
||||
{({ props }) => (
|
||||
<div {...props}>
|
||||
<div className='autosuggest-textarea__suggestions' style={{ width: textareaRef.current?.clientWidth }}>
|
||||
{suggestions.map(renderSuggestion)}
|
||||
<LocalCustomEmojiProvider>
|
||||
<Overlay show={!(suggestionsHidden || suggestions.isEmpty())} offset={[0, 0]} placement='bottom' target={textareaRef} popperConfig={{ strategy: 'fixed' }}>
|
||||
{({ props }) => (
|
||||
<div {...props}>
|
||||
<div className='autosuggest-textarea__suggestions' style={{ width: textareaRef.current?.clientWidth }}>
|
||||
{suggestions.map(renderSuggestion)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Overlay>
|
||||
)}
|
||||
</Overlay>
|
||||
</LocalCustomEmojiProvider>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -74,6 +74,24 @@ export const Compact: Story = {
|
||||
play: buttonTest,
|
||||
};
|
||||
|
||||
export const CompactSecondary: Story = {
|
||||
args: {
|
||||
compact: true,
|
||||
secondary: true,
|
||||
children: 'Compact secondary button',
|
||||
},
|
||||
play: buttonTest,
|
||||
};
|
||||
|
||||
export const CompactPlain: Story = {
|
||||
args: {
|
||||
compact: true,
|
||||
plain: true,
|
||||
children: 'Compact plain button',
|
||||
},
|
||||
play: buttonTest,
|
||||
};
|
||||
|
||||
export const Dangerous: Story = {
|
||||
args: {
|
||||
dangerous: true,
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { MouseEventHandler, PropsWithChildren } from 'react';
|
||||
import type {
|
||||
FC,
|
||||
MouseEventHandler,
|
||||
PropsWithChildren,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
@@ -8,6 +13,7 @@ import {
|
||||
} from 'react';
|
||||
|
||||
import { cleanExtraEmojis } from '@/mastodon/features/emoji/normalize';
|
||||
import { useCustomEmojis } from '@/mastodon/hooks/useCustomEmojis';
|
||||
import { autoPlayGif } from '@/mastodon/initial_state';
|
||||
import { polymorphicForwardRef } from '@/types/polymorphic';
|
||||
import type {
|
||||
@@ -103,3 +109,10 @@ export const CustomEmojiProvider = ({
|
||||
</CustomEmojiContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const LocalCustomEmojiProvider: FC<{ children: ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const emojis = useCustomEmojis();
|
||||
return <CustomEmojiProvider emojis={emojis}>{children}</CustomEmojiProvider>;
|
||||
};
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
stringToEmojiState,
|
||||
tokenizeText,
|
||||
} from '@/mastodon/features/emoji/render';
|
||||
import type { ExtraCustomEmojiMap } from '@/mastodon/features/emoji/types';
|
||||
|
||||
import { AnimateEmojiContext, CustomEmojiContext } from './context';
|
||||
|
||||
@@ -27,19 +26,14 @@ interface EmojiProps {
|
||||
code: string;
|
||||
showFallback?: boolean;
|
||||
showLoading?: boolean;
|
||||
customEmoji?: ExtraCustomEmojiMap | null;
|
||||
}
|
||||
|
||||
export const Emoji: FC<EmojiProps> = ({
|
||||
code,
|
||||
showFallback = true,
|
||||
showLoading = true,
|
||||
customEmoji: customEmojiOverride,
|
||||
}) => {
|
||||
let customEmoji = useContext(CustomEmojiContext);
|
||||
if (customEmojiOverride) {
|
||||
customEmoji = customEmojiOverride;
|
||||
}
|
||||
const customEmoji = useContext(CustomEmojiContext);
|
||||
|
||||
// First, set the emoji state based on the input code.
|
||||
const [state, setState] = useState(() =>
|
||||
|
||||
@@ -35,6 +35,10 @@
|
||||
color: var(--color-text-secondary);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-text-status-links);
|
||||
}
|
||||
}
|
||||
|
||||
[data-color-scheme='dark'] .defaultImage {
|
||||
|
||||
@@ -48,10 +48,14 @@
|
||||
|
||||
.status {
|
||||
// If there's no content, we need to compensate for the parent's
|
||||
// flex gap to avoid extra spacing below the field.
|
||||
// flex gap to avoid extra spacing below or next to the field.
|
||||
&:empty {
|
||||
margin-top: calc(-1 * var(--form-field-label-gap));
|
||||
}
|
||||
|
||||
[data-input-placement^='inline'] &:empty {
|
||||
margin-inline-start: calc(-1 * var(--form-field-label-gap));
|
||||
}
|
||||
}
|
||||
|
||||
.inputWrapper {
|
||||
|
||||
@@ -24,7 +24,7 @@ export interface FieldStatus {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface FieldWrapperProps {
|
||||
export interface FieldWrapperProps {
|
||||
label: ReactNode;
|
||||
hint?: ReactNode;
|
||||
required?: boolean;
|
||||
|
||||
@@ -4,11 +4,17 @@ import { forwardRef } from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { FormFieldWrapper } from './form_field_wrapper';
|
||||
import type { CommonFieldWrapperProps } from './form_field_wrapper';
|
||||
import type {
|
||||
CommonFieldWrapperProps,
|
||||
FieldWrapperProps,
|
||||
} from './form_field_wrapper';
|
||||
import classes from './select.module.scss';
|
||||
|
||||
interface Props
|
||||
extends ComponentPropsWithoutRef<'select'>, CommonFieldWrapperProps {}
|
||||
extends
|
||||
ComponentPropsWithoutRef<'select'>,
|
||||
CommonFieldWrapperProps,
|
||||
Pick<FieldWrapperProps, 'inputPlacement'> {}
|
||||
|
||||
/**
|
||||
* A simple form field for single-item selections.
|
||||
@@ -19,13 +25,28 @@ interface Props
|
||||
*/
|
||||
|
||||
export const SelectField = forwardRef<HTMLSelectElement, Props>(
|
||||
({ id, label, hint, required, status, children, ...otherProps }, ref) => (
|
||||
(
|
||||
{
|
||||
id,
|
||||
label,
|
||||
hint,
|
||||
required,
|
||||
status,
|
||||
inputPlacement,
|
||||
children,
|
||||
wrapperClassName,
|
||||
...otherProps
|
||||
},
|
||||
ref,
|
||||
) => (
|
||||
<FormFieldWrapper
|
||||
label={label}
|
||||
hint={hint}
|
||||
required={required}
|
||||
status={status}
|
||||
inputId={id}
|
||||
inputPlacement={inputPlacement}
|
||||
className={wrapperClassName}
|
||||
>
|
||||
{(inputProps) => (
|
||||
<Select {...otherProps} {...inputProps} ref={ref}>
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -566,7 +566,7 @@ class Status extends ImmutablePureComponent {
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else if (status.get('tagged_collections').size) {
|
||||
} else if (status.get('tagged_collections').size && !status.get('quote')) {
|
||||
const firstLinkedCollection = status.get('tagged_collections').first();
|
||||
if (firstLinkedCollection) {
|
||||
media = (
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { ComponentProps, FC } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import type { ApiCollectionJSON } from '@/mastodon/api_types/collections';
|
||||
import type { ApiMentionJSON } from '@/mastodon/api_types/statuses';
|
||||
import { getCollectionPath } from '@/mastodon/features/collections/utils';
|
||||
import type { OnElementHandler } from '@/mastodon/utils/html';
|
||||
@@ -15,7 +14,7 @@ export interface HandledLinkProps {
|
||||
prevText?: string;
|
||||
hashtagAccountId?: string;
|
||||
mention?: Pick<ApiMentionJSON, 'id' | 'acct'>;
|
||||
collection?: Pick<ApiCollectionJSON, 'id'>;
|
||||
collectionId?: string;
|
||||
}
|
||||
|
||||
export const HandledLink: FC<HandledLinkProps & ComponentProps<'a'>> = ({
|
||||
@@ -24,7 +23,7 @@ export const HandledLink: FC<HandledLinkProps & ComponentProps<'a'>> = ({
|
||||
prevText,
|
||||
hashtagAccountId,
|
||||
mention,
|
||||
collection,
|
||||
collectionId,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
@@ -61,11 +60,11 @@ export const HandledLink: FC<HandledLinkProps & ComponentProps<'a'>> = ({
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
} else if (collection) {
|
||||
} else if (collectionId) {
|
||||
return (
|
||||
<Link
|
||||
className={classNames(className)}
|
||||
to={getCollectionPath(collection.id)}
|
||||
to={getCollectionPath(collectionId)}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
@@ -98,15 +97,18 @@ export const HandledLink: FC<HandledLinkProps & ComponentProps<'a'>> = ({
|
||||
|
||||
export const useElementHandledLink = ({
|
||||
hashtagAccountId,
|
||||
hrefToCollectionId: hrefToCollection,
|
||||
hrefToMention,
|
||||
}: {
|
||||
hashtagAccountId?: string;
|
||||
hrefToCollectionId?: (href: string) => string | undefined;
|
||||
hrefToMention?: (href: string) => ApiMentionJSON | undefined;
|
||||
} = {}) => {
|
||||
const onElement = useCallback<OnElementHandler>(
|
||||
(element, { key, ...props }, children) => {
|
||||
if (element instanceof HTMLAnchorElement) {
|
||||
const mention = hrefToMention?.(element.href);
|
||||
const collectionId = hrefToCollection?.(element.href);
|
||||
return (
|
||||
<HandledLink
|
||||
{...props}
|
||||
@@ -116,6 +118,7 @@ export const useElementHandledLink = ({
|
||||
prevText={element.previousSibling?.textContent ?? undefined}
|
||||
hashtagAccountId={hashtagAccountId}
|
||||
mention={mention}
|
||||
collectionId={collectionId}
|
||||
>
|
||||
{children}
|
||||
</HandledLink>
|
||||
@@ -123,7 +126,7 @@ export const useElementHandledLink = ({
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
[hashtagAccountId, hrefToMention],
|
||||
[hashtagAccountId, hrefToCollection, hrefToMention],
|
||||
);
|
||||
return { onElement };
|
||||
};
|
||||
|
||||
@@ -69,7 +69,7 @@ class TranslateButton extends PureComponent {
|
||||
}
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
languages: state.getIn(['server', 'translationLanguages', 'items']),
|
||||
languages: state.server.translationLanguages.items,
|
||||
});
|
||||
|
||||
class StatusContent extends PureComponent {
|
||||
@@ -170,7 +170,7 @@ class StatusContent extends PureComponent {
|
||||
text={element.innerText}
|
||||
hashtagAccountId={this.props.status.getIn(['account', 'id'])}
|
||||
mention={mention?.toJSON()}
|
||||
collection={taggedCollection?.toJSON()}
|
||||
collectionId={taggedCollection?.get('id')}
|
||||
key={key}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { IntlShape } from 'react-intl';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import type { List as ImmutableList } from 'immutable';
|
||||
|
||||
import type { SelectItem } from '@/mastodon/components/dropdown_selector';
|
||||
import { Select } from '@/mastodon/components/form_fields';
|
||||
@@ -123,14 +122,13 @@ export const RulesSection: FC<RulesSectionProps> = ({ isLoading = false }) => {
|
||||
};
|
||||
|
||||
const selectRules = (state: RootState) => {
|
||||
const rules = state.server.getIn([
|
||||
'server',
|
||||
'rules',
|
||||
]) as ImmutableList<Rule> | null;
|
||||
const rules = state.server.server.item?.rules;
|
||||
|
||||
if (!rules) {
|
||||
return [];
|
||||
}
|
||||
return rules.toJS() as Rule[];
|
||||
|
||||
return rules;
|
||||
};
|
||||
|
||||
const rulesSelector = createSelector(
|
||||
|
||||
@@ -41,10 +41,10 @@ const severityMessages = {
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
server: state.getIn(['server', 'server']),
|
||||
server: state.server.server,
|
||||
locale: state.getIn(['meta', 'locale']),
|
||||
extendedDescription: state.getIn(['server', 'extendedDescription']),
|
||||
domainBlocks: state.getIn(['server', 'domainBlocks']),
|
||||
extendedDescription: state.server.extendedDescription,
|
||||
domainBlocks: state.server.domainBlocks,
|
||||
});
|
||||
|
||||
class About extends PureComponent {
|
||||
@@ -76,7 +76,7 @@ class About extends PureComponent {
|
||||
|
||||
render () {
|
||||
const { multiColumn, intl, server, extendedDescription, domainBlocks, locale } = this.props;
|
||||
const isLoading = server.get('isLoading');
|
||||
const isLoading = server.isLoading;
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn} label={intl.formatMessage(messages.title)}>
|
||||
@@ -84,13 +84,13 @@ class About extends PureComponent {
|
||||
<div className='about__header'>
|
||||
<ServerHeroImage
|
||||
withAltBadge
|
||||
alt={server.getIn(['thumbnail', 'description']) ?? ''}
|
||||
blurhash={server.getIn(['thumbnail', 'blurhash'])}
|
||||
src={server.getIn(['thumbnail', 'url'])}
|
||||
srcSet={server.getIn(['thumbnail', 'versions'])?.map((value, key) => `${value} ${key.replace('@', '')}`).join(', ')}
|
||||
alt={server.item?.thumbnail.description ?? ''}
|
||||
blurhash={server.item?.thumbnail.blurhash}
|
||||
src={server.item?.thumbnail.url}
|
||||
srcSet={Object.keys(server.item?.thumbnail.versions ?? {}).map((key) => `${server.item?.thumbnail.versions && server.item.thumbnail.versions[key]} ${key.replace('@', '')}`).join(', ')}
|
||||
className='about__header__hero'
|
||||
/>
|
||||
<h1>{isLoading ? <Skeleton width='10ch' /> : server.get('domain')}</h1>
|
||||
<h1>{isLoading ? <Skeleton width='10ch' /> : server.domain}</h1>
|
||||
<p><FormattedMessage id='about.powered_by' defaultMessage='Decentralized social media powered by {mastodon}' values={{ mastodon: <a href='https://joinmastodon.org' className='about__mail' target='_blank' rel='noopener'>Mastodon</a> }} /></p>
|
||||
</div>
|
||||
|
||||
@@ -98,7 +98,7 @@ class About extends PureComponent {
|
||||
<div className='about__meta__column'>
|
||||
<h4><FormattedMessage id='server_banner.administered_by' defaultMessage='Administered by:' /></h4>
|
||||
|
||||
<Account id={server.getIn(['contact', 'account', 'id'])} size={36} minimal />
|
||||
<Account id={server.item?.contact?.account?.id} size={36} minimal />
|
||||
</div>
|
||||
|
||||
<hr className='about__meta__divider' />
|
||||
@@ -106,12 +106,12 @@ class About extends PureComponent {
|
||||
<div className='about__meta__column'>
|
||||
<h4><FormattedMessage id='about.contact' defaultMessage='Contact:' /></h4>
|
||||
|
||||
{isLoading ? <Skeleton width='10ch' /> : <a className='about__mail' href={`mailto:${server.getIn(['contact', 'email'])}`}>{server.getIn(['contact', 'email'])}</a>}
|
||||
{isLoading ? <Skeleton width='10ch' /> : <a className='about__mail' href={`mailto:${server.item?.contact?.email}`}>{server.item?.contact?.email}</a>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Section open title={intl.formatMessage(messages.title)}>
|
||||
{extendedDescription.get('isLoading') ? (
|
||||
{extendedDescription.isLoading ? (
|
||||
<>
|
||||
<Skeleton width='100%' />
|
||||
<br />
|
||||
@@ -121,10 +121,10 @@ class About extends PureComponent {
|
||||
<br />
|
||||
<Skeleton width='70%' />
|
||||
</>
|
||||
) : (extendedDescription.get('content')?.length > 0 ? (
|
||||
) : (extendedDescription.item?.content?.length > 0 ? (
|
||||
<div
|
||||
className='prose'
|
||||
dangerouslySetInnerHTML={{ __html: extendedDescription.get('content') }}
|
||||
dangerouslySetInnerHTML={{ __html: extendedDescription.item?.content }}
|
||||
/>
|
||||
) : (
|
||||
<p><FormattedMessage id='about.not_available' defaultMessage='This information has not been made available on this server.' /></p>
|
||||
@@ -134,26 +134,26 @@ class About extends PureComponent {
|
||||
<RulesSection />
|
||||
|
||||
<Section title={intl.formatMessage(messages.blocks)} onOpen={this.handleDomainBlocksOpen}>
|
||||
{domainBlocks.get('isLoading') ? (
|
||||
{domainBlocks.isLoading ? (
|
||||
<>
|
||||
<Skeleton width='100%' />
|
||||
<br />
|
||||
<Skeleton width='70%' />
|
||||
</>
|
||||
) : (domainBlocks.get('isAvailable') ? (
|
||||
) : (domainBlocks.isAvailable ? (
|
||||
<>
|
||||
<p><FormattedMessage id='about.domain_blocks.preamble' defaultMessage='Mastodon generally allows you to view content from and interact with users from any other server in the fediverse. These are the exceptions that have been made on this particular server.' /></p>
|
||||
|
||||
{domainBlocks.get('items').size > 0 && (
|
||||
{domainBlocks.items.length > 0 && (
|
||||
<div className='about__domain-blocks'>
|
||||
{domainBlocks.get('items').map(block => (
|
||||
<div className='about__domain-blocks__domain' key={block.get('domain')}>
|
||||
{domainBlocks.items.map(block => (
|
||||
<div className='about__domain-blocks__domain' key={block.domain}>
|
||||
<div className='about__domain-blocks__domain__header'>
|
||||
<h6><span title={`SHA-256: ${block.get('digest')}`}>{block.get('domain')}</span></h6>
|
||||
<span className='about__domain-blocks__domain__type' title={intl.formatMessage(severityMessages[block.get('severity')].explanation)}>{intl.formatMessage(severityMessages[block.get('severity')].title)}</span>
|
||||
<h6><span title={`SHA-256: ${block.digest}`}>{block.domain}</span></h6>
|
||||
<span className='about__domain-blocks__domain__type' title={intl.formatMessage(severityMessages[block.severity].explanation)}>{intl.formatMessage(severityMessages[block.severity].title)}</span>
|
||||
</div>
|
||||
|
||||
<p>{(block.get('comment') || '').length > 0 ? block.get('comment') : <FormattedMessage id='about.domain_blocks.no_reason_available' defaultMessage='Reason not available' />}</p>
|
||||
<p>{(block.comment ?? '').length > 0 ? block.comment : <FormattedMessage id='about.domain_blocks.no_reason_available' defaultMessage='Reason not available' />}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -25,6 +25,7 @@ export function usePinnedStatusIds({
|
||||
tagged,
|
||||
pinned: true,
|
||||
replies: true,
|
||||
boosts: true,
|
||||
});
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
@@ -31,7 +31,7 @@ const selectServerName = createAppSelector(
|
||||
[
|
||||
(state) => state.accounts,
|
||||
(_, accountId: string) => accountId,
|
||||
(state) => state.server.getIn(['server', 'domain']) as string | undefined,
|
||||
(state) => state.server.server.item?.domain,
|
||||
],
|
||||
(accounts, accountId, serverDomain) => {
|
||||
const acct = accounts.getIn([accountId, 'acct']) as string | undefined;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
.wrapper {
|
||||
--list-item-gap: 16px;
|
||||
--list-item-padding-block: 12px;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
&:has(input:disabled) {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useId } from 'react';
|
||||
|
||||
import type { ApiCollectionJSON } from '@/mastodon/api_types/collections';
|
||||
import { Toggle } from '@/mastodon/components/form_fields';
|
||||
import {
|
||||
ListItemContent,
|
||||
ListItemWrapper,
|
||||
} from '@/mastodon/components/list_item';
|
||||
import {
|
||||
AvatarGrid,
|
||||
CollectionInfo,
|
||||
} from 'mastodon/features/collections/components/collection_lockup';
|
||||
|
||||
import classes from './collection_toggle.module.scss';
|
||||
|
||||
export interface CollectionToggleProps {
|
||||
collection: ApiCollectionJSON;
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
subtitle?: React.ReactNode;
|
||||
onChange: React.ChangeEventHandler<HTMLInputElement>;
|
||||
}
|
||||
|
||||
export const CollectionToggle: React.FC<CollectionToggleProps> = ({
|
||||
collection,
|
||||
checked,
|
||||
disabled,
|
||||
subtitle,
|
||||
onChange,
|
||||
}) => {
|
||||
const uniqueId = useId();
|
||||
const toggleId = `${uniqueId}-toggle`;
|
||||
const infoId = `${uniqueId}-info`;
|
||||
|
||||
return (
|
||||
<ListItemWrapper
|
||||
className={classes.wrapper}
|
||||
icon={
|
||||
<AvatarGrid
|
||||
accountIds={collection.items.map((item) => item.account_id)}
|
||||
sensitive={collection.sensitive}
|
||||
/>
|
||||
}
|
||||
sideContent={
|
||||
<Toggle
|
||||
id={toggleId}
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={onChange}
|
||||
aria-describedby={infoId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ListItemContent
|
||||
as='label'
|
||||
htmlFor={toggleId}
|
||||
subtitle={
|
||||
subtitle ?? (
|
||||
<CollectionInfo
|
||||
collection={collection}
|
||||
withTimestamp={false}
|
||||
withAuthorHandle={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
{collection.name}
|
||||
</ListItemContent>
|
||||
</ListItemWrapper>
|
||||
);
|
||||
};
|
||||
143
app/javascript/mastodon/features/collection_adder/index.tsx
Normal file
143
app/javascript/mastodon/features/collection_adder/index.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { useCallback, useId, useState } from 'react';
|
||||
|
||||
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import type { ApiCollectionJSON } from '@/mastodon/api_types/collections';
|
||||
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
||||
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
|
||||
import type { Account } from '@/mastodon/models/account';
|
||||
import {
|
||||
addCollectionItem,
|
||||
removeCollectionItem,
|
||||
} from '@/mastodon/reducers/slices/collections';
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
import { MAX_COLLECTION_ACCOUNT_COUNT } from '../collections/editor/accounts';
|
||||
import { useCollectionsCreatedBy } from '../collections/overview/created_by_you';
|
||||
|
||||
import { CollectionToggle } from './collection_toggle';
|
||||
|
||||
const messages = defineMessages({
|
||||
close: {
|
||||
id: 'lightbox.close',
|
||||
defaultMessage: 'Close',
|
||||
},
|
||||
});
|
||||
|
||||
const ListItem: React.FC<{
|
||||
collection: ApiCollectionJSON;
|
||||
account: Account;
|
||||
}> = ({ collection, account }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
const accountItemInCollection = collection.items.find(
|
||||
(item) => item.account_id === account.id,
|
||||
);
|
||||
const isAccountInCollection = !!accountItemInCollection;
|
||||
|
||||
const addOrRemove = useCallback(
|
||||
async (shouldAdd: boolean) => {
|
||||
setIsUpdating(true);
|
||||
|
||||
if (shouldAdd) {
|
||||
await dispatch(
|
||||
addCollectionItem({
|
||||
collectionId: collection.id,
|
||||
accountId: account.id,
|
||||
}),
|
||||
);
|
||||
} else if (accountItemInCollection) {
|
||||
await dispatch(
|
||||
removeCollectionItem({
|
||||
collectionId: collection.id,
|
||||
itemId: accountItemInCollection.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
setIsUpdating(false);
|
||||
},
|
||||
[account.id, collection.id, accountItemInCollection, dispatch],
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
void addOrRemove(e.target.checked);
|
||||
},
|
||||
[addOrRemove],
|
||||
);
|
||||
|
||||
const hasMaxItemCount =
|
||||
!isAccountInCollection &&
|
||||
collection.item_count >= MAX_COLLECTION_ACCOUNT_COUNT;
|
||||
|
||||
return (
|
||||
<CollectionToggle
|
||||
key={collection.id}
|
||||
collection={collection}
|
||||
disabled={isUpdating || hasMaxItemCount}
|
||||
subtitle={
|
||||
hasMaxItemCount ? (
|
||||
<FormattedMessage
|
||||
id='collections.search_accounts_max_reached'
|
||||
defaultMessage='You have added the maximum number of accounts'
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
checked={isAccountInCollection}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const CollectionAdder: React.FC<{
|
||||
accountId: string;
|
||||
onClose: () => void;
|
||||
}> = ({ accountId, onClose }) => {
|
||||
const intl = useIntl();
|
||||
const titleId = useId();
|
||||
const account = useAppSelector((state) => state.accounts.get(accountId));
|
||||
const currentAccountId = useCurrentAccountId();
|
||||
const { collections, status } = useCollectionsCreatedBy(currentAccountId);
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal dialog-modal'>
|
||||
<div className='dialog-modal__header'>
|
||||
<IconButton
|
||||
className='dialog-modal__header__close'
|
||||
title={intl.formatMessage(messages.close)}
|
||||
icon='times'
|
||||
iconComponent={CloseIcon}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
<span className='dialog-modal__header__title' id={titleId}>
|
||||
<FormattedMessage
|
||||
id='collections.add_to_collection'
|
||||
defaultMessage='Add {name} to collections'
|
||||
values={{ name: <strong>@{account?.acct}</strong> }}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='dialog-modal__content'>
|
||||
<div
|
||||
className='lists-scrollable'
|
||||
role='group'
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
{status === 'loading' || !account ? (
|
||||
<LoadingIndicator />
|
||||
) : (
|
||||
collections.map((item) => (
|
||||
<ListItem key={item.id} collection={item} account={account} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -57,10 +57,45 @@ export const CollectionLockup: React.FC<CollectionLockupProps> = ({
|
||||
className,
|
||||
}) => {
|
||||
const { id, name } = collection;
|
||||
|
||||
return (
|
||||
<ListItemWrapper
|
||||
className={classNames(classes.wrapper, className)}
|
||||
icon={
|
||||
<AvatarGrid
|
||||
accountIds={collection.items.map((item) => item.account_id)}
|
||||
sensitive={collection.sensitive}
|
||||
/>
|
||||
}
|
||||
sideContent={sideContent}
|
||||
>
|
||||
<ListItemLink
|
||||
as='h3'
|
||||
to={getCollectionPath(id)}
|
||||
subtitle={
|
||||
<CollectionInfo
|
||||
collection={collection}
|
||||
withAuthorHandle={withAuthorHandle}
|
||||
withTimestamp={withTimestamp}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{name}
|
||||
</ListItemLink>
|
||||
</ListItemWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export const CollectionInfo: React.FC<
|
||||
Pick<
|
||||
CollectionLockupProps,
|
||||
'collection' | 'withAuthorHandle' | 'withTimestamp'
|
||||
>
|
||||
> = ({ collection, withAuthorHandle, withTimestamp }) => {
|
||||
const authorAccount = useAccount(collection.account_id);
|
||||
const authorHandle = useAccountHandle(authorAccount, domain);
|
||||
|
||||
const collectionInfo = (
|
||||
return (
|
||||
<ul>
|
||||
{collection.sensitive && (
|
||||
<li className='sr-only'>
|
||||
@@ -98,25 +133,4 @@ export const CollectionLockup: React.FC<CollectionLockupProps> = ({
|
||||
)}
|
||||
</ul>
|
||||
);
|
||||
|
||||
return (
|
||||
<ListItemWrapper
|
||||
className={classNames(classes.wrapper, className)}
|
||||
icon={
|
||||
<AvatarGrid
|
||||
accountIds={collection.items.map((item) => item.account_id)}
|
||||
sensitive={collection.sensitive}
|
||||
/>
|
||||
}
|
||||
sideContent={sideContent}
|
||||
>
|
||||
<ListItemLink
|
||||
as='h3'
|
||||
to={getCollectionPath(id)}
|
||||
subtitle={collectionInfo}
|
||||
>
|
||||
{name}
|
||||
</ListItemLink>
|
||||
</ListItemWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -24,11 +24,13 @@ import classes from './share_modal.module.scss';
|
||||
const messages = defineMessages({
|
||||
shareTextOwn: {
|
||||
id: 'collection.share_template_own',
|
||||
defaultMessage: 'Check out my new collection: {link}',
|
||||
defaultMessage: 'Check out my new collection:',
|
||||
description: 'Collection links are appended after a new line',
|
||||
},
|
||||
shareTextOther: {
|
||||
id: 'collection.share_template_other',
|
||||
defaultMessage: 'Check out this cool collection: {link}',
|
||||
defaultMessage: 'Check out this cool collection:',
|
||||
description: 'Collection links are appended after a new line',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -51,13 +53,10 @@ export const CollectionShareModal: React.FC<{
|
||||
}, [collectionLink]);
|
||||
|
||||
const handleShareViaPost = useCallback(() => {
|
||||
const shareMessage = isOwnCollection
|
||||
? intl.formatMessage(messages.shareTextOwn, {
|
||||
link: collectionLink,
|
||||
})
|
||||
: intl.formatMessage(messages.shareTextOther, {
|
||||
link: collectionLink,
|
||||
});
|
||||
let shareMessage = isOwnCollection
|
||||
? intl.formatMessage(messages.shareTextOwn)
|
||||
: intl.formatMessage(messages.shareTextOther);
|
||||
shareMessage += `\n\n${collectionLink}`;
|
||||
|
||||
onClose();
|
||||
dispatch(changeCompose(shareMessage));
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { PendingBadge } from '@/mastodon/components/badge';
|
||||
import { SelectField } from '@/mastodon/components/form_fields';
|
||||
import { useSearchParam } from '@/mastodon/hooks/useSearchParam';
|
||||
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
|
||||
import type {
|
||||
ApiCollectionJSON,
|
||||
@@ -14,7 +16,6 @@ import {
|
||||
AccountListItemFollowButton,
|
||||
} from 'mastodon/components/account_list_item';
|
||||
import { Button } from 'mastodon/components/button';
|
||||
import { Callout } from 'mastodon/components/callout';
|
||||
import {
|
||||
Article,
|
||||
ItemList,
|
||||
@@ -35,50 +36,6 @@ const messages = defineMessages({
|
||||
},
|
||||
});
|
||||
|
||||
const SensitiveScreen: React.FC<{
|
||||
sensitive: boolean | undefined;
|
||||
focusTargetRef: React.RefObject<HTMLHeadingElement>;
|
||||
children: React.ReactNode;
|
||||
}> = ({ sensitive, focusTargetRef, children }) => {
|
||||
const [isVisible, setIsVisible] = useState(!sensitive);
|
||||
|
||||
const showAnyway = useCallback(() => {
|
||||
setIsVisible(true);
|
||||
setTimeout(() => {
|
||||
focusTargetRef.current?.focus();
|
||||
}, 0);
|
||||
}, [focusTargetRef]);
|
||||
|
||||
if (isVisible) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<Callout
|
||||
variant='warning'
|
||||
title={
|
||||
<FormattedMessage
|
||||
id='collections.detail.sensitive_content'
|
||||
defaultMessage='Sensitive content'
|
||||
/>
|
||||
}
|
||||
primaryLabel={
|
||||
<FormattedMessage
|
||||
id='content_warning.show_short'
|
||||
defaultMessage='Show'
|
||||
/>
|
||||
}
|
||||
onPrimary={showAnyway}
|
||||
className={classes.sensitiveScreen}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='collections.detail.sensitive_note'
|
||||
defaultMessage='The description and accounts may not be suitable for all viewers.'
|
||||
/>
|
||||
</Callout>
|
||||
);
|
||||
};
|
||||
|
||||
type CollectionItemWithAccount = CollectionAccountItem & {
|
||||
account?: Account | null;
|
||||
};
|
||||
@@ -98,6 +55,43 @@ const getCollectionItems = createAppSelector(
|
||||
),
|
||||
);
|
||||
|
||||
function sortAccounts(
|
||||
accounts: CollectionItemWithAccount[],
|
||||
sortBy?: string,
|
||||
): CollectionItemWithAccount[] {
|
||||
if (!sortBy || sortBy === 'date_added') {
|
||||
return accounts;
|
||||
}
|
||||
|
||||
const sorted = [...accounts];
|
||||
|
||||
switch (sortBy) {
|
||||
case 'alphabetical':
|
||||
return sorted.sort((a, b) => {
|
||||
const nameA = a.account?.display_name ?? '';
|
||||
const nameB = b.account?.display_name ?? '';
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
|
||||
case 'last_active':
|
||||
return sorted.sort((a, b) => {
|
||||
const dateA = a.account?.last_status_at ?? '';
|
||||
const dateB = b.account?.last_status_at ?? '';
|
||||
return new Date(dateB).getTime() - new Date(dateA).getTime();
|
||||
});
|
||||
|
||||
case 'most_followers':
|
||||
return sorted.sort((a, b) => {
|
||||
const followersA = a.account?.followers_count ?? 0;
|
||||
const followersB = b.account?.followers_count ?? 0;
|
||||
return followersB - followersA;
|
||||
});
|
||||
|
||||
default:
|
||||
return accounts;
|
||||
}
|
||||
}
|
||||
|
||||
export const CollectionAccountsList: React.FC<{
|
||||
collection: ApiCollectionJSON;
|
||||
}> = ({ collection }) => {
|
||||
@@ -113,11 +107,20 @@ export const CollectionAccountsList: React.FC<{
|
||||
getCollectionItems(state, id),
|
||||
);
|
||||
|
||||
const [sortBy, setSortBy] = useSearchParam('sort', 'date_added');
|
||||
const changeSortBy = useCallback(
|
||||
(event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
setSortBy(event.target.value);
|
||||
},
|
||||
[setSortBy],
|
||||
);
|
||||
const sortedAccounts = sortAccounts(collectionAccounts, sortBy);
|
||||
|
||||
const { visibleAccounts, hiddenAccounts } = useMemo(() => {
|
||||
const visibleAccounts: CollectionItemWithAccount[] = [];
|
||||
const hiddenAccounts: CollectionItemWithAccount[] = [];
|
||||
|
||||
collectionAccounts.forEach((item) => {
|
||||
sortedAccounts.forEach((item) => {
|
||||
const { account, account_id } = item;
|
||||
|
||||
if (!isOwnCollection && !account) {
|
||||
@@ -134,7 +137,7 @@ export const CollectionAccountsList: React.FC<{
|
||||
});
|
||||
|
||||
return { visibleAccounts, hiddenAccounts };
|
||||
}, [collectionAccounts, isOwnCollection, relationships]);
|
||||
}, [sortedAccounts, isOwnCollection, relationships]);
|
||||
|
||||
const renderAccountItemButton = useCallback(
|
||||
({ relationship, accountId }: RenderButtonOptions) => {
|
||||
@@ -192,46 +195,81 @@ export const CollectionAccountsList: React.FC<{
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3
|
||||
className={classes.columnSubheading}
|
||||
tabIndex={-1}
|
||||
ref={listHeadingRef}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='collections.account_count'
|
||||
defaultMessage='{count, plural, one {# account} other {# accounts}}'
|
||||
values={{ count: collection.item_count }}
|
||||
/>
|
||||
</h3>
|
||||
<SensitiveScreen
|
||||
sensitive={!isOwnCollection && collection.sensitive}
|
||||
focusTargetRef={listHeadingRef}
|
||||
>
|
||||
<ItemList emptyMessage={intl.formatMessage(messages.empty)}>
|
||||
<TruncatedListItems
|
||||
visibleItems={visibleAccounts}
|
||||
truncatedItems={hiddenAccounts}
|
||||
toggleButton={{
|
||||
icon: VisibilityOffIcon,
|
||||
title: (
|
||||
<FormattedMessage
|
||||
id='collections.hidden_accounts_link'
|
||||
defaultMessage='{count, plural, one {# hidden account} other {# hidden accounts}}'
|
||||
values={{ count: hiddenAccounts.length }}
|
||||
/>
|
||||
),
|
||||
subtitle: (
|
||||
<FormattedMessage
|
||||
id='collections.hidden_accounts_description'
|
||||
defaultMessage='You’ve blocked or muted {count, plural, one {this user} other {these users}}'
|
||||
values={{ count: hiddenAccounts.length }}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
renderListItem={renderListItem}
|
||||
<div className={classes.subheadingWithSelect}>
|
||||
<h3
|
||||
className={classes.columnSubheading}
|
||||
tabIndex={-1}
|
||||
ref={listHeadingRef}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='collections.account_count'
|
||||
defaultMessage='{count, plural, one {# account} other {# accounts}}'
|
||||
values={{ count: collection.item_count }}
|
||||
/>
|
||||
</ItemList>
|
||||
</SensitiveScreen>
|
||||
</h3>
|
||||
<SelectField
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='collections.sort_by'
|
||||
defaultMessage='Sort by:'
|
||||
/>
|
||||
}
|
||||
value={sortBy}
|
||||
onChange={changeSortBy}
|
||||
inputPlacement='inline-end'
|
||||
className={classes.select}
|
||||
wrapperClassName={classes.selectWrapper}
|
||||
>
|
||||
<option value='alphabetical'>
|
||||
<FormattedMessage
|
||||
id='collections.sort_alphabetical'
|
||||
defaultMessage='Alphabetical'
|
||||
/>
|
||||
</option>
|
||||
<option value='last_active'>
|
||||
<FormattedMessage
|
||||
id='collections.sort_last_active'
|
||||
defaultMessage='Last active'
|
||||
/>
|
||||
</option>
|
||||
<option value='most_followers'>
|
||||
<FormattedMessage
|
||||
id='collections.sort_most_followers'
|
||||
defaultMessage='Most followers'
|
||||
/>
|
||||
</option>
|
||||
<option value='date_added'>
|
||||
<FormattedMessage
|
||||
id='collections.sort_date_added'
|
||||
defaultMessage='Date added'
|
||||
/>
|
||||
</option>
|
||||
</SelectField>
|
||||
</div>
|
||||
<ItemList emptyMessage={intl.formatMessage(messages.empty)}>
|
||||
<TruncatedListItems
|
||||
visibleItems={visibleAccounts}
|
||||
truncatedItems={hiddenAccounts}
|
||||
toggleButton={{
|
||||
icon: VisibilityOffIcon,
|
||||
title: (
|
||||
<FormattedMessage
|
||||
id='collections.hidden_accounts_link'
|
||||
defaultMessage='{count, plural, one {# hidden account} other {# hidden accounts}}'
|
||||
values={{ count: hiddenAccounts.length }}
|
||||
/>
|
||||
),
|
||||
subtitle: (
|
||||
<FormattedMessage
|
||||
id='collections.hidden_accounts_description'
|
||||
defaultMessage='You’ve blocked or muted {count, plural, one {this user} other {these users}}'
|
||||
values={{ count: hiddenAccounts.length }}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
renderListItem={renderListItem}
|
||||
/>
|
||||
</ItemList>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
@@ -138,9 +138,40 @@ export const PendingNote: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
|
||||
collection,
|
||||
const SensitiveContentNote: React.FC<{ onReveal: () => void }> = ({
|
||||
onReveal,
|
||||
}) => {
|
||||
return (
|
||||
<Callout
|
||||
variant='warning'
|
||||
title={
|
||||
<FormattedMessage
|
||||
id='collections.detail.sensitive_content'
|
||||
defaultMessage='Sensitive content'
|
||||
/>
|
||||
}
|
||||
primaryLabel={
|
||||
<FormattedMessage
|
||||
id='content_warning.show_short'
|
||||
defaultMessage='Show'
|
||||
/>
|
||||
}
|
||||
onPrimary={onReveal}
|
||||
className={classes.sensitiveScreen}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='collections.detail.sensitive_note'
|
||||
defaultMessage='The description and accounts may not be suitable for all viewers.'
|
||||
/>
|
||||
</Callout>
|
||||
);
|
||||
};
|
||||
|
||||
const CollectionHeader: React.FC<{
|
||||
collection: ApiCollectionJSON;
|
||||
withDescription: boolean;
|
||||
headingRef: React.RefObject<HTMLHeadingElement>;
|
||||
}> = ({ collection, withDescription, headingRef }) => {
|
||||
const intl = useIntl();
|
||||
const { name, description, tag, account_id, items } = collection;
|
||||
const dispatch = useAppDispatch();
|
||||
@@ -181,7 +212,9 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
|
||||
<div className={classes.titleWithMenu}>
|
||||
<div className={classes.titleWrapper}>
|
||||
{tag && <Badge label={`#${tag.name}`} icon={null} />}
|
||||
<h2 className={classes.name}>{name}</h2>
|
||||
<h2 className={classes.name} ref={headingRef} tabIndex={-1}>
|
||||
{name}
|
||||
</h2>
|
||||
<AuthorNote id={account_id} />
|
||||
</div>
|
||||
<div className={classes.headerButtonWrapper}>
|
||||
@@ -199,7 +232,9 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{description && <p className={classes.description}>{description}</p>}
|
||||
{withDescription && description && (
|
||||
<p className={classes.description}>{description}</p>
|
||||
)}
|
||||
{hasPendingAccounts && <PendingNote />}
|
||||
{isCurrentUserInCollection && (
|
||||
<RevokeControls
|
||||
@@ -211,6 +246,52 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
|
||||
);
|
||||
};
|
||||
|
||||
function useRevealSensitiveContent({
|
||||
sensitive,
|
||||
}: {
|
||||
sensitive: boolean | undefined;
|
||||
}) {
|
||||
const postRevealFocusTargetRef = useRef<HTMLHeadingElement>(null);
|
||||
const [isContentVisible, setIsContentVisible] = useState(!sensitive);
|
||||
|
||||
const revealContent = useCallback(() => {
|
||||
setIsContentVisible(true);
|
||||
setTimeout(() => {
|
||||
postRevealFocusTargetRef.current?.focus();
|
||||
}, 0);
|
||||
}, [postRevealFocusTargetRef]);
|
||||
|
||||
return {
|
||||
isContentVisible,
|
||||
revealContent,
|
||||
postRevealFocusTargetRef,
|
||||
};
|
||||
}
|
||||
|
||||
const ColumnContent: React.FC<{
|
||||
collection: ApiCollectionJSON;
|
||||
}> = ({ collection }) => {
|
||||
const { isContentVisible, revealContent, postRevealFocusTargetRef } =
|
||||
useRevealSensitiveContent({
|
||||
sensitive: collection.sensitive && collection.account_id !== me,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<CollectionHeader
|
||||
collection={collection}
|
||||
headingRef={postRevealFocusTargetRef}
|
||||
withDescription={isContentVisible}
|
||||
/>
|
||||
{isContentVisible ? (
|
||||
<CollectionAccountsList collection={collection} />
|
||||
) : (
|
||||
<SensitiveContentNote onReveal={revealContent} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const CollectionDetailPage: React.FC<{
|
||||
multiColumn?: boolean;
|
||||
}> = ({ multiColumn }) => {
|
||||
@@ -241,10 +322,7 @@ export const CollectionDetailPage: React.FC<{
|
||||
|
||||
<Scrollable>
|
||||
{collection ? (
|
||||
<>
|
||||
<CollectionHeader collection={collection} />
|
||||
<CollectionAccountsList collection={collection} />
|
||||
</>
|
||||
<ColumnContent collection={collection} />
|
||||
) : (
|
||||
<LoadingIndicator />
|
||||
)}
|
||||
|
||||
@@ -64,11 +64,30 @@
|
||||
}
|
||||
|
||||
.sensitiveScreen {
|
||||
margin: 16px;
|
||||
margin-inline: 16px;
|
||||
}
|
||||
|
||||
.subheadingWithSelect {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 16px;
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
.selectWrapper {
|
||||
// Align to right edge even when wrapped to new line
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.select {
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
background-color: var(--color-bg-primary);
|
||||
}
|
||||
|
||||
.columnSubheading {
|
||||
padding-inline: 16px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -39,11 +39,12 @@ import {
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
import { PendingNote } from '../detail';
|
||||
import { canAccountBeAdded, canAccountBeAddedByFollowers } from '../utils';
|
||||
|
||||
import classes from './styles.module.scss';
|
||||
import { WizardStepTitle } from './wizard_step_title';
|
||||
|
||||
const MAX_ACCOUNT_COUNT = 25;
|
||||
export const MAX_COLLECTION_ACCOUNT_COUNT = 25;
|
||||
|
||||
const AddedAccountItem: React.FC<{
|
||||
accountId: string;
|
||||
@@ -99,9 +100,6 @@ const renderAccountItem = (account: ApiMutedAccountJSON) => (
|
||||
|
||||
type GroupKey = 'available' | 'mustFollow' | 'disabled';
|
||||
|
||||
const canAccountBeAdded = (account: ApiMutedAccountJSON) =>
|
||||
['automatic', 'manual'].includes(account.feature_approval.current_user);
|
||||
|
||||
function groupSuggestions(
|
||||
accounts: ApiMutedAccountJSON[],
|
||||
relationships: ImmutableMap<string, Relationship>,
|
||||
@@ -113,12 +111,8 @@ function groupSuggestions(
|
||||
return 'available';
|
||||
}
|
||||
|
||||
const canAccountBeAddedByFollowers =
|
||||
account.feature_approval.automatic.includes('followers') ||
|
||||
account.feature_approval.manual.includes('followers');
|
||||
|
||||
if (
|
||||
canAccountBeAddedByFollowers &&
|
||||
canAccountBeAddedByFollowers(account) &&
|
||||
!relationships.get(account.id)?.following
|
||||
) {
|
||||
return 'mustFollow';
|
||||
@@ -212,7 +206,7 @@ export const CollectionAccounts: React.FC<{
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
|
||||
const hasItems = editorItems.length > 0;
|
||||
const hasMaxItems = editorItems.length === MAX_ACCOUNT_COUNT;
|
||||
const hasMaxItems = editorItems.length === MAX_COLLECTION_ACCOUNT_COUNT;
|
||||
|
||||
const {
|
||||
accounts: suggestedAccounts,
|
||||
@@ -406,7 +400,10 @@ export const CollectionAccounts: React.FC<{
|
||||
<FormattedMessage
|
||||
id='collections.hints.accounts_counter'
|
||||
defaultMessage='{count}/{max} accounts'
|
||||
values={{ count: editorItems.length, max: MAX_ACCOUNT_COUNT }}
|
||||
values={{
|
||||
count: editorItems.length,
|
||||
max: MAX_COLLECTION_ACCOUNT_COUNT,
|
||||
}}
|
||||
/>
|
||||
</AccountsHeadingElement>
|
||||
)}
|
||||
@@ -426,7 +423,7 @@ export const CollectionAccounts: React.FC<{
|
||||
id='collections.accounts.empty_description'
|
||||
defaultMessage='Add up to {count} accounts'
|
||||
values={{
|
||||
count: MAX_ACCOUNT_COUNT,
|
||||
count: MAX_COLLECTION_ACCOUNT_COUNT,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import { EmptyState } from 'mastodon/components/empty_state';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
import { ItemList } from 'mastodon/components/scrollable_list/components';
|
||||
@@ -34,6 +35,7 @@ function useCollectionsFeaturing(accountId: string | null | undefined) {
|
||||
|
||||
export const CollectionsFeaturingYou: React.FC = () => {
|
||||
const accountId = useAccountId();
|
||||
const account = useAccount(accountId);
|
||||
|
||||
const { collections, status } = useCollectionsFeaturing(accountId);
|
||||
|
||||
@@ -46,16 +48,43 @@ export const CollectionsFeaturingYou: React.FC = () => {
|
||||
}
|
||||
|
||||
if (collections.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
message={
|
||||
<FormattedMessage
|
||||
id='empty_column.collections.featured_in'
|
||||
defaultMessage='You have not been added to any collections yet.'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
if (account?.discoverable) {
|
||||
return (
|
||||
<EmptyState
|
||||
message={
|
||||
<FormattedMessage
|
||||
id='empty_column.collections.featured_in'
|
||||
defaultMessage='You have not been added to any collections yet.'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<EmptyState
|
||||
message={
|
||||
<>
|
||||
<FormattedMessage
|
||||
id='empty_column.collections.featured_in'
|
||||
defaultMessage='You have not been added to any collections yet.'
|
||||
/>
|
||||
<br />
|
||||
<FormattedMessage
|
||||
id='empty_column.collections.featured_in_undiscoverable'
|
||||
defaultMessage='In order for people to add you to collections, you need to allow featuring in discovery experiences from <link>Preferences > Privacy and reach</link>'
|
||||
values={{
|
||||
link: (chunks) => (
|
||||
<a href='/settings/privacy#account_discoverable'>
|
||||
{chunks}
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ApiMutedAccountJSON } from '@/mastodon/api_types/accounts';
|
||||
import type { Account } from '@/mastodon/models/account';
|
||||
import { isServerFeatureEnabled } from '@/mastodon/utils/environment';
|
||||
|
||||
export function areCollectionsEnabled() {
|
||||
@@ -5,3 +7,12 @@ export function areCollectionsEnabled() {
|
||||
}
|
||||
|
||||
export const getCollectionPath = (id: string) => `/collections/${id}`;
|
||||
|
||||
export const canAccountBeAdded = (account: ApiMutedAccountJSON | Account) =>
|
||||
['automatic', 'manual'].includes(account.feature_approval.current_user);
|
||||
|
||||
export const canAccountBeAddedByFollowers = (
|
||||
account: ApiMutedAccountJSON | Account,
|
||||
) =>
|
||||
account.feature_approval.automatic.includes('followers') ||
|
||||
account.feature_approval.manual.includes('followers');
|
||||
|
||||
@@ -82,10 +82,6 @@ class ComposeForm extends ImmutablePureComponent {
|
||||
autoFocus: false,
|
||||
};
|
||||
|
||||
state = {
|
||||
highlighted: false,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.textareaRef = createRef(null);
|
||||
@@ -220,8 +216,6 @@ class ComposeForm extends ImmutablePureComponent {
|
||||
Promise.resolve().then(() => {
|
||||
this.textareaRef.current.setSelectionRange(selectionStart, selectionEnd);
|
||||
this.textareaRef.current.focus();
|
||||
this.setState({ highlighted: true });
|
||||
this.timeout = setTimeout(() => this.setState({ highlighted: false }), 700);
|
||||
}).catch(console.error);
|
||||
} else if(prevProps.isSubmitting && !this.props.isSubmitting) {
|
||||
this.textareaRef.current.focus();
|
||||
@@ -252,7 +246,6 @@ class ComposeForm extends ImmutablePureComponent {
|
||||
|
||||
render () {
|
||||
const { intl, onPaste, onDrop, autoFocus, withoutNavigation, maxChars, isSubmitting } = this.props;
|
||||
const { highlighted } = this.state;
|
||||
|
||||
return (
|
||||
<form className='compose-form' onSubmit={this.handleSubmit}>
|
||||
@@ -260,7 +253,7 @@ class ComposeForm extends ImmutablePureComponent {
|
||||
{!withoutNavigation && <NavigationBar />}
|
||||
<Warning />
|
||||
|
||||
<div className={classNames('compose-form__highlightable', { active: highlighted })} ref={this.setRef}>
|
||||
<div className='compose-form__highlightable' ref={this.setRef}>
|
||||
<EditIndicator />
|
||||
|
||||
<div className='compose-form__dropdowns'>
|
||||
|
||||
@@ -228,7 +228,7 @@ class EmojiPickerMenuImpl extends PureComponent {
|
||||
|
||||
handleClick = (emoji, event) => {
|
||||
if (!emoji.native) {
|
||||
emoji.native = emoji.colons;
|
||||
emoji.native = `:${emoji.id}:`;
|
||||
}
|
||||
if (!(event.ctrlKey || event.metaKey)) {
|
||||
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
import { emojiIndex } from 'emoji-mart';
|
||||
import { pick } from 'lodash';
|
||||
|
||||
import { search } from '../emoji_mart_search_light';
|
||||
|
||||
const trimEmojis = emoji => pick(emoji, ['id', 'unified', 'native', 'custom']);
|
||||
|
||||
describe('emoji_index', () => {
|
||||
it('should give same result for emoji_index_light and emoji-mart', () => {
|
||||
const expected = [
|
||||
{
|
||||
id: 'pineapple',
|
||||
unified: '1f34d',
|
||||
native: '🍍',
|
||||
},
|
||||
];
|
||||
expect(search('pineapple').map(trimEmojis)).toEqual(expected);
|
||||
expect(emojiIndex.search('pineapple').map(trimEmojis)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('orders search results correctly', () => {
|
||||
const expected = [
|
||||
{
|
||||
id: 'apple',
|
||||
unified: '1f34e',
|
||||
native: '🍎',
|
||||
},
|
||||
{
|
||||
id: 'pineapple',
|
||||
unified: '1f34d',
|
||||
native: '🍍',
|
||||
},
|
||||
{
|
||||
id: 'green_apple',
|
||||
unified: '1f34f',
|
||||
native: '🍏',
|
||||
},
|
||||
{
|
||||
id: 'iphone',
|
||||
unified: '1f4f1',
|
||||
native: '📱',
|
||||
},
|
||||
];
|
||||
expect(search('apple').map(trimEmojis)).toEqual(expected);
|
||||
expect(emojiIndex.search('apple').map(trimEmojis)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('can include/exclude categories', () => {
|
||||
expect(search('flag', { include: ['people'] })).toEqual([]);
|
||||
expect(emojiIndex.search('flag', { include: ['people'] })).toEqual([]);
|
||||
});
|
||||
|
||||
it('(different behavior from emoji-mart) do not erases custom emoji if not passed again', () => {
|
||||
const custom = [
|
||||
{
|
||||
id: 'mastodon',
|
||||
name: 'mastodon',
|
||||
short_names: ['mastodon'],
|
||||
text: '',
|
||||
emoticons: [],
|
||||
keywords: ['mastodon'],
|
||||
imageUrl: 'http://example.com',
|
||||
custom: true,
|
||||
},
|
||||
];
|
||||
search('', { custom });
|
||||
emojiIndex.search('', { custom });
|
||||
const expected = [];
|
||||
const lightExpected = [
|
||||
{
|
||||
id: 'mastodon',
|
||||
custom: true,
|
||||
},
|
||||
];
|
||||
expect(search('masto').map(trimEmojis)).toEqual(lightExpected);
|
||||
expect(emojiIndex.search('masto').map(trimEmojis)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('(different behavior from emoji-mart) erases custom emoji if another is passed', () => {
|
||||
const custom = [
|
||||
{
|
||||
id: 'mastodon',
|
||||
name: 'mastodon',
|
||||
short_names: ['mastodon'],
|
||||
text: '',
|
||||
emoticons: [],
|
||||
keywords: ['mastodon'],
|
||||
imageUrl: 'http://example.com',
|
||||
custom: true,
|
||||
},
|
||||
];
|
||||
search('', { custom });
|
||||
emojiIndex.search('', { custom });
|
||||
const expected = [];
|
||||
expect(search('masto', { custom: [] }).map(trimEmojis)).toEqual(expected);
|
||||
expect(emojiIndex.search('masto').map(trimEmojis)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('handles custom emoji', () => {
|
||||
const custom = [
|
||||
{
|
||||
id: 'mastodon',
|
||||
name: 'mastodon',
|
||||
short_names: ['mastodon'],
|
||||
text: '',
|
||||
emoticons: [],
|
||||
keywords: ['mastodon'],
|
||||
imageUrl: 'http://example.com',
|
||||
custom: true,
|
||||
},
|
||||
];
|
||||
search('', { custom });
|
||||
emojiIndex.search('', { custom });
|
||||
const expected = [
|
||||
{
|
||||
id: 'mastodon',
|
||||
custom: true,
|
||||
},
|
||||
];
|
||||
expect(search('masto', { custom }).map(trimEmojis)).toEqual(expected);
|
||||
expect(emojiIndex.search('masto', { custom }).map(trimEmojis)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should filter only emojis we care about, exclude pineapple', () => {
|
||||
const emojisToShowFilter = emoji => emoji.unified !== '1F34D';
|
||||
expect(search('apple', { emojisToShowFilter }).map((obj) => obj.id))
|
||||
.not.toContain('pineapple');
|
||||
expect(emojiIndex.search('apple', { emojisToShowFilter }).map((obj) => obj.id))
|
||||
.not.toContain('pineapple');
|
||||
});
|
||||
|
||||
it('does an emoji whose unified name is irregular', () => {
|
||||
const expected = [
|
||||
{
|
||||
'id': 'water_polo',
|
||||
'unified': '1f93d',
|
||||
'native': '🤽',
|
||||
},
|
||||
{
|
||||
'id': 'man-playing-water-polo',
|
||||
'unified': '1f93d-200d-2642-fe0f',
|
||||
'native': '🤽♂️',
|
||||
},
|
||||
{
|
||||
'id': 'woman-playing-water-polo',
|
||||
'unified': '1f93d-200d-2640-fe0f',
|
||||
'native': '🤽♀️',
|
||||
},
|
||||
];
|
||||
expect(search('polo').map(trimEmojis)).toEqual(expected);
|
||||
expect(emojiIndex.search('polo').map(trimEmojis)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('can search for thinking_face', () => {
|
||||
const expected = [
|
||||
{
|
||||
id: 'thinking_face',
|
||||
unified: '1f914',
|
||||
native: '🤔',
|
||||
},
|
||||
];
|
||||
expect(search('thinking_fac').map(trimEmojis)).toEqual(expected);
|
||||
expect(emojiIndex.search('thinking_fac').map(trimEmojis)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('can search for woman-facepalming', () => {
|
||||
const expected = [
|
||||
{
|
||||
id: 'woman-facepalming',
|
||||
unified: '1f926-200d-2640-fe0f',
|
||||
native: '🤦♀️',
|
||||
},
|
||||
];
|
||||
expect(search('woman-facep').map(trimEmojis)).toEqual(expected);
|
||||
expect(emojiIndex.search('woman-facep').map(trimEmojis)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -46,6 +46,8 @@ const loadDB = (() => {
|
||||
return loadPromise;
|
||||
})();
|
||||
|
||||
type ScoreMap = Map<string, AnyEmojiData & { score: number }>;
|
||||
|
||||
export async function search({
|
||||
query,
|
||||
locale: localeString,
|
||||
@@ -75,7 +77,7 @@ export async function search({
|
||||
|
||||
// Create an array of emoji results
|
||||
const db = await loadDB();
|
||||
const resultArrays: Map<string, AnyEmojiData>[] = [];
|
||||
const resultArrays: ScoreMap[] = [];
|
||||
for (let i = 0; i < queryTokens.length; i++) {
|
||||
const token = queryTokens[i];
|
||||
if (!token) continue;
|
||||
@@ -90,14 +92,21 @@ export async function search({
|
||||
db.getAllFromIndex(locale, 'tokens', range),
|
||||
db.getAllFromIndex('custom', 'tokens', range),
|
||||
]);
|
||||
const resultMap = new Map<string, AnyEmojiData>([
|
||||
...unicodeResults.map(
|
||||
(emoji) => [emoji.hexcode, emoji] as [string, AnyEmojiData],
|
||||
),
|
||||
...customResults.map(
|
||||
(emoji) => [emoji.shortcode, emoji] as [string, AnyEmojiData],
|
||||
),
|
||||
]);
|
||||
const resultMap: ScoreMap = new Map();
|
||||
for (const emoji of unicodeResults) {
|
||||
const score = getScoreForEmoji(emoji, token);
|
||||
if (score === null) {
|
||||
continue;
|
||||
}
|
||||
resultMap.set(emoji.hexcode, { ...emoji, score });
|
||||
}
|
||||
for (const emoji of customResults) {
|
||||
const score = getScoreForEmoji(emoji, token);
|
||||
if (score === null) {
|
||||
continue;
|
||||
}
|
||||
resultMap.set(emoji.shortcode, { ...emoji, score });
|
||||
}
|
||||
log('found %d results for token "%s"', resultMap.size, token);
|
||||
resultArrays.push(resultMap);
|
||||
}
|
||||
@@ -106,7 +115,7 @@ export async function search({
|
||||
const results = Array.from(
|
||||
resultArrays
|
||||
.reduce((prev, curr) => {
|
||||
const intersection = new Map<string, AnyEmojiData>();
|
||||
const intersection: ScoreMap = new Map();
|
||||
for (const [code, emoji] of prev) {
|
||||
if (curr.has(code)) {
|
||||
intersection.set(code, emoji);
|
||||
@@ -115,33 +124,7 @@ export async function search({
|
||||
return intersection;
|
||||
})
|
||||
.values(),
|
||||
);
|
||||
|
||||
results.sort((a, b) => {
|
||||
// Checks if a or b has the last token exactly, or only a prefix.
|
||||
const aHasToken = a.tokens.includes(lastToken);
|
||||
const bHasToken = b.tokens.includes(lastToken);
|
||||
if (aHasToken && !bHasToken) {
|
||||
return -1;
|
||||
} else if (!aHasToken && bHasToken) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// If one is a custom emoji, prioritize it over Unicode emojis.
|
||||
if ('category' in a) {
|
||||
return -1;
|
||||
} else if ('category' in b) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// If both are Unicode emojis, prioritize by order.
|
||||
if ('order' in a && 'order' in b) {
|
||||
return (a.order ?? 0) - (b.order ?? 0); // If these are both Unicode emojis, sort by order.
|
||||
}
|
||||
|
||||
// ¯\_(ツ)_/¯
|
||||
return 0;
|
||||
});
|
||||
).toSorted((a, b) => a.score - b.score);
|
||||
|
||||
const time = performance.measure('emoji-search-end', 'emoji-search-start');
|
||||
log(
|
||||
@@ -157,6 +140,24 @@ export async function search({
|
||||
return results;
|
||||
}
|
||||
|
||||
function getScoreForEmoji(emoji: AnyEmojiData, query: string) {
|
||||
const id = 'shortcode' in emoji ? emoji.shortcode : emoji.label;
|
||||
if (id === query) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let index = 1;
|
||||
for (const token of [id, emoji.tokens]) {
|
||||
const tokenIndex = token.indexOf(query);
|
||||
if (tokenIndex !== -1) {
|
||||
return index + tokenIndex / token.length;
|
||||
}
|
||||
index++;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function putEmojiData(emojis: CompactEmoji[], locale: Locale) {
|
||||
loadedLocales.add(locale);
|
||||
const db = await loadDB();
|
||||
@@ -252,6 +253,12 @@ export async function loadEmojiByHexcode(
|
||||
return skinHexcodeToEmoji(hexcode, skinResult);
|
||||
}
|
||||
|
||||
export async function loadAllUnicodeEmojis(localeString: string) {
|
||||
const locale = await toLoadedLocale(localeString);
|
||||
const db = await loadDB();
|
||||
return db.getAll(locale);
|
||||
}
|
||||
|
||||
export async function loadCustomEmojiByShortcode(shortcode: string) {
|
||||
const db = await loadDB();
|
||||
return db.get('custom', shortcode);
|
||||
@@ -285,6 +292,11 @@ export async function loadLegacyShortcodesByShortcode(shortcode: string) {
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadAllShortcodes() {
|
||||
const db = await loadDB();
|
||||
return db.getAll('shortcodes');
|
||||
}
|
||||
|
||||
// Private functions
|
||||
|
||||
async function syncLocales(db: Database) {
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import type { BaseEmoji, EmojiData, NimbleEmojiIndex } from 'emoji-mart';
|
||||
import type { Category, Data, Emoji } from 'emoji-mart/dist-es/utils/data';
|
||||
|
||||
/*
|
||||
* The 'search' property, although not defined in the [`Emoji`]{@link node_modules/@types/emoji-mart/dist-es/utils/data.d.ts#Emoji} type,
|
||||
* is used in the application.
|
||||
* This could be due to an oversight by the library maintainer.
|
||||
* The `search` property is defined and used [here]{@link node_modules/emoji-mart/dist/utils/data.js#uncompress}.
|
||||
*/
|
||||
export type Search = string;
|
||||
/*
|
||||
* The 'skins' property does not exist in the application data.
|
||||
* This could be a potential area of refactoring or error handling.
|
||||
* The non-existence of 'skins' property is evident at [this location]{@link app/javascript/mastodon/features/emoji/emoji_compressed.js:121}.
|
||||
*/
|
||||
type Skins = null;
|
||||
|
||||
type Filename = string;
|
||||
type UnicodeFilename = string;
|
||||
export type FilenameData = [
|
||||
filename: Filename,
|
||||
unicodeFilename?: UnicodeFilename,
|
||||
][];
|
||||
export type ShortCodesToEmojiDataKey =
|
||||
| EmojiData['id']
|
||||
| BaseEmoji['native']
|
||||
| keyof NimbleEmojiIndex['emojis'];
|
||||
|
||||
type SearchData = [
|
||||
BaseEmoji['native'],
|
||||
Emoji['short_names'],
|
||||
Search,
|
||||
Emoji['unified'],
|
||||
];
|
||||
|
||||
export type ShortCodesToEmojiData = Record<
|
||||
ShortCodesToEmojiDataKey,
|
||||
[FilenameData, SearchData]
|
||||
>;
|
||||
type EmojisWithoutShortCodes = FilenameData;
|
||||
|
||||
type EmojiCompressed = [
|
||||
ShortCodesToEmojiData,
|
||||
Skins,
|
||||
Category[],
|
||||
Data['aliases'],
|
||||
EmojisWithoutShortCodes,
|
||||
];
|
||||
|
||||
/*
|
||||
* `emoji_compressed.js` uses `babel-plugin-preval`, which makes it difficult to convert to TypeScript.
|
||||
* As a temporary solution, we are allowing a default export here to apply the TypeScript type `EmojiCompressed` to the JS file export.
|
||||
* - {@link app/javascript/mastodon/features/emoji/emoji_compressed.js}
|
||||
*/
|
||||
declare const emojiCompressed: EmojiCompressed;
|
||||
|
||||
export default emojiCompressed; // eslint-disable-line import/no-default-export
|
||||
@@ -1,138 +0,0 @@
|
||||
// @preval
|
||||
// http://www.unicode.org/Public/emoji/5.0/emoji-test.txt
|
||||
// This file contains the compressed version of the emoji data from
|
||||
// both emoji_map.json and from emoji-mart's emojiIndex and data objects.
|
||||
// It's designed to be emitted in an array format to take up less space
|
||||
// over the wire.
|
||||
|
||||
// This version comment should be bumped each time the emoji data is changed
|
||||
// to ensure that the prevaled file is regenerated by Babel
|
||||
// version: 4
|
||||
|
||||
import { NimbleEmojiIndex } from 'emoji-mart';
|
||||
import { uncompress as emojiMartUncompress } from 'emoji-mart/dist/utils/data';
|
||||
|
||||
import data from './emoji_data.json';
|
||||
import emojiMap from './emoji_map.json';
|
||||
import { unicodeToFilename, unicodeToUnifiedName } from './unicode_utils';
|
||||
|
||||
emojiMartUncompress(data);
|
||||
|
||||
const emojiMartData = data;
|
||||
const emojiIndex = new NimbleEmojiIndex(emojiMartData);
|
||||
|
||||
const excluded = ['®', '©', '™'];
|
||||
const skinTones = ['🏻', '🏼', '🏽', '🏾', '🏿'];
|
||||
const shortcodeMap = {};
|
||||
|
||||
const shortCodesToEmojiData = {};
|
||||
const emojisWithoutShortCodes = [];
|
||||
|
||||
Object.keys(emojiIndex.emojis).forEach((key) => {
|
||||
let emoji = emojiIndex.emojis[key];
|
||||
|
||||
// Emojis with skin tone modifiers are stored like this
|
||||
if (Object.hasOwn(emoji, '1')) {
|
||||
emoji = emoji['1'];
|
||||
}
|
||||
|
||||
shortcodeMap[emoji.native] = emoji.id;
|
||||
});
|
||||
|
||||
const stripModifiers = (unicode) => {
|
||||
skinTones.forEach((tone) => {
|
||||
unicode = unicode.replace(tone, '');
|
||||
});
|
||||
|
||||
return unicode;
|
||||
};
|
||||
|
||||
Object.keys(emojiMap).forEach((key) => {
|
||||
if (excluded.includes(key)) {
|
||||
delete emojiMap[key];
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedKey = stripModifiers(key);
|
||||
let shortcode = shortcodeMap[normalizedKey];
|
||||
|
||||
if (!shortcode) {
|
||||
shortcode = shortcodeMap[normalizedKey + '\uFE0F'];
|
||||
}
|
||||
|
||||
const filename = emojiMap[key];
|
||||
|
||||
const filenameData = [key];
|
||||
|
||||
if (unicodeToFilename(key) !== filename) {
|
||||
// filename can't be derived using unicodeToFilename
|
||||
filenameData.push(filename);
|
||||
}
|
||||
|
||||
if (typeof shortcode === 'undefined') {
|
||||
emojisWithoutShortCodes.push(filenameData);
|
||||
} else {
|
||||
if (!Array.isArray(shortCodesToEmojiData[shortcode])) {
|
||||
shortCodesToEmojiData[shortcode] = [[]];
|
||||
}
|
||||
|
||||
shortCodesToEmojiData[shortcode][0].push(filenameData);
|
||||
}
|
||||
});
|
||||
|
||||
Object.keys(emojiIndex.emojis).forEach((key) => {
|
||||
let emoji = emojiIndex.emojis[key];
|
||||
|
||||
// Emojis with skin tone modifiers are stored like this
|
||||
if (Object.hasOwn(emoji, '1')) {
|
||||
emoji = emoji['1'];
|
||||
}
|
||||
|
||||
const { native } = emoji;
|
||||
let { short_names, search, unified } = emojiMartData.emojis[key];
|
||||
|
||||
if (short_names[0] !== key) {
|
||||
throw new Error(
|
||||
'The compressor expects the first short_code to be the ' +
|
||||
'key. It may need to be rewritten if the emoji change such that this ' +
|
||||
'is no longer the case.',
|
||||
);
|
||||
}
|
||||
|
||||
short_names = short_names.slice(1); // first short name can be inferred from the key
|
||||
|
||||
const searchData = [native, short_names, search];
|
||||
|
||||
if (unicodeToUnifiedName(native) !== unified) {
|
||||
// unified name can't be derived from unicodeToUnifiedName
|
||||
searchData.push(unified);
|
||||
}
|
||||
|
||||
if (!Array.isArray(shortCodesToEmojiData[key])) {
|
||||
shortCodesToEmojiData[key] = [[]];
|
||||
}
|
||||
|
||||
shortCodesToEmojiData[key].push(searchData);
|
||||
});
|
||||
|
||||
// JSON.parse/stringify is to emulate what @preval is doing and avoid any
|
||||
// inconsistent behavior in dev mode
|
||||
export default JSON.parse(
|
||||
JSON.stringify([
|
||||
shortCodesToEmojiData,
|
||||
/*
|
||||
* The property `skins` is not found in the current context.
|
||||
* This could potentially lead to issues when interacting with modules or data structures
|
||||
* that expect the presence of `skins` property.
|
||||
* Currently, no definitions or references to `skins` property can be found in:
|
||||
* - {@link node_modules/emoji-mart/dist/utils/data.js}
|
||||
* - {@link node_modules/emoji-mart/data/all.json}
|
||||
* - {@link app/javascript/mastodon/features/emoji/emoji_compressed.d.ts#Skins}
|
||||
* Future refactorings or updates should consider adding definitions or handling for `skins` property.
|
||||
*/
|
||||
emojiMartData.skins,
|
||||
emojiMartData.categories,
|
||||
emojiMartData.aliases,
|
||||
emojisWithoutShortCodes,
|
||||
]),
|
||||
);
|
||||
File diff suppressed because one or more lines are too long
@@ -1,50 +0,0 @@
|
||||
// The output of this module is designed to mimic emoji-mart's
|
||||
// "data" object, such that we can use it for a light version of emoji-mart's
|
||||
// emojiIndex.search functionality.
|
||||
import type { BaseEmoji } from 'emoji-mart';
|
||||
import type { Emoji } from 'emoji-mart/dist-es/utils/data';
|
||||
import emojiCompressed from 'virtual:mastodon-emoji-compressed';
|
||||
import type {
|
||||
Search,
|
||||
ShortCodesToEmojiData,
|
||||
} from 'virtual:mastodon-emoji-compressed';
|
||||
|
||||
import { unicodeToUnifiedName } from './unicode_utils';
|
||||
|
||||
type Emojis = Record<
|
||||
NonNullable<keyof ShortCodesToEmojiData>,
|
||||
{
|
||||
native: BaseEmoji['native'];
|
||||
search: Search;
|
||||
short_names: Emoji['short_names'];
|
||||
unified: Emoji['unified'];
|
||||
}
|
||||
>;
|
||||
|
||||
const [
|
||||
shortCodesToEmojiData,
|
||||
_skins,
|
||||
categories,
|
||||
short_names,
|
||||
_emojisWithoutShortCodes,
|
||||
] = emojiCompressed;
|
||||
|
||||
const emojis: Emojis = {};
|
||||
|
||||
// decompress
|
||||
Object.keys(shortCodesToEmojiData).forEach((shortCode) => {
|
||||
const emojiData = shortCodesToEmojiData[shortCode];
|
||||
if (!emojiData) return;
|
||||
|
||||
const [_filenameData, searchData] = emojiData;
|
||||
const [native, short_names, search, unified] = searchData;
|
||||
|
||||
emojis[shortCode] = {
|
||||
native,
|
||||
search,
|
||||
short_names: short_names ? [shortCode].concat(short_names) : undefined,
|
||||
unified: unified ?? unicodeToUnifiedName(native),
|
||||
};
|
||||
});
|
||||
|
||||
export { emojis, categories, short_names };
|
||||
@@ -1,185 +0,0 @@
|
||||
// This code is largely borrowed from:
|
||||
// https://github.com/missive/emoji-mart/blob/5f2ffcc/src/utils/emoji-index.js
|
||||
|
||||
import { emojis, categories } from './emoji_mart_data_light';
|
||||
import { getData, getSanitizedData, uniq, intersect } from './emoji_utils';
|
||||
|
||||
let originalPool = {};
|
||||
let index = {};
|
||||
let emojisList = {};
|
||||
let emoticonsList = {};
|
||||
let customEmojisList = [];
|
||||
|
||||
for (let emoji in emojis) {
|
||||
let emojiData = emojis[emoji];
|
||||
let { short_names, emoticons } = emojiData;
|
||||
let id = short_names[0];
|
||||
|
||||
if (emoticons) {
|
||||
emoticons.forEach(emoticon => {
|
||||
if (emoticonsList[emoticon]) {
|
||||
return;
|
||||
}
|
||||
|
||||
emoticonsList[emoticon] = id;
|
||||
});
|
||||
}
|
||||
|
||||
emojisList[id] = getSanitizedData(id);
|
||||
originalPool[id] = emojiData;
|
||||
}
|
||||
|
||||
function clearCustomEmojis(pool) {
|
||||
customEmojisList.forEach((emoji) => {
|
||||
let emojiId = emoji.id || emoji.short_names[0];
|
||||
|
||||
delete pool[emojiId];
|
||||
delete emojisList[emojiId];
|
||||
});
|
||||
}
|
||||
|
||||
function addCustomToPool(custom, pool) {
|
||||
if (customEmojisList.length) clearCustomEmojis(pool);
|
||||
|
||||
custom.forEach((emoji) => {
|
||||
let emojiId = emoji.id || emoji.short_names[0];
|
||||
|
||||
if (emojiId && !pool[emojiId]) {
|
||||
pool[emojiId] = getData(emoji);
|
||||
emojisList[emojiId] = getSanitizedData(emoji);
|
||||
}
|
||||
});
|
||||
|
||||
customEmojisList = custom;
|
||||
index = {};
|
||||
}
|
||||
|
||||
function search(value, { emojisToShowFilter, maxResults, include, exclude, custom } = {}) {
|
||||
if (custom !== undefined) {
|
||||
if (customEmojisList !== custom)
|
||||
addCustomToPool(custom, originalPool);
|
||||
} else {
|
||||
custom = [];
|
||||
}
|
||||
|
||||
maxResults = maxResults || 75;
|
||||
include = include || [];
|
||||
exclude = exclude || [];
|
||||
|
||||
let results = null,
|
||||
pool = originalPool;
|
||||
|
||||
if (value.length) {
|
||||
if (value === '-' || value === '-1') {
|
||||
return [emojisList['-1']];
|
||||
}
|
||||
|
||||
let values = value.toLowerCase().split(/[\s|,\-_]+/),
|
||||
allResults = [];
|
||||
|
||||
if (values.length > 2) {
|
||||
values = [values[0], values[1]];
|
||||
}
|
||||
|
||||
if (include.length || exclude.length) {
|
||||
pool = {};
|
||||
|
||||
categories.forEach(category => {
|
||||
let isIncluded = include && include.length ? include.indexOf(category.name.toLowerCase()) > -1 : true;
|
||||
let isExcluded = exclude && exclude.length ? exclude.indexOf(category.name.toLowerCase()) > -1 : false;
|
||||
if (!isIncluded || isExcluded) {
|
||||
return;
|
||||
}
|
||||
|
||||
category.emojis.forEach(emojiId => pool[emojiId] = emojis[emojiId]);
|
||||
});
|
||||
|
||||
if (custom.length) {
|
||||
let customIsIncluded = include && include.length ? include.indexOf('custom') > -1 : true;
|
||||
let customIsExcluded = exclude && exclude.length ? exclude.indexOf('custom') > -1 : false;
|
||||
if (customIsIncluded && !customIsExcluded) {
|
||||
addCustomToPool(custom, pool);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const searchValue = (value) => {
|
||||
let aPool = pool,
|
||||
aIndex = index,
|
||||
length = 0;
|
||||
|
||||
for (let charIndex = 0; charIndex < value.length; charIndex++) {
|
||||
const char = value[charIndex];
|
||||
length++;
|
||||
|
||||
aIndex[char] = aIndex[char] || {};
|
||||
aIndex = aIndex[char];
|
||||
|
||||
if (!aIndex.results) {
|
||||
let scores = {};
|
||||
|
||||
aIndex.results = [];
|
||||
aIndex.pool = {};
|
||||
|
||||
for (let id in aPool) {
|
||||
let emoji = aPool[id],
|
||||
{ search } = emoji,
|
||||
sub = value.slice(0, length),
|
||||
subIndex = search.indexOf(sub);
|
||||
|
||||
if (subIndex !== -1) {
|
||||
let score = subIndex + 1;
|
||||
if (sub === id) score = 0;
|
||||
|
||||
aIndex.results.push(emojisList[id]);
|
||||
aIndex.pool[id] = emoji;
|
||||
|
||||
scores[id] = score;
|
||||
}
|
||||
}
|
||||
|
||||
aIndex.results.sort((a, b) => {
|
||||
let aScore = scores[a.id],
|
||||
bScore = scores[b.id];
|
||||
|
||||
return aScore - bScore;
|
||||
});
|
||||
}
|
||||
|
||||
aPool = aIndex.pool;
|
||||
}
|
||||
|
||||
return aIndex.results;
|
||||
};
|
||||
|
||||
if (values.length > 1) {
|
||||
results = searchValue(value);
|
||||
} else {
|
||||
results = [];
|
||||
}
|
||||
|
||||
allResults = values.map(searchValue).filter(a => a);
|
||||
|
||||
if (allResults.length > 1) {
|
||||
allResults = intersect.apply(null, allResults);
|
||||
} else if (allResults.length) {
|
||||
allResults = allResults[0];
|
||||
}
|
||||
|
||||
results = uniq(results.concat(allResults));
|
||||
}
|
||||
|
||||
if (results) {
|
||||
if (emojisToShowFilter) {
|
||||
results = results.filter((result) => emojisToShowFilter(emojis[result.id]));
|
||||
}
|
||||
|
||||
if (results && results.length > maxResults) {
|
||||
results = results.slice(0, maxResults);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export { search };
|
||||
@@ -1,217 +0,0 @@
|
||||
// This code is largely borrowed from:
|
||||
// https://github.com/missive/emoji-mart/blob/5f2ffcc/src/utils/index.js
|
||||
|
||||
import * as data from './emoji_mart_data_light';
|
||||
|
||||
const buildSearch = (data) => {
|
||||
const search = [];
|
||||
|
||||
let addToSearch = (strings, split) => {
|
||||
if (!strings) {
|
||||
return;
|
||||
}
|
||||
|
||||
(Array.isArray(strings) ? strings : [strings]).forEach((string) => {
|
||||
(split ? string.split(/[-|_|\s]+/) : [string]).forEach((s) => {
|
||||
s = s.toLowerCase();
|
||||
|
||||
if (search.indexOf(s) === -1) {
|
||||
search.push(s);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
addToSearch(data.short_names, true);
|
||||
addToSearch(data.name, true);
|
||||
addToSearch(data.keywords, false);
|
||||
addToSearch(data.emoticons, false);
|
||||
|
||||
return search.join(',');
|
||||
};
|
||||
|
||||
const _String = String;
|
||||
|
||||
const stringFromCodePoint = _String.fromCodePoint || function () {
|
||||
let MAX_SIZE = 0x4000;
|
||||
let codeUnits = [];
|
||||
let highSurrogate;
|
||||
let lowSurrogate;
|
||||
let index = -1;
|
||||
let length = arguments.length;
|
||||
if (!length) {
|
||||
return '';
|
||||
}
|
||||
let result = '';
|
||||
while (++index < length) {
|
||||
let codePoint = Number(arguments[index]);
|
||||
if (
|
||||
!isFinite(codePoint) || // `NaN`, `+Infinity`, or `-Infinity`
|
||||
codePoint < 0 || // not a valid Unicode code point
|
||||
codePoint > 0x10FFFF || // not a valid Unicode code point
|
||||
Math.floor(codePoint) !== codePoint // not an integer
|
||||
) {
|
||||
throw RangeError('Invalid code point: ' + codePoint);
|
||||
}
|
||||
if (codePoint <= 0xFFFF) { // BMP code point
|
||||
codeUnits.push(codePoint);
|
||||
} else { // Astral code point; split in surrogate halves
|
||||
// http://mathiasbynens.be/notes/javascript-encoding#surrogate-formulae
|
||||
codePoint -= 0x10000;
|
||||
highSurrogate = (codePoint >> 10) + 0xD800;
|
||||
lowSurrogate = (codePoint % 0x400) + 0xDC00;
|
||||
codeUnits.push(highSurrogate, lowSurrogate);
|
||||
}
|
||||
if (index + 1 === length || codeUnits.length > MAX_SIZE) {
|
||||
result += String.fromCharCode.apply(null, codeUnits);
|
||||
codeUnits.length = 0;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
|
||||
const _JSON = JSON;
|
||||
|
||||
const COLONS_REGEX = /^(?::([^:]+):)(?::skin-tone-(\d):)?$/;
|
||||
const SKINS = [
|
||||
'1F3FA', '1F3FB', '1F3FC',
|
||||
'1F3FD', '1F3FE', '1F3FF',
|
||||
];
|
||||
|
||||
function unifiedToNative(unified) {
|
||||
let unicodes = unified.split('-'),
|
||||
codePoints = unicodes.map((u) => `0x${u}`);
|
||||
|
||||
return stringFromCodePoint.apply(null, codePoints);
|
||||
}
|
||||
|
||||
function sanitize(emoji) {
|
||||
let { name, short_names, skin_tone, skin_variations, emoticons, unified, custom, imageUrl } = emoji,
|
||||
id = emoji.id || short_names[0],
|
||||
colons = `:${id}:`;
|
||||
|
||||
if (custom) {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
colons,
|
||||
emoticons,
|
||||
custom,
|
||||
imageUrl,
|
||||
};
|
||||
}
|
||||
|
||||
if (skin_tone) {
|
||||
colons += `:skin-tone-${skin_tone}:`;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
colons,
|
||||
emoticons,
|
||||
unified: unified.toLowerCase(),
|
||||
skin: skin_tone || (skin_variations ? 1 : null),
|
||||
native: unifiedToNative(unified),
|
||||
};
|
||||
}
|
||||
|
||||
function getSanitizedData() {
|
||||
return sanitize(getData(...arguments));
|
||||
}
|
||||
|
||||
function getData(emoji, skin, set) {
|
||||
let emojiData = {};
|
||||
|
||||
if (typeof emoji === 'string') {
|
||||
let matches = emoji.match(COLONS_REGEX);
|
||||
|
||||
if (matches) {
|
||||
emoji = matches[1];
|
||||
|
||||
if (matches[2]) {
|
||||
skin = parseInt(matches[2]);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.hasOwn(data.short_names, emoji)) {
|
||||
emoji = data.short_names[emoji];
|
||||
}
|
||||
|
||||
if (Object.hasOwn(data.emojis, emoji)) {
|
||||
emojiData = data.emojis[emoji];
|
||||
}
|
||||
} else if (emoji.id) {
|
||||
if (Object.hasOwn(data.short_names, emoji.id)) {
|
||||
emoji.id = data.short_names[emoji.id];
|
||||
}
|
||||
|
||||
if (Object.hasOwn(data.emojis, emoji.id)) {
|
||||
emojiData = data.emojis[emoji.id];
|
||||
skin = skin || emoji.skin;
|
||||
}
|
||||
}
|
||||
|
||||
if (!Object.keys(emojiData).length) {
|
||||
emojiData = emoji;
|
||||
emojiData.custom = true;
|
||||
|
||||
if (!emojiData.search) {
|
||||
emojiData.search = buildSearch(emoji);
|
||||
}
|
||||
}
|
||||
|
||||
emojiData.emoticons = emojiData.emoticons || [];
|
||||
emojiData.variations = emojiData.variations || [];
|
||||
|
||||
if (emojiData.skin_variations && skin > 1 && set) {
|
||||
emojiData = JSON.parse(_JSON.stringify(emojiData));
|
||||
|
||||
let skinKey = SKINS[skin - 1],
|
||||
variationData = emojiData.skin_variations[skinKey];
|
||||
|
||||
if (!variationData.variations && emojiData.variations) {
|
||||
delete emojiData.variations;
|
||||
}
|
||||
|
||||
if (variationData[`has_img_${set}`]) {
|
||||
emojiData.skin_tone = skin;
|
||||
|
||||
for (let k in variationData) {
|
||||
let v = variationData[k];
|
||||
emojiData[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (emojiData.variations && emojiData.variations.length) {
|
||||
emojiData = JSON.parse(_JSON.stringify(emojiData));
|
||||
emojiData.unified = emojiData.variations.shift();
|
||||
}
|
||||
|
||||
return emojiData;
|
||||
}
|
||||
|
||||
function uniq(arr) {
|
||||
return arr.reduce((acc, item) => {
|
||||
if (acc.indexOf(item) === -1) {
|
||||
acc.push(item);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
function intersect(a, b) {
|
||||
const uniqA = uniq(a);
|
||||
const uniqB = uniq(b);
|
||||
|
||||
return uniqA.filter(item => uniqB.indexOf(item) >= 0);
|
||||
}
|
||||
|
||||
export {
|
||||
getData,
|
||||
getSanitizedData,
|
||||
uniq,
|
||||
intersect,
|
||||
};
|
||||
@@ -75,6 +75,36 @@ export async function fetchCustomEmojiData() {
|
||||
return customEmojis;
|
||||
}
|
||||
|
||||
type LegacyEmoji =
|
||||
| { id: string; custom?: false; native: string }
|
||||
| {
|
||||
id: string;
|
||||
custom: true;
|
||||
};
|
||||
|
||||
// Replicates the old legacy search function.
|
||||
export async function emojiMartSearch(
|
||||
token: string,
|
||||
locale: string,
|
||||
limit = 5,
|
||||
): Promise<LegacyEmoji[]> {
|
||||
const query = token.replace(':', '').toLowerCase().trim();
|
||||
if (!query.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { search } = await import('./database');
|
||||
const results = await search({ query, locale, limit });
|
||||
return results.map((emoji) =>
|
||||
'shortcode' in emoji
|
||||
? { id: emoji.shortcode, custom: true }
|
||||
: {
|
||||
id: emoji.label.replaceAll(' ', '_').toLowerCase(),
|
||||
native: emoji.unicode,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function usePickerEmojis() {
|
||||
const [, setLoaded] = useState(customEmojis !== null);
|
||||
|
||||
|
||||
@@ -419,9 +419,7 @@ const InteractionModal: React.FC<{
|
||||
}> = ({ accountId, url, intent }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const signupUrl = useAppSelector(
|
||||
(state) =>
|
||||
(state.server.getIn(['server', 'registrations', 'url'], null) ||
|
||||
'/auth/sign_up') as string,
|
||||
(state) => state.server.server.item?.registrations.url ?? '/auth/sign_up',
|
||||
);
|
||||
const account = useAppSelector((state) => state.accounts.get(accountId));
|
||||
const name = <DisplayName account={account} variant='simple' />;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useEffect, useState, useCallback, useId } from 'react';
|
||||
|
||||
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import { isFulfilled } from '@reduxjs/toolkit';
|
||||
|
||||
import { Toggle } from '@/mastodon/components/form_fields';
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
|
||||
import { fetchLists } from 'mastodon/actions/lists';
|
||||
@@ -15,7 +16,6 @@ import {
|
||||
} from 'mastodon/api/lists';
|
||||
import type { ApiListJSON } from 'mastodon/api_types/lists';
|
||||
import { Button } from 'mastodon/components/button';
|
||||
import { CheckBox } from 'mastodon/components/check_box';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import { getOrderedLists } from 'mastodon/selectors/lists';
|
||||
@@ -42,6 +42,8 @@ const ListItem: React.FC<{
|
||||
checked: boolean;
|
||||
onChange: (id: string, checked: boolean) => void;
|
||||
}> = ({ id, title, checked, onChange }) => {
|
||||
const uniqueId = useId();
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(id, e.target.checked);
|
||||
@@ -50,14 +52,13 @@ const ListItem: React.FC<{
|
||||
);
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/label-has-associated-control
|
||||
<label className='lists__item'>
|
||||
<label className='lists__item' htmlFor={uniqueId}>
|
||||
<div className='lists__item__title'>
|
||||
<Icon id='list-ul' icon={ListAltIcon} />
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
|
||||
<CheckBox value={id} checked={checked} onChange={handleChange} />
|
||||
<Toggle id={uniqueId} checked={checked} onChange={handleChange} />
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -20,10 +20,7 @@ export const SignInBanner: React.FC = () => {
|
||||
let signupButton: React.ReactNode;
|
||||
|
||||
const signupUrl = useAppSelector(
|
||||
(state) =>
|
||||
(state.server.getIn(['server', 'registrations', 'url'], null) as
|
||||
| string
|
||||
| null) ?? '/auth/sign_up',
|
||||
(state) => state.server.server.item?.registrations.url ?? '/auth/sign_up',
|
||||
);
|
||||
|
||||
if (sso_redirect) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import type { List } from 'immutable';
|
||||
import type { List, Map } from 'immutable';
|
||||
|
||||
import { EmojiHTML } from '@/mastodon/components/emoji/html';
|
||||
import { useElementHandledLink } from '@/mastodon/components/status/handled_link';
|
||||
@@ -23,8 +23,19 @@ export const EmbeddedStatusContent: React.FC<{
|
||||
},
|
||||
[mentions],
|
||||
);
|
||||
const hrefToCollection = useCallback(
|
||||
(href: string) => {
|
||||
const collections = status.get('tagged_collections') as List<
|
||||
Map<'url' | 'id', string>
|
||||
>;
|
||||
const collection = collections.find((item) => item.get('url') === href);
|
||||
return collection?.get('id');
|
||||
},
|
||||
[status],
|
||||
);
|
||||
const htmlHandlers = useElementHandledLink({
|
||||
hashtagAccountId: status.get('account') as string | undefined,
|
||||
hrefToCollectionId: hrefToCollection,
|
||||
hrefToMention,
|
||||
});
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ export const MODAL_COMPONENTS = {
|
||||
'DOMAIN_BLOCK': DomainBlockModal,
|
||||
'REPORT': ReportModal,
|
||||
'REPORT_COLLECTION': ReportCollectionModal,
|
||||
'COLLECTION_ADDER': () => import('@/mastodon/features/collection_adder').then(module => ({ default: module.CollectionAdder })),
|
||||
'SHARE_COLLECTION': () => import('@/mastodon/features/collections/components/share_modal').then(module => ({ default: module.CollectionShareModal })),
|
||||
'REVOKE_COLLECTION_INCLUSION': () => import('@/mastodon/features/collections/detail/revoke_collection_inclusion_modal').then(module => ({ default: module.RevokeCollectionInclusionModal })),
|
||||
'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
|
||||
|
||||
@@ -84,10 +84,7 @@ const NotificationsButton = () => {
|
||||
const LoginOrSignUp: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const signupUrl = useAppSelector(
|
||||
(state) =>
|
||||
(state.server.getIn(['server', 'registrations', 'url'], null) as
|
||||
| string
|
||||
| null) ?? '/auth/sign_up',
|
||||
(state) => state.server.server.item?.registrations.url ?? '/auth/sign_up',
|
||||
);
|
||||
|
||||
const openClosedRegistrationsModal = useCallback(() => {
|
||||
@@ -95,7 +92,7 @@ const LoginOrSignUp: React.FC = () => {
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchServer());
|
||||
void dispatch(fetchServer());
|
||||
}, [dispatch]);
|
||||
|
||||
if (sso_redirect) {
|
||||
|
||||
@@ -52,7 +52,7 @@ export const ReportCollectionModal: React.FC<{
|
||||
const account = useAccount(account_id);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchServer());
|
||||
void dispatch(fetchServer());
|
||||
}, [dispatch]);
|
||||
|
||||
const [submitState, setSubmitState] = useState<
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { ExtraCustomEmojiMap } from '../features/emoji/types';
|
||||
import { emojiLogger } from '../features/emoji/utils';
|
||||
|
||||
let emojis: ExtraCustomEmojiMap | null = null;
|
||||
|
||||
const log = emojiLogger('useCustomEmojis');
|
||||
|
||||
export function useCustomEmojis() {
|
||||
const [, setLoaded] = useState(emojis !== null);
|
||||
useEffect(() => {
|
||||
@@ -21,6 +24,7 @@ async function loadEmojisIntoCache() {
|
||||
const { loadAllCustomEmoji } = await import('../features/emoji/database');
|
||||
const emojisRaw = await loadAllCustomEmoji();
|
||||
if (emojisRaw === null) {
|
||||
log('Custom emojis not loaded yet');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -32,4 +36,5 @@ async function loadEmojisIntoCache() {
|
||||
static_url: emoji.static_url,
|
||||
};
|
||||
}
|
||||
log('Loaded %d custom emojis into cache', Object.keys(emojis).length);
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
"account.locked_info": "Гэты ўліковы запіс пазначаны як схаваны. Уладальнік сам вырашае, хто можа падпісвацца на яго.",
|
||||
"account.media": "Медыя",
|
||||
"account.mention": "Згадаць @{name}",
|
||||
"account.menu.add_to_collection": "Дадаць у калекцыю…",
|
||||
"account.menu.add_to_list": "Дадаць у спіс…",
|
||||
"account.menu.block": "Заблакіраваць профіль",
|
||||
"account.menu.block_domain": "Заблакіраваць {domain}",
|
||||
@@ -372,6 +373,7 @@
|
||||
"collections.accounts.empty_description": "Дадайце да {count} уліковых запісаў",
|
||||
"collections.accounts.empty_editor_title": "У гэтай калекцыі пакуль нікога няма",
|
||||
"collections.accounts.empty_title": "Гэтая калекцыя пустая",
|
||||
"collections.add_to_collection": "Дадаць {name} у калекцыі",
|
||||
"collections.block_collection_owner": "Заблакіраваць профіль",
|
||||
"collections.by_account": "ад {account_handle}",
|
||||
"collections.collection_description": "Апісанне",
|
||||
@@ -394,7 +396,7 @@
|
||||
"collections.detail.loading": "Загружаецца калекцыя…",
|
||||
"collections.detail.revoke_inclusion": "Прыбраць сябе",
|
||||
"collections.detail.sensitive_content": "Адчувальнае змесціва",
|
||||
"collections.detail.sensitive_note": "У гэтай калекцыі прысутнічаюць уліковыя запісы і кантэнт, змесціва якіх можа падацца адчувальным для некаторых карыстальнікаў.",
|
||||
"collections.detail.sensitive_note": "Апісанне і ўліковыя запісы могуць не пасаваць усім гледачам.",
|
||||
"collections.detail.share": "Падзяліцца гэтай калекцыяй",
|
||||
"collections.detail.you_are_in_this_collection": "Вас уключылі ў гэтую калекцыю",
|
||||
"collections.edit_details": "Рэдагаваць падрабязнасці",
|
||||
@@ -425,6 +427,11 @@
|
||||
"collections.search_accounts_max_reached": "Вы дадалі максімальную колькасць уліковых запісаў",
|
||||
"collections.sensitive": "Адчувальная",
|
||||
"collections.share_short": "Абагуліць",
|
||||
"collections.sort_alphabetical": "Алфавіце",
|
||||
"collections.sort_by": "Сартаваць па:",
|
||||
"collections.sort_date_added": "Даце дадавання",
|
||||
"collections.sort_last_active": "Апошняй актыўнасці",
|
||||
"collections.sort_most_followers": "Колькасці падпісчыкаў",
|
||||
"collections.suggestions.can_not_add": "Немагчыма дадаць",
|
||||
"collections.suggestions.can_not_add_desc": "Магчыма, гэтыя ўліковыя запісы схаваныя ад рэкамендацый або знаходзяцца на серверы, які не падтрымлівае калекцыі.",
|
||||
"collections.suggestions.must_follow": "Патрабуецца падпіска",
|
||||
@@ -636,6 +643,7 @@
|
||||
"empty_column.blocks": "Вы яшчэ нікога не заблакіравалі.",
|
||||
"empty_column.bookmarked_statuses": "У Вашых закладках яшчэ няма допісаў. Калі Вы дадасце закладку, яна з’явіцца тут.",
|
||||
"empty_column.collections.featured_in": "Вас пакуль не дадалі ў ніякія калекцыі.",
|
||||
"empty_column.collections.featured_in_undiscoverable": "Каб людзі маглі дадаваць Вас у калекцыі, Вам трэба даць ім дазвол знаходзіць Вас у <link>Налады > Прыватнасць і пошук</link>",
|
||||
"empty_column.community": "Мясцовая стужка пустая. Напішыце нешта публічнае, каб разварушыць справу!",
|
||||
"empty_column.direct": "Пакуль у Вас няма асабістых згадванняў. Калі Вы дашляце або атрымаеце штосьці, яно з’явіцца тут.",
|
||||
"empty_column.disabled_feed": "Гэта стужка была адключаная Вашымі адміністратарамі сервера.",
|
||||
|
||||
@@ -327,11 +327,15 @@
|
||||
"annual_report.summary.share_on_mastodon": "Rhannwch ar Mastodon",
|
||||
"attachments_list.unprocessed": "(heb eu prosesu)",
|
||||
"audio.hide": "Cuddio sain",
|
||||
"block_modal.no_collections": "Does dim modd i'r un ohonoch ychwanegu'ch gilydd at gasgliadau. Byddwch yn cael eich tynnu'n awtomatig o gasgliadau presennol eich gilydd, os yw'n berthnasol.",
|
||||
"block_modal.remote_users_caveat": "Byddwn yn gofyn i'r gweinydd {domain} barchu eich penderfyniad. Fodd bynnag, nid yw cydymffurfiad wedi'i warantu gan y gall rhai gweinyddwyr drin rhwystrau mewn ffyrdd gwahanol. Mae'n bosibl y bydd postiadau cyhoeddus yn dal i fod yn weladwy i ddefnyddwyr nad ydynt wedi mewngofnodi.",
|
||||
"block_modal.show_less": "Dangos llai",
|
||||
"block_modal.show_more": "Dangos rhagor",
|
||||
"block_modal.they_cant_mention": "Gallwch chi ddim sôn am, dilyn, na dyfynnu eich gilydd.",
|
||||
"block_modal.they_cant_see_posts": "Byddan nhw ddim yn gallu gweld eich cynnwys a fyddwch chi ddim yn gweld eu cynnwys nhw.",
|
||||
"block_modal.they_will_know": "Gallan nhw weld eu bod wedi'u rhwystro.",
|
||||
"block_modal.title": "Blocio defnyddiwr?",
|
||||
"block_modal.you_wont_see_mentions": "Fyddwch chi ddim yn gweld postiadau gan eraill sy'n eu crybwyll.",
|
||||
"boost_modal.combo": "Mae modd pwyso {combo} er mwyn hepgor hyn tro nesa",
|
||||
"boost_modal.reblog": "Hybu postiad?",
|
||||
"boost_modal.undo_reblog": "Dad-hybu postiad?",
|
||||
@@ -390,7 +394,7 @@
|
||||
"collections.detail.loading": "Yn llwytho casgliad…",
|
||||
"collections.detail.revoke_inclusion": "Tynnu fi",
|
||||
"collections.detail.sensitive_content": "Cynnwys sensitif",
|
||||
"collections.detail.sensitive_note": "Mae'r casgliad hwn yn cynnwys cyfrifon a chynnwys a allai fod yn sensitif i rai defnyddwyr.",
|
||||
"collections.detail.sensitive_note": "Efallai na fydd y disgrifiad a'r cyfrifon yn addas i bob darllenydd.",
|
||||
"collections.detail.share": "Rhannu'r casgliad hwn",
|
||||
"collections.detail.you_are_in_this_collection": "Rydych chi wedi'ch cynnwys yn y casgliad hwn",
|
||||
"collections.edit_details": "Golygu manylion",
|
||||
@@ -421,6 +425,11 @@
|
||||
"collections.search_accounts_max_reached": "Rydych chi wedi ychwanegu'r nifer mwyaf o gyfrifon",
|
||||
"collections.sensitive": "Sensitif",
|
||||
"collections.share_short": "Rhannu",
|
||||
"collections.sort_alphabetical": "Yn nhrefn yr Wyddor",
|
||||
"collections.sort_by": "Trefnu yn ôl:",
|
||||
"collections.sort_date_added": "Dyddiad ychwanegu",
|
||||
"collections.sort_last_active": "Yn weithgar ddiwethaf",
|
||||
"collections.sort_most_followers": "Mwyaf o ddilynwyr",
|
||||
"collections.suggestions.can_not_add": "Dim modd ei ychwanegu",
|
||||
"collections.suggestions.can_not_add_desc": "Efallai bod y cyfrifon hyn wedi dewis peidio â chael eu darganfod, neu efallai eu bod ar weinydd nad yw'n cefnogi casgliadau.",
|
||||
"collections.suggestions.must_follow": "Rhaid dilyn yn gyntaf",
|
||||
@@ -632,6 +641,7 @@
|
||||
"empty_column.blocks": "Dydych chi heb rwystro unrhyw ddefnyddwyr eto.",
|
||||
"empty_column.bookmarked_statuses": "Does gennych chi ddim unrhyw bostiad wedi'u cadw fel nod tudalen eto. Pan fyddwch yn gosod nod tudalen i un, mi fydd yn ymddangos yma.",
|
||||
"empty_column.collections.featured_in": "Dydych chi heb gael eich ychwanegu at unrhyw gasgliadau eto.",
|
||||
"empty_column.collections.featured_in_undiscoverable": "Er mwyn i bobl eich ychwanegu at gasgliadau, mae angen i chi ganiatáu i chi ymddangos mewn profiadau darganfod o<link> Dewisiadau > Preifatrwydd a chyrhaeddiad</link>",
|
||||
"empty_column.community": "Mae'r ffrwd lleol yn wag. Beth am ysgrifennu rhywbeth cyhoeddus!",
|
||||
"empty_column.direct": "Does gennych chi unrhyw grybwylliadau preifat eto. Pan fyddwch chi'n anfon neu'n derbyn un, bydd yn ymddangos yma.",
|
||||
"empty_column.disabled_feed": "Mae'r ffrwd hon wedi'i hanalluogi gan weinyddwyr eich gweinydd.",
|
||||
@@ -777,6 +787,7 @@
|
||||
"info_button.label": "Cymorth",
|
||||
"info_button.what_is_alt_text": "<h1>Beth yw testun amgen?</h1><p> Mae Testun Amgen yn darparu disgrifiadau delwedd ar gyfer pobl â nam ar eu golwg, cysylltiadau lled band isel, neu'r rhai sy'n ceisio cyd-destun ychwanegol.</p><p> Gallwch wella hygyrchedd a dealltwriaeth i bawb trwy ysgrifennu testun amgen clir, cryno a gwrthrychol.</p><ul><li> Dal elfennau pwysig</li><li> Crynhoi testun mewn delweddau</li><li> Defnyddiwch strwythur brawddegau rheolaidd</li><li> Osgoi gwybodaeth ddiangen</li><li> Canolbwyntio ar dueddiadau a chanfyddiadau allweddol mewn delweddau cymhleth (fel diagramau neu fapiau)</li></ul>",
|
||||
"interaction_modal.action": "I ryngweithio â phostiad {name}, mae angen i chi fewngofnodi i'ch cyfrif ar ba bynnag weinydd Mastodon rydych chi'n ei ddefnyddio.",
|
||||
"interaction_modal.action_follow": "I ddilyn {name}, mae angen i chi fewngofnodi i'ch cyfrif ar ba bynnag weinydd Mastodon rydych chi'n ei ddefnyddio.",
|
||||
"interaction_modal.go": "Mynd",
|
||||
"interaction_modal.no_account_yet": "Dim cyfrif eto?",
|
||||
"interaction_modal.on_another_server": "Ar weinydd gwahanol",
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
"account.locked_info": "Denne kontos fortrolighedsstatus er sat til låst. Ejeren bedømmer manuelt, hvem der kan følge vedkommende.",
|
||||
"account.media": "Medier",
|
||||
"account.mention": "Nævn @{name}",
|
||||
"account.menu.add_to_collection": "Føj til samling…",
|
||||
"account.menu.add_to_list": "Føj til liste…",
|
||||
"account.menu.block": "Blokér konto",
|
||||
"account.menu.block_domain": "Blokér {domain}",
|
||||
@@ -372,6 +373,7 @@
|
||||
"collections.accounts.empty_description": "Tilføj op til {count} konti",
|
||||
"collections.accounts.empty_editor_title": "Ingen er i denne samling endnu",
|
||||
"collections.accounts.empty_title": "Denne samling er tom",
|
||||
"collections.add_to_collection": "Tilføj {name} til samlinger",
|
||||
"collections.block_collection_owner": "Blokér konto",
|
||||
"collections.by_account": "af {account_handle}",
|
||||
"collections.collection_description": "Beskrivelse",
|
||||
@@ -394,7 +396,7 @@
|
||||
"collections.detail.loading": "Indlæser samling…",
|
||||
"collections.detail.revoke_inclusion": "Fjern mig",
|
||||
"collections.detail.sensitive_content": "Følsomt indhold",
|
||||
"collections.detail.sensitive_note": "Denne samling indeholder konti og indhold, der kan være følsomt for nogle brugere.",
|
||||
"collections.detail.sensitive_note": "Beskrivelsen og kontiene er muligvis ikke egnet for alle.",
|
||||
"collections.detail.share": "Del denne samling",
|
||||
"collections.detail.you_are_in_this_collection": "Du er med i denne samling",
|
||||
"collections.edit_details": "Rediger detaljer",
|
||||
@@ -425,6 +427,11 @@
|
||||
"collections.search_accounts_max_reached": "Du har tilføjet det maksimale antal konti",
|
||||
"collections.sensitive": "Sensitivt",
|
||||
"collections.share_short": "Del",
|
||||
"collections.sort_alphabetical": "Alfabetisk",
|
||||
"collections.sort_by": "Sortér efter:",
|
||||
"collections.sort_date_added": "Dato tilføjet",
|
||||
"collections.sort_last_active": "Senest aktiv",
|
||||
"collections.sort_most_followers": "Flest følgere",
|
||||
"collections.suggestions.can_not_add": "Kan ikke tilføjes",
|
||||
"collections.suggestions.can_not_add_desc": "Disse konti kan have fravalgt opdagelse, eller de kan være på en server, der ikke understøtter samlinger.",
|
||||
"collections.suggestions.must_follow": "Skal følge først",
|
||||
@@ -636,6 +643,7 @@
|
||||
"empty_column.blocks": "Du har ikke blokeret nogle brugere endnu.",
|
||||
"empty_column.bookmarked_statuses": "Du har ingen bogmærkede indlæg endnu. Når du bogmærker ét, vil det dukke op hér.",
|
||||
"empty_column.collections.featured_in": "Du er ikke blevet tilføjet til nogen samlinger endnu.",
|
||||
"empty_column.collections.featured_in_undiscoverable": "For at andre kan føje dig til samlinger, skal du give tilladelse til at blive vist i opdagelsesfunktioner under <link>Præferencer > Fortrolighed og rækkevidde</link>",
|
||||
"empty_column.community": "Den lokale tidslinje er tom. Skriv noget offentligt for at sætte tingene i gang!",
|
||||
"empty_column.direct": "Du har ikke nogen private omtaler endnu. Når du sender eller modtager en, vil den blive vist her.",
|
||||
"empty_column.disabled_feed": "Dette feed er blevet deaktiveret af dine serveradministratorer.",
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
"account.locked_info": "Die Privatsphäre dieses Kontos wurde auf „geschützt“ gesetzt. Die Person bestimmt manuell, wer ihrem Profil folgen darf.",
|
||||
"account.media": "Medien",
|
||||
"account.mention": "@{name} erwähnen",
|
||||
"account.menu.add_to_collection": "Zur Sammlung hinzufügen …",
|
||||
"account.menu.add_to_list": "Einer Liste hinzufügen …",
|
||||
"account.menu.block": "Konto blockieren",
|
||||
"account.menu.block_domain": "{domain} blockieren",
|
||||
@@ -372,6 +373,7 @@
|
||||
"collections.accounts.empty_description": "Füge bis zu {count} Konten hinzu",
|
||||
"collections.accounts.empty_editor_title": "Noch befindet sich niemand in dieser Sammlung",
|
||||
"collections.accounts.empty_title": "Diese Sammlung ist leer",
|
||||
"collections.add_to_collection": "{name} zur Sammlung hinzufügen",
|
||||
"collections.block_collection_owner": "Konto blockieren",
|
||||
"collections.by_account": "von {account_handle}",
|
||||
"collections.collection_description": "Beschreibung",
|
||||
@@ -394,7 +396,7 @@
|
||||
"collections.detail.loading": "Sammlung wird geladen …",
|
||||
"collections.detail.revoke_inclusion": "Mich entfernen",
|
||||
"collections.detail.sensitive_content": "Inhaltswarnung",
|
||||
"collections.detail.sensitive_note": "Diese Sammlung enthält Profile und Inhalte, die manche als anstößig empfinden.",
|
||||
"collections.detail.sensitive_note": "Die Beschreibung und Konten sind möglicherweise nicht für alle geeignet.",
|
||||
"collections.detail.share": "Sammlung teilen",
|
||||
"collections.detail.you_are_in_this_collection": "Du bist ein Teil dieser Sammlung",
|
||||
"collections.edit_details": "Details bearbeiten",
|
||||
@@ -425,6 +427,11 @@
|
||||
"collections.search_accounts_max_reached": "Du hast die Höchstzahl an Konten hinzugefügt",
|
||||
"collections.sensitive": "Inhaltswarnung",
|
||||
"collections.share_short": "Teilen",
|
||||
"collections.sort_alphabetical": "Alphabetisch",
|
||||
"collections.sort_by": "Sortieren nach:",
|
||||
"collections.sort_date_added": "Datum des Hinzufügens",
|
||||
"collections.sort_last_active": "Neueste Aktivität",
|
||||
"collections.sort_most_followers": "Followerzahl",
|
||||
"collections.suggestions.can_not_add": "Kann nicht hinzugefügt werden",
|
||||
"collections.suggestions.can_not_add_desc": "Diese Konten möchten möglicherweise nicht entdeckt werden oder deren Server unterstützt noch keine Sammlungen.",
|
||||
"collections.suggestions.must_follow": "Profil muss gefolgt werden",
|
||||
@@ -636,6 +643,7 @@
|
||||
"empty_column.blocks": "Du hast bisher keine Profile blockiert.",
|
||||
"empty_column.bookmarked_statuses": "Du hast bisher keine Beiträge als Lesezeichen abgelegt. Sobald du einen Beitrag als Lesezeichen speicherst, wird er hier erscheinen.",
|
||||
"empty_column.collections.featured_in": "Du wurdest noch keiner Sammlung hinzugefügt.",
|
||||
"empty_column.collections.featured_in_undiscoverable": "Damit du zu Sammlungen hinzugefügt werden kannst, muss „Mich beim Entdecken berücksichtigen“ unter <link>Einstellungen > Datenschutz und Reichweite</link> aktiviert werden",
|
||||
"empty_column.community": "Die lokale Timeline ist leer. Schreibe einen öffentlichen Beitrag, um den Stein ins Rollen zu bringen!",
|
||||
"empty_column.direct": "Du hast noch keine privaten Erwähnungen. Sobald du eine sendest oder erhältst, wird sie hier erscheinen.",
|
||||
"empty_column.disabled_feed": "Diesen Feed haben deine Server-Administrator*innen deaktiviert.",
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
"account.locked_info": "Η κατάσταση απορρήτου αυτού του λογαριασμού έχει ρυθμιστεί σε κλειδωμένη. Ο ιδιοκτήτης ελέγχει χειροκίνητα ποιος μπορεί να τον ακολουθήσει.",
|
||||
"account.media": "Πολυμέσα",
|
||||
"account.mention": "Επισήμανση @{name}",
|
||||
"account.menu.add_to_collection": "Προσθήκη σε συλλογή…",
|
||||
"account.menu.add_to_list": "Προσθήκη στη λίστα…",
|
||||
"account.menu.block": "Αποκλεισμός λογαριασμού",
|
||||
"account.menu.block_domain": "Αποκλεισμός {domain}",
|
||||
@@ -372,6 +373,7 @@
|
||||
"collections.accounts.empty_description": "Προσθέστε μέχρι και {count} λογαριασμούς",
|
||||
"collections.accounts.empty_editor_title": "Κανείς δεν είναι ακόμη σε αυτήν τη συλλογή",
|
||||
"collections.accounts.empty_title": "Αυτή η συλλογή είναι κενή",
|
||||
"collections.add_to_collection": "Προσθήκη {name} σε συλλογές",
|
||||
"collections.block_collection_owner": "Αποκλεισμός λογαριασμού",
|
||||
"collections.by_account": "από {account_handle}",
|
||||
"collections.collection_description": "Περιγραφή",
|
||||
@@ -394,7 +396,7 @@
|
||||
"collections.detail.loading": "Γίνεται φόρτωση της συλλογής…",
|
||||
"collections.detail.revoke_inclusion": "Αφαίρεσε με",
|
||||
"collections.detail.sensitive_content": "Ευαίσθητο περιεχόμενο",
|
||||
"collections.detail.sensitive_note": "Αυτή η συλλογή περιέχει λογαριασμούς και περιεχόμενο που μπορεί να είναι ευαίσθητα σε ορισμένους χρήστες.",
|
||||
"collections.detail.sensitive_note": "Η περιγραφή και οι λογαριασμοί μπορεί να μην είναι κατάλληλα για όλους τους θεατές.",
|
||||
"collections.detail.share": "Κοινοποιήστε αυτήν τη συλλογή",
|
||||
"collections.detail.you_are_in_this_collection": "Είστε αναδεδειγμένοι σε αυτήν τη συλλογή",
|
||||
"collections.edit_details": "Επεξεργασία λεπτομερειών",
|
||||
@@ -425,6 +427,11 @@
|
||||
"collections.search_accounts_max_reached": "Έχετε προσθέσει τον μέγιστο αριθμό λογαριασμών",
|
||||
"collections.sensitive": "Ευαίσθητο",
|
||||
"collections.share_short": "Κοινοποίηση",
|
||||
"collections.sort_alphabetical": "Αλφαβητικά",
|
||||
"collections.sort_by": "Ταξινόμηση κατά:",
|
||||
"collections.sort_date_added": "Ημερομηνία προσθήκης",
|
||||
"collections.sort_last_active": "Τελευταία ενεργός",
|
||||
"collections.sort_most_followers": "Περισσότεροι ακόλουθοι",
|
||||
"collections.suggestions.can_not_add": "Δεν μπορεί να προστεθεί",
|
||||
"collections.suggestions.can_not_add_desc": "Αυτοί οι λογαριασμοί μπορεί να έχουν εξαιρεθεί από την ανακάλυψη, ή μπορεί να είναι σε έναν διακομιστή που δεν υποστηρίζει συλλογές.",
|
||||
"collections.suggestions.must_follow": "Πρέπει να τον ακολουθήσετε πρώτα",
|
||||
@@ -636,6 +643,7 @@
|
||||
"empty_column.blocks": "Δεν έχεις αποκλείσει κανέναν χρήστη ακόμη.",
|
||||
"empty_column.bookmarked_statuses": "Δεν έχεις καμία ανάρτηση με σελιδοδείκτη ακόμη. Μόλις βάλεις κάποιον, θα εμφανιστεί εδώ.",
|
||||
"empty_column.collections.featured_in": "Δεν έχετε προστεθεί ακόμη σε καμία συλλογή.",
|
||||
"empty_column.collections.featured_in_undiscoverable": "Προκειμένου ο κόσμος να σας προσθέσει σε συλλογές, πρέπει να επιτρέψετε την ανάδειξή σας σε εμπειρίες ανακάλυψης από τις <link>Προτιμήσεις > Ιδιωτικότητα και προσιτότητα</link>",
|
||||
"empty_column.community": "Η τοπική ροή είναι κενή. Γράψε κάτι δημόσια για να αρχίσει να κυλά η μπάλα!",
|
||||
"empty_column.direct": "Δεν έχεις καμία ιδιωτική επισήμανση ακόμη. Όταν στείλεις ή λάβεις μία, θα εμφανιστεί εδώ.",
|
||||
"empty_column.disabled_feed": "Αυτή η ροή έχει απενεργοποιηθεί από τους διαχειριστές του διακομιστή σας.",
|
||||
|
||||
@@ -394,7 +394,6 @@
|
||||
"collections.detail.loading": "Loading collection…",
|
||||
"collections.detail.revoke_inclusion": "Remove me",
|
||||
"collections.detail.sensitive_content": "Sensitive content",
|
||||
"collections.detail.sensitive_note": "This collection contains accounts and content that may be sensitive to some users.",
|
||||
"collections.detail.share": "Share this collection",
|
||||
"collections.detail.you_are_in_this_collection": "You're featured in this collection",
|
||||
"collections.edit_details": "Edit details",
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
"account.locked_info": "This account privacy status is set to locked. The owner manually reviews who can follow them.",
|
||||
"account.media": "Media",
|
||||
"account.mention": "Mention @{name}",
|
||||
"account.menu.add_to_collection": "Add to collection…",
|
||||
"account.menu.add_to_list": "Add to list…",
|
||||
"account.menu.block": "Block account",
|
||||
"account.menu.block_domain": "Block {domain}",
|
||||
@@ -366,12 +367,13 @@
|
||||
"collection.share_modal.share_via_system": "Share to…",
|
||||
"collection.share_modal.title": "Share collection",
|
||||
"collection.share_modal.title_new": "Share your new collection!",
|
||||
"collection.share_template_other": "Check out this cool collection: {link}",
|
||||
"collection.share_template_own": "Check out my new collection: {link}",
|
||||
"collection.share_template_other": "Check out this cool collection:",
|
||||
"collection.share_template_own": "Check out my new collection:",
|
||||
"collections.account_count": "{count, plural, one {# account} other {# accounts}}",
|
||||
"collections.accounts.empty_description": "Add up to {count} accounts",
|
||||
"collections.accounts.empty_editor_title": "No one is in this collection yet",
|
||||
"collections.accounts.empty_title": "This collection is empty",
|
||||
"collections.add_to_collection": "Add {name} to collections",
|
||||
"collections.block_collection_owner": "Block account",
|
||||
"collections.by_account": "by {account_handle}",
|
||||
"collections.collection_description": "Description",
|
||||
@@ -394,7 +396,7 @@
|
||||
"collections.detail.loading": "Loading collection…",
|
||||
"collections.detail.revoke_inclusion": "Remove me",
|
||||
"collections.detail.sensitive_content": "Sensitive content",
|
||||
"collections.detail.sensitive_note": "This collection contains accounts and content that may be sensitive to some users.",
|
||||
"collections.detail.sensitive_note": "The description and accounts may not be suitable for all viewers.",
|
||||
"collections.detail.share": "Share this collection",
|
||||
"collections.detail.you_are_in_this_collection": "You're featured in this collection",
|
||||
"collections.edit_details": "Edit details",
|
||||
@@ -425,6 +427,11 @@
|
||||
"collections.search_accounts_max_reached": "You have added the maximum number of accounts",
|
||||
"collections.sensitive": "Sensitive",
|
||||
"collections.share_short": "Share",
|
||||
"collections.sort_alphabetical": "Alphabetical",
|
||||
"collections.sort_by": "Sort by:",
|
||||
"collections.sort_date_added": "Date added",
|
||||
"collections.sort_last_active": "Last active",
|
||||
"collections.sort_most_followers": "Most followers",
|
||||
"collections.suggestions.can_not_add": "Can’t be added",
|
||||
"collections.suggestions.can_not_add_desc": "These accounts may have opted out of discovery, or they might be on a server that doesn’t support collections.",
|
||||
"collections.suggestions.must_follow": "Must follow first",
|
||||
@@ -636,6 +643,7 @@
|
||||
"empty_column.blocks": "You haven't blocked any users yet.",
|
||||
"empty_column.bookmarked_statuses": "You don't have any bookmarked posts yet. When you bookmark one, it will show up here.",
|
||||
"empty_column.collections.featured_in": "You have not been added to any collections yet.",
|
||||
"empty_column.collections.featured_in_undiscoverable": "In order for people to add you to collections, you need to allow featuring in discovery experiences from <link>Preferences > Privacy and reach</link>",
|
||||
"empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!",
|
||||
"empty_column.direct": "You don't have any private mentions yet. When you send or receive one, it will show up here.",
|
||||
"empty_column.disabled_feed": "This feed has been disabled by your server administrators.",
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
"account.locked_info": "Esta cuenta es privada. El propietario manualmente revisa quién puede seguirle.",
|
||||
"account.media": "Medios",
|
||||
"account.mention": "Mencionar a @{name}",
|
||||
"account.menu.add_to_collection": "Agregar a la colección…",
|
||||
"account.menu.add_to_list": "Añadir a lista…",
|
||||
"account.menu.block": "Bloquear cuenta",
|
||||
"account.menu.block_domain": "Bloquear a {domain}",
|
||||
@@ -372,6 +373,7 @@
|
||||
"collections.accounts.empty_description": "Agregá hasta {count} cuentas",
|
||||
"collections.accounts.empty_editor_title": "Todavía no hay nadie en esta colección",
|
||||
"collections.accounts.empty_title": "Esta colección está vacía",
|
||||
"collections.add_to_collection": "Agregar {name} a las colecciones",
|
||||
"collections.block_collection_owner": "Bloquear cuenta",
|
||||
"collections.by_account": "por {account_handle}",
|
||||
"collections.collection_description": "Descripción",
|
||||
@@ -394,7 +396,7 @@
|
||||
"collections.detail.loading": "Cargando colección…",
|
||||
"collections.detail.revoke_inclusion": "Quitarme",
|
||||
"collections.detail.sensitive_content": "Contenido sensible",
|
||||
"collections.detail.sensitive_note": "Esta colección contiene cuentas y contenido que pueden ser sensibles a algunos usuarios.",
|
||||
"collections.detail.sensitive_note": "La descripción y las cuentas pueden no ser adecuadas para todos los espectadores.",
|
||||
"collections.detail.share": "Compartir esta colección",
|
||||
"collections.detail.you_are_in_this_collection": "Te destacaron en esta colección",
|
||||
"collections.edit_details": "Editar detalles",
|
||||
@@ -425,6 +427,11 @@
|
||||
"collections.search_accounts_max_reached": "Agregaste el número máximo de cuentas",
|
||||
"collections.sensitive": "Sensible",
|
||||
"collections.share_short": "Compartir",
|
||||
"collections.sort_alphabetical": "Alfabéticamente",
|
||||
"collections.sort_by": "Ordenar por:",
|
||||
"collections.sort_date_added": "Fecha de agregado",
|
||||
"collections.sort_last_active": "Última actividad",
|
||||
"collections.sort_most_followers": "Más seguidores",
|
||||
"collections.suggestions.can_not_add": "No se puede agregar",
|
||||
"collections.suggestions.can_not_add_desc": "Estas cuentas pueden haber optado por no ser descubiertas, o podrían estar en un servidor que no soporta colecciones.",
|
||||
"collections.suggestions.must_follow": "Deben seguirse primero",
|
||||
@@ -636,6 +643,7 @@
|
||||
"empty_column.blocks": "Todavía no bloqueaste a ningún usuario.",
|
||||
"empty_column.bookmarked_statuses": "Todavía no tenés mensajes guardados en \"Marcadores\". Cuando guardés uno en \"Marcadores\", se mostrará acá.",
|
||||
"empty_column.collections.featured_in": "Todavía no te agregaron a ninguna colección.",
|
||||
"empty_column.collections.featured_in_undiscoverable": "Para que la gente pueda agregarte a colecciones, tenés que permitir que te destacen en las experiencias de descubrimiento en <link>Configuración > Privacidad y alcance</link>",
|
||||
"empty_column.community": "La línea temporal local está vacía. ¡Escribí algo en modo público para que se empiece a correr la bola!",
|
||||
"empty_column.direct": "Todavía no tenés ninguna mención privada. Cuando enviés o recibás una, se mostrará acá.",
|
||||
"empty_column.disabled_feed": "Esta línea temporal fue deshabilitada por los administradores de tu servidor.",
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
"account.locked_info": "El estado de privacidad de esta cuenta está configurado como bloqueado. El propietario revisa manualmente quién puede seguirlo.",
|
||||
"account.media": "Multimedia",
|
||||
"account.mention": "Mencionar a @{name}",
|
||||
"account.menu.add_to_collection": "Añadir a colección…",
|
||||
"account.menu.add_to_list": "Añadir a lista…",
|
||||
"account.menu.block": "Bloquear cuenta",
|
||||
"account.menu.block_domain": "Bloquear {domain}",
|
||||
@@ -372,6 +373,7 @@
|
||||
"collections.accounts.empty_description": "Añade hasta {count} cuentas",
|
||||
"collections.accounts.empty_editor_title": "No hay nadie en esta colección todavía",
|
||||
"collections.accounts.empty_title": "Esta colección está vacía",
|
||||
"collections.add_to_collection": "Añadir a {name} a colecciones",
|
||||
"collections.block_collection_owner": "Bloquer cuenta",
|
||||
"collections.by_account": "de {account_handle}",
|
||||
"collections.collection_description": "Descripción",
|
||||
@@ -394,7 +396,7 @@
|
||||
"collections.detail.loading": "Cargando colección…",
|
||||
"collections.detail.revoke_inclusion": "Excluirme",
|
||||
"collections.detail.sensitive_content": "Contenido sensible",
|
||||
"collections.detail.sensitive_note": "Esta colección contiene cuentas y contenido que pueden resultar sensibles para algunos usuarios.",
|
||||
"collections.detail.sensitive_note": "Es posible que la descripción y las cuentas no sean aptas para todos las personas.",
|
||||
"collections.detail.share": "Compartir esta colección",
|
||||
"collections.detail.you_are_in_this_collection": "Apareces en esta colección",
|
||||
"collections.edit_details": "Editar detalles",
|
||||
@@ -425,6 +427,11 @@
|
||||
"collections.search_accounts_max_reached": "Has añadido el número máximo de cuentas",
|
||||
"collections.sensitive": "Sensible",
|
||||
"collections.share_short": "Compartir",
|
||||
"collections.sort_alphabetical": "Alfabético",
|
||||
"collections.sort_by": "Orden:",
|
||||
"collections.sort_date_added": "Añadido a fecha",
|
||||
"collections.sort_last_active": "Última actividad",
|
||||
"collections.sort_most_followers": "Más seguidores",
|
||||
"collections.suggestions.can_not_add": "No puede ser añadida",
|
||||
"collections.suggestions.can_not_add_desc": "Es posible que estas cuentas hayan optado por no ser descubiertas, o que se encuentren en un servidor que no soporta colecciones.",
|
||||
"collections.suggestions.must_follow": "Primero tienes que seguirla",
|
||||
@@ -636,6 +643,7 @@
|
||||
"empty_column.blocks": "Aún no has bloqueado a ningún usuario.",
|
||||
"empty_column.bookmarked_statuses": "Aún no tienes ninguna publicación guardada como marcador. Cuando guardes una, se mostrará aquí.",
|
||||
"empty_column.collections.featured_in": "Aún no te han añadido a ninguna colección.",
|
||||
"empty_column.collections.featured_in_undiscoverable": "Para que los usuarios puedan añadirte a sus colecciones, debes habilitar la opción de aparecer en las experiencias de descubrimiento desde <link>Preferencias > Privacidad y alcance</link>",
|
||||
"empty_column.community": "La cronología local está vacía. ¡Escribe algo públicamente para ponerla en marcha!",
|
||||
"empty_column.direct": "Aún no tienes ninguna mención privada. Cuando envíes o recibas una, aparecerá aquí.",
|
||||
"empty_column.disabled_feed": "Esta cronología fue desactivada por los administradores de tu servidor.",
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
"account.locked_info": "El estado de privacidad de esta cuenta está configurado como bloqueado. El proprietario debe revisar manualmente quien puede seguirle.",
|
||||
"account.media": "Multimedia",
|
||||
"account.mention": "Mencionar a @{name}",
|
||||
"account.menu.add_to_collection": "Añadir a colección…",
|
||||
"account.menu.add_to_list": "Añadir a lista…",
|
||||
"account.menu.block": "Bloquear cuenta",
|
||||
"account.menu.block_domain": "Bloquear {domain}",
|
||||
@@ -372,6 +373,7 @@
|
||||
"collections.accounts.empty_description": "Añade hasta {count} cuentas",
|
||||
"collections.accounts.empty_editor_title": "No hay nadie en esta colección todavía",
|
||||
"collections.accounts.empty_title": "Esta colección está vacía",
|
||||
"collections.add_to_collection": "Añadir {name} a colecciones",
|
||||
"collections.block_collection_owner": "Bloquear cuenta",
|
||||
"collections.by_account": "de {account_handle}",
|
||||
"collections.collection_description": "Descripción",
|
||||
@@ -394,7 +396,7 @@
|
||||
"collections.detail.loading": "Cargando colección…",
|
||||
"collections.detail.revoke_inclusion": "Sácame de aquí",
|
||||
"collections.detail.sensitive_content": "Contenido sensible",
|
||||
"collections.detail.sensitive_note": "Esta colección contiene cuentas y contenido que puede ser sensible para algunos usuarios.",
|
||||
"collections.detail.sensitive_note": "La descripción y cuentas pueden no ser adecuadas para todas las personas.",
|
||||
"collections.detail.share": "Compartir esta colección",
|
||||
"collections.detail.you_are_in_this_collection": "Apareces en esta colección",
|
||||
"collections.edit_details": "Editar detalles",
|
||||
@@ -425,6 +427,11 @@
|
||||
"collections.search_accounts_max_reached": "Has añadido el número máximo de cuentas",
|
||||
"collections.sensitive": "Sensible",
|
||||
"collections.share_short": "Compartir",
|
||||
"collections.sort_alphabetical": "Alfabéticamente",
|
||||
"collections.sort_by": "Ordenar por:",
|
||||
"collections.sort_date_added": "Fecha de inclusión",
|
||||
"collections.sort_last_active": "Última actividad",
|
||||
"collections.sort_most_followers": "Más seguidores",
|
||||
"collections.suggestions.can_not_add": "No puede ser añadida",
|
||||
"collections.suggestions.can_not_add_desc": "Estas cuentas pueden haber optado por no ser descubiertas, o podrían estar en un servidor que no soporta colecciones.",
|
||||
"collections.suggestions.must_follow": "Primero tienes que seguirla",
|
||||
@@ -636,6 +643,7 @@
|
||||
"empty_column.blocks": "Aún no has bloqueado a ningún usuario.",
|
||||
"empty_column.bookmarked_statuses": "Aún no tienes ninguna publicación guardada como marcador. Cuando guardes una, se mostrará aquí.",
|
||||
"empty_column.collections.featured_in": "Aún no te han añadido a ninguna colección.",
|
||||
"empty_column.collections.featured_in_undiscoverable": "Para que la gente pueda añadirte a colecciones, debes permitir ser destacado en algoritmos de descubrimiento desde <link>Preferencias > Privacidad y alcance</link>",
|
||||
"empty_column.community": "La línea de tiempo local está vacía. ¡Escribe algo para empezar la fiesta!",
|
||||
"empty_column.direct": "Aún no tienes menciones privadas. Cuando envíes o recibas una, aparecerán aquí.",
|
||||
"empty_column.disabled_feed": "Esta cronología ha sido desactivada por los administradores del servidor.",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user