Compare commits

...

56 Commits

Author SHA1 Message Date
diondiondion
15a7507a09 Use radio buttons for emoji style preference (#39126) 2026-05-21 16:55:28 +00:00
Claire
cdf721a273 Fix remote statuses with large media descriptions being rejected (#39135) 2026-05-21 15:46:10 +00:00
Echo
cafe7ea35c Use display name component for empty message (#39131) 2026-05-21 13:59:02 +00:00
David Roetzel
e18ca373eb Revert "Add partial accounts to collections endpoint (#38919)" (#39128) 2026-05-21 13:45:09 +00:00
diondiondion
e54f927149 Accessibility: Add skip link & landmark regions to settings (#39129) 2026-05-21 13:41:14 +00:00
Echo
6735902c1a Fixes collection notification urls (#39127) 2026-05-21 13:17:35 +00:00
renovate[bot]
dc3ffac4a2 Update dependency lint-staged to v17 (#38917)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-21 12:28:52 +00:00
David Roetzel
fe885d5788 Remove superfluous comment (#39123) 2026-05-21 12:10:31 +00:00
Echo
adfe7242f7 Updates vagrant to Node v24 (#39124) 2026-05-21 12:09:08 +00:00
David Roetzel
dfcfef38af Update to ruby 4.0.5 (#39099) 2026-05-21 11:45:36 +00:00
Matt Jankowski
fbc116ef90 Drop support for EOL node version 20 (#38926) 2026-05-21 07:41:52 +00:00
github-actions[bot]
6b5e18fb1d New Crowdin Translations (automated) (#39095)
Co-authored-by: GitHub Actions <noreply@github.com>
2026-05-21 07:37:39 +00:00
diondiondion
d39f7bc72f Accessibility: Add visible focus outlines to emoji/language search fields (#39120) 2026-05-21 07:24:55 +00:00
diondiondion
e68c1c824a Accessibility: Add visible focus outlines to main search & composer fields (#39111) 2026-05-21 06:41:34 +00:00
Eugen Rochko
076c8ec51e Refactor server reducer into TypeScript (#39089) 2026-05-20 14:06:38 +00:00
Claire
f5b57e8ba7 Bump version to v4.5.10 (#39104) 2026-05-20 13:20:08 +00:00
Claire
0786c1e57a Merge commit from fork 2026-05-20 14:38:24 +02:00
Claire
ec2a99341c Merge commit from fork
* Refactor `PrivateAddressCheck`

Also ensures IPv4-mapped IPv6 addresses get properly checked no matter the version of `ipaddr`.

* Add some missing IPv6 ranges from `PrivateAddressCheck`
2026-05-20 14:34:32 +02:00
diondiondion
6e7e8de343 Allow adding an account to a collection directly from the profile page (#39080) 2026-05-20 11:29:41 +00:00
diondiondion
a444a0b572 Accessibility: Add landmark elements to login/sign-up pages (#39098) 2026-05-20 11:28:37 +00:00
Claire
6f8558a6b9 Fix Request error when issuing a request which host is an IP address (#39030) 2026-05-20 09:04:50 +00:00
David Roetzel
22203f8aeb Improve collection item verification (#39096) 2026-05-20 07:55:17 +00:00
Claire
f28715d370 Fix custom emoji selection (#39088) 2026-05-20 07:23:29 +00:00
Claire
fcf012c602 Update browserslists target (#39076) 2026-05-19 16:21:18 +00:00
Echo
dee1dc41aa Include boosts to restore pinned ordering (#39084) 2026-05-19 15:15:36 +00:00
diondiondion
655de32990 Ensure quote posts have no collection previews (#39082) 2026-05-19 14:14:25 +00:00
Echo
99db6a1910 Trigger initial field flow recalculation (#39079) 2026-05-19 12:58:46 +00:00
Echo
d5a7b383fa Autosuggest emojis rendering fix (#39077) 2026-05-19 12:22:52 +00:00
Echo
34c91555ae Refactor emoji search (#39008) 2026-05-19 10:47:45 +00:00
renovate[bot]
bd77f2e86d Update dependency typescript to v6.0.3 (#39060)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-19 08:07:01 +00:00
renovate[bot]
de7282d1cd Update opentelemetry-ruby (non-major) (#39021)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-19 08:06:10 +00:00
renovate[bot]
7f5b16a6ad Update dependency @vitejs/plugin-react to v6.0.2 (#39041)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-19 07:52:11 +00:00
renovate[bot]
e3f81c7368 Update dependency @vitejs/plugin-legacy to v8.0.2 (#39039)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-19 07:52:07 +00:00
renovate[bot]
b36c121a53 Update github/codeql-action digest to 9e0d7b8 (#38981)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-19 07:18:08 +00:00
Tan, Kian-ting
b3992e62ed fix nan-tw not listed in SUPPORTED_LOCALES (#37721)
Co-authored-by: Claire <claire.github-309c@sitedethib.com>
2026-05-19 07:16:50 +00:00
Matt Jankowski
1232b55211 Use mime type symbol to set content_type for custom css response (#37845) 2026-05-19 07:16:24 +00:00
Matt Jankowski
5f33cf0b0a Extract api/v1/statuses#context to standalone controller (#38348) 2026-05-19 07:15:35 +00:00
Matt Jankowski
c3afdb760c Remove references to deleted lint config files (#39033) 2026-05-19 07:11:43 +00:00
renovate[bot]
40f5533990 Update peter-evans/create-pull-request digest to 5f6978f (#38982)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-19 07:07:26 +00:00
github-actions[bot]
eec97e387a New Crowdin Translations (automated) (#39075)
Co-authored-by: GitHub Actions <noreply@github.com>
2026-05-19 07:00:03 +00:00
renovate[bot]
eea90c205a Update DefinitelyTyped types (non-major) (#39059)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-19 06:53:52 +00:00
renovate[bot]
7592813e15 Update dependency postcss-preset-env to v11.3.0 (#39028)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-19 06:51:14 +00:00
renovate[bot]
f0204f3e62 Update dependency vite to v8.0.13 (#38985)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-19 06:50:28 +00:00
renovate[bot]
01434ad4b6 Update dependency ox to v2.14.26 (#38974)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-19 06:44:57 +00:00
diondiondion
c26003af21 Allow users to change how accounts are sorted when viewing a collection (#39073) 2026-05-18 16:48:40 +00:00
Pia B.
07a05e1edf Add batch remove for collections in reports (#39020) 2026-05-18 14:53:40 +00:00
Matt Jankowski
2402730083 Remove unused bin/retry script (#39071) 2026-05-18 14:43:55 +00:00
diondiondion
28ae61f34d Unify compact button size between variants (#39070) 2026-05-18 14:33:23 +00:00
diondiondion
dcb6dbbc86 Update content & placement of "sensitive content" warning on collection page (#39069) 2026-05-18 13:08:41 +00:00
Claire
99b72f60ad Nudge users to turn on discoverable when viewing the empty list of collections they are in (#39029)
Co-authored-by: diondiondion <mail@diondiondion.com>
2026-05-18 12:11:14 +00:00
renovate[bot]
19914e9ef6 Update dependency axios to v1.16.1 (#39031)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-18 12:10:48 +00:00
renovate[bot]
a05d2d7ee2 Update formatjs monorepo (#39013)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-18 11:32:04 +00:00
renovate[bot]
19b19ad8c2 Update dependency ws to v8.20.1 (#39018)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-18 10:31:45 +00:00
renovate[bot]
8f47470853 Update dependency aws-sdk-s3 to v1.222.0 (#39036)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-18 10:20:46 +00:00
Matt Jankowski
75024a1778 Use ruby version 4.0.4 (#39016) 2026-05-18 10:08:22 +00:00
David Roetzel
db304735bf Temporary tweak to account background refresh (#39062) 2026-05-18 10:04:28 +00:00
239 changed files with 2888 additions and 3327 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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}}'

View File

@@ -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)'

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -1 +1 @@
4.0.3
4.0.5

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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
View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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,

View File

@@ -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))

View File

@@ -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

View File

@@ -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

View File

@@ -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)',

View File

@@ -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 =

View File

@@ -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));

View File

@@ -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,
});

View 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(),
);

View File

@@ -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,

View File

@@ -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');

View File

@@ -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;
};
}

View File

@@ -317,9 +317,10 @@ function useColumnWrap() {
if (element) {
listRef.current = element;
observer.observe(element);
handleRecalculate();
}
},
[observer],
[handleRecalculate, observer],
);
return { wrapperRef: wrapperRefCallback };

View File

@@ -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(

View File

@@ -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>
);
};

View File

@@ -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>
);
}

View File

@@ -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>
);
});

View File

@@ -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,

View File

@@ -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>;
};

View File

@@ -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(() =>

View File

@@ -35,6 +35,10 @@
color: var(--color-text-secondary);
text-wrap: pretty;
}
a {
color: var(--color-text-status-links);
}
}
[data-color-scheme='dark'] .defaultImage {

View File

@@ -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 {

View File

@@ -24,7 +24,7 @@ export interface FieldStatus {
message?: string;
}
interface FieldWrapperProps {
export interface FieldWrapperProps {
label: ReactNode;
hint?: ReactNode;
required?: boolean;

View File

@@ -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}>

View File

@@ -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')}

View File

@@ -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>
</>

View File

@@ -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 = (

View File

@@ -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 };
};

View File

@@ -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}

View File

@@ -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(

View File

@@ -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>

View File

@@ -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 ?? [],

View File

@@ -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(

View File

@@ -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();

View File

@@ -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,
}),
);

View File

@@ -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(

View File

@@ -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 ?? '');

View File

@@ -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 {

View File

@@ -25,6 +25,7 @@ export function usePinnedStatusIds({
tagged,
pinned: true,
replies: true,
boosts: true,
});
const dispatch = useAppDispatch();

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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>
);
};

View 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>
);
};

View File

@@ -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>
);
};

View File

@@ -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));

View File

@@ -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='Youve 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='Youve blocked or muted {count, plural, one {this user} other {these users}}'
values={{ count: hiddenAccounts.length }}
/>
),
}}
renderListItem={renderListItem}
/>
</ItemList>
</>
);
};

View File

@@ -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 />
)}

View File

@@ -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;
}

View File

@@ -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,
}}
/>
}

View File

@@ -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 (

View File

@@ -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');

View File

@@ -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'>

View File

@@ -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)) {

View File

@@ -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);
});
});

View File

@@ -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) {

View File

@@ -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

View File

@@ -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

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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,
};

View File

@@ -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);

View File

@@ -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' />;

View File

@@ -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>
);
};

View File

@@ -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) {

View File

@@ -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,
});

View File

@@ -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 }),

View File

@@ -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) {

View File

@@ -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<

View File

@@ -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);
}

View File

@@ -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": "Гэта стужка была адключаная Вашымі адміністратарамі сервера.",

View File

@@ -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",

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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": "Αυτή η ροή έχει απενεργοποιηθεί από τους διαχειριστές του διακομιστή σας.",

View File

@@ -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",

View File

@@ -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": "Cant be added",
"collections.suggestions.can_not_add_desc": "These accounts may have opted out of discovery, or they might be on a server that doesnt 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.",

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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