Compare commits

...

98 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
Shlee
bb94f91f86 Fix accounts header banner grayscale (#39042) 2026-05-18 08:46:49 +00:00
Echo
cdf48e806d Fixes bio spacing when there aren't paragraph tags (#39055) 2026-05-18 08:45:43 +00:00
renovate[bot]
b946b8679d Update dependency strong_migrations to v2.8.0 (#39040)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-18 07:26:23 +00:00
github-actions[bot]
3294b5777f New Crowdin Translations (automated) (#39037)
Co-authored-by: GitHub Actions <noreply@github.com>
2026-05-18 07:26:12 +00:00
renovate[bot]
f095346f8f Update dependency sidekiq to v8.1.5 (#39032)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-18 07:19:53 +00:00
renovate[bot]
d70c807a76 Update dependency aws-sdk-core to v3.247.0 (#39035)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-18 07:19:28 +00:00
Nicholas La Roux
630ad9fd49 Add libheif dependencies to setup-ruby action to unbreak media_attachment_spec.rb on latest pre-release yet rolled out runner image (#39052) 2026-05-18 06:23:20 +00:00
David Roetzel
8bbde181db Use the same condition for stale refresh (#39026) 2026-05-13 14:57:25 +00:00
Matt Jankowski
13fbf00a97 Update codecov-action to v6 (#39019) 2026-05-13 14:48:51 +00:00
Matt Jankowski
0ef5dca3c8 Remove flatware config block, re-extract simplecov config to standalone file (#39017) 2026-05-13 14:46:46 +00:00
github-actions[bot]
771fdcbb9f New Crowdin Translations (automated) (#39024)
Co-authored-by: GitHub Actions <noreply@github.com>
2026-05-13 09:08:11 +00:00
Echo
758db36ec7 Refactor account header banners (#38921) 2026-05-13 08:38:17 +00:00
Pia B.
bbb3392dbe add collections icon to admin report interface (#39009) 2026-05-12 17:46:24 +00:00
Michael Stanclift
cb5c5432b3 Narrow scope of Docker build cache in Github workflows (#39014) 2026-05-12 16:16:55 +00:00
Pia B.
7c05f56fe8 Add batch actions to collections and possibility to report multiple collections (#38991) 2026-05-12 16:13:01 +00:00
Michael Stanclift
d2f640272f Prepare Dockerfile for Node 26 (#38943) 2026-05-12 15:22:38 +00:00
renovate[bot]
2f1bbe051c Update dependency sidekiq to v8.1.4 (#38953)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-12 13:20:47 +00:00
Matt Jankowski
a547dfff37 Move flatware setup into rails_helper (#38944) 2026-05-12 13:12:49 +00:00
github-actions[bot]
735a00d741 New Crowdin Translations (automated) (#39000)
Co-authored-by: GitHub Actions <noreply@github.com>
2026-05-12 07:54:35 +00:00
Pia B.
a6a8a37ae1 add raketasks to generate collections for testing (#38986) 2026-05-11 15:17:00 +00:00
Echo
82ce9367c3 Fixes line spacing in bio (#38988) 2026-05-11 11:13:37 +00:00
github-actions[bot]
e081d5936c New Crowdin Translations (automated) (#38952)
Co-authored-by: GitHub Actions <noreply@github.com>
2026-05-11 07:02:39 +00:00
renovate[bot]
c3a1e04692 Update dorny/paths-filter digest to d1c1ffe (#38886)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-11 06:47:22 +00:00
renovate[bot]
817a0a6764 Update dependency aws-sdk-s3 to v1.221.0 (#38928)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-11 06:46:53 +00:00
renovate[bot]
c45287c72d Update dependency vite to v8.0.11 (#38931)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-11 06:46:36 +00:00
renovate[bot]
2a890822e3 Update dependency hiredis-client to v0.29.0 (#38958)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-11 06:22:19 +00:00
diondiondion
2e7df27180 Make search field and tabs sticky on search results page (#38968) 2026-05-11 06:21:51 +00:00
renovate[bot]
8e03c9c1fc Update dependency devise to v5.0.4 [SECURITY] (#38969)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-11 06:21:03 +00:00
David Roetzel
b1f7c9641f Fix updating collection items when position is unknown (#38962) 2026-05-11 06:08:44 +00:00
diondiondion
e7ed8bb682 Indent collection preview cards when displayed in notifications (#38957) 2026-05-10 08:31:12 +00:00
diondiondion
dcc26c1b24 Stylelint: Allow leading underscore in CSS variable names (#38966) 2026-05-08 14:24:02 +00:00
Echo
8d6406f561 Remove legacy emojify function (#38965) 2026-05-08 14:05:31 +00:00
diondiondion
2543425e04 New profile: Fix link colors in bio and display name overflow (#38964) 2026-05-08 13:30:28 +00:00
diondiondion
cf7a092053 Improve layout and spacing of number fields (#38963) 2026-05-08 13:30:20 +00:00
diondiondion
86e4ecfa20 Add language to collection payload (#38961) 2026-05-08 09:52:09 +00:00
diondiondion
658ad9f57e Fix crash when rendering remote post with collection card (#38959) 2026-05-08 09:22:28 +00:00
diondiondion
b71333921b Fix text overflow issues in list item component (#38954) 2026-05-08 08:34:55 +00:00
Shlee
9ff094b62e Fix #38946 (#38951) 2026-05-08 06:44:28 +00:00
Claire
b2aa476abb Redirect with interstitial when trying to view a remote collection while logged out (#38941) 2026-05-07 16:04:26 +00:00
Echo
496d41cdce Fix fields not having links (#38945) 2026-05-07 15:54:19 +00:00
diondiondion
674e2685be Fix "New collection" link appearing on other accounts' profiles (#38942) 2026-05-07 14:49:31 +00:00
Claire
fcd56d6732 Fix type of interactingObject, interactionTarget and add missing QuoteAuthorization (#38940) 2026-05-07 14:47:19 +00:00
380 changed files with 5242 additions and 5637 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

@@ -14,10 +14,16 @@ runs:
shell: bash
run: |
sudo apt-get update
sudo apt-get install --no-install-recommends -y libicu-dev libidn11-dev libvips42 ${{ inputs.additional-system-dependencies }}
sudo apt-get install --no-install-recommends -y \
libicu-dev \
libidn11-dev \
libvips42 \
libheif-plugin-aomdec \
libheif-plugin-libde265 \
${{ inputs.additional-system-dependencies }}
- name: Set up Ruby
uses: ruby/setup-ruby@94e4d89d3e6c1c7599e0210d114c5ffb23f1a866 # v1
uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1
with:
ruby-version: ${{ inputs.ruby-version }}
bundler-cache: true

View File

@@ -87,8 +87,8 @@ jobs:
platforms: ${{ matrix.platform }}
provenance: false
push: ${{ inputs.push_to_images != '' }}
cache-from: ${{ inputs.cache && 'type=gha' || '' }}
cache-to: ${{ inputs.cache && 'type=gha,mode=max' || '' }}
cache-from: ${{ inputs.cache && format('type=gha,scope=build-{0}-{1}', hashFiles(inputs.file_to_build), env.PLATFORM_PAIR) || '' }}
cache-to: ${{ inputs.cache && format('type=gha,mode=max,scope=build-{0}-{1}', hashFiles(inputs.file_to_build), env.PLATFORM_PAIR) || '' }}
outputs: type=image,"name=${{ env.IMAGE_NAMES }}",push-by-digest=true,name-canonical=true,push=${{ inputs.push_to_images != '' }}
- name: Export digest

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

@@ -20,7 +20,7 @@ jobs:
with:
fetch-depth: 0
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3
- uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3
id: filter
with:
filters: |

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

@@ -163,11 +163,11 @@ jobs:
rspec-persistence-main
rspec-persistence
- run: bin/flatware rspec -r ./spec/flatware_helper.rb
- run: bin/flatware rspec
- name: Upload coverage reports to Codecov
if: matrix.ruby-version == '.ruby-version'
uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6
with:
files: coverage/lcov/*.lcov
env:

View File

@@ -1 +1 @@
4.0.3
4.0.5

26
.simplecov Normal file
View File

@@ -0,0 +1,26 @@
# frozen_string_literal: true
SimpleCov.start 'rails' do
# During parallel runs, ensure unique names for post-run merge
command_name "job-#{ENV['TEST_ENV_NUMBER']}" if ENV['TEST_ENV_NUMBER']
if ENV['CI']
require 'simplecov-lcov'
formatter SimpleCov::Formatter::LcovFormatter
formatter.config.report_with_single_file = true
else
formatter SimpleCov::Formatter::HTMLFormatter
end
enable_coverage :branch
add_filter 'lib/linter'
add_group 'Libraries', 'lib'
add_group 'Policies', 'app/policies'
add_group 'Presenters', 'app/presenters'
add_group 'Search', 'app/chewy'
add_group 'Serializers', 'app/serializers'
add_group 'Services', 'app/services'
add_group 'Validators', 'app/validators'
end

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"
@@ -340,10 +340,13 @@ COPY --from=node /usr/local/bin /usr/local/bin
COPY --from=node /usr/local/lib /usr/local/lib
RUN \
# Configure Corepack
rm /usr/local/bin/yarn*; \
npm i -g corepack; \
corepack prepare --activate;
# Mount local Corepack and Yarn caches from Docker buildx caches
--mount=type=cache,id=corepack-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/corepack,sharing=locked \
--mount=type=cache,id=yarn-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/yarn,sharing=locked \
# Remove pre-installed Yarn binaries (only present on Node <26)
rm -f /usr/local/bin/yarn*; \
# Install Corepack
npm i -g corepack;
# hadolint ignore=DL3008
RUN \

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,8 +99,8 @@ GEM
ast (2.4.3)
attr_required (1.0.2)
aws-eventstream (1.4.0)
aws-partitions (1.1242.0)
aws-sdk-core (3.246.0)
aws-partitions (1.1249.0)
aws-sdk-core (3.247.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -108,11 +108,11 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.124.0)
aws-sdk-core (~> 3, >= 3.244.0)
aws-sdk-kms (1.125.0)
aws-sdk-core (~> 3, >= 3.247.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.220.0)
aws-sdk-core (~> 3, >= 3.244.0)
aws-sdk-s3 (1.222.0)
aws-sdk-core (~> 3, >= 3.247.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
@@ -132,7 +132,7 @@ GEM
binding_of_caller (2.0.0)
debug_inspector (>= 1.2.0)
blurhash (0.1.8)
bootsnap (1.24.1)
bootsnap (1.24.4)
msgpack (~> 1.2)
brakeman (8.0.4)
racc
@@ -190,7 +190,7 @@ GEM
irb (~> 1.10)
reline (>= 0.3.8)
debug_inspector (1.2.0)
devise (5.0.3)
devise (5.0.4)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
railties (>= 7.0)
@@ -305,8 +305,8 @@ GEM
json
highline (3.1.2)
reline
hiredis-client (0.28.0)
redis-client (= 0.28.0)
hiredis-client (0.29.0)
redis-client (= 0.29.0)
hkdf (0.3.0)
htmlentities (4.4.2)
http (5.3.1)
@@ -354,7 +354,7 @@ GEM
azure-blob (~> 0.5.2)
hashie (~> 5.0)
jmespath (1.6.2)
json (2.19.4)
json (2.19.5)
json-canonicalization (1.0.0)
json-jwt (1.17.0)
activesupport (>= 4.2)
@@ -449,7 +449,7 @@ GEM
mime-types-data (3.2026.0414)
mini_mime (1.1.5)
mini_portile2 (2.8.9)
minitest (6.0.5)
minitest (6.0.6)
drb (~> 2.0)
prism (~> 1.5)
msgpack (1.8.0)
@@ -507,11 +507,11 @@ GEM
openssl (4.0.1)
openssl-signature_algorithm (1.3.0)
openssl (> 2.0)
opentelemetry-api (1.9.0)
opentelemetry-api (1.10.0)
logger
opentelemetry-common (0.24.0)
opentelemetry-common (0.25.0)
opentelemetry-api (~> 1.0)
opentelemetry-exporter-otlp (0.33.0)
opentelemetry-exporter-otlp (0.34.0)
google-protobuf (>= 3.18)
googleapis-common-protos-types (~> 1.3)
opentelemetry-api (~> 1.1)
@@ -574,19 +574,19 @@ GEM
opentelemetry-instrumentation-base (~> 0.25)
opentelemetry-instrumentation-sidekiq (0.29.0)
opentelemetry-instrumentation-base (~> 0.25)
opentelemetry-registry (0.5.0)
opentelemetry-registry (0.6.0)
opentelemetry-api (~> 1.1)
opentelemetry-sdk (1.11.0)
opentelemetry-sdk (1.12.0)
logger
opentelemetry-api (~> 1.1)
opentelemetry-common (~> 0.20)
opentelemetry-registry (~> 0.2)
opentelemetry-semantic_conventions
opentelemetry-semantic_conventions (1.37.0)
opentelemetry-semantic_conventions (1.37.1)
opentelemetry-api (~> 1.0)
orm_adapter (0.5.0)
ostruct (0.6.3)
ox (2.14.25)
ox (2.14.26)
bigdecimal (>= 3.0)
parallel (1.28.0)
parser (3.3.11.1)
@@ -709,7 +709,7 @@ GEM
redcarpet (3.6.1)
redis (5.4.1)
redis-client (>= 0.22.0)
redis-client (0.28.0)
redis-client (0.29.0)
connection_pool
regexp_parser (2.12.0)
reline (0.6.3)
@@ -816,12 +816,12 @@ GEM
securerandom (0.4.1)
shoulda-matchers (7.0.1)
activesupport (>= 7.1)
sidekiq (8.1.3)
sidekiq (8.1.5)
connection_pool (>= 3.0.0)
json (>= 2.16.0)
logger (>= 1.7.0)
rack (>= 3.2.0)
redis-client (>= 0.26.0)
redis-client (>= 0.29.0)
sidekiq-bulk (0.2.0)
sidekiq
sidekiq-scheduler (6.0.2)
@@ -851,7 +851,7 @@ GEM
concurrent-ruby
zeitwerk
stringio (3.2.0)
strong_migrations (2.7.0)
strong_migrations (2.8.0)
activerecord (>= 7.2)
swd (2.0.3)
activesupport (>= 3)
@@ -1020,8 +1020,8 @@ DEPENDENCIES
omniauth-rails_csrf_protection (~> 2.0)
omniauth-saml (~> 2.0)
omniauth_openid_connect (~> 0.8.0)
opentelemetry-api (~> 1.9.0)
opentelemetry-exporter-otlp (~> 0.33.0)
opentelemetry-api (~> 1.10.0)
opentelemetry-exporter-otlp (~> 0.34.0)
opentelemetry-instrumentation-active_job (~> 0.12.0)
opentelemetry-instrumentation-active_model_serializers (~> 0.25.0)
opentelemetry-instrumentation-concurrent_ruby (~> 0.25.0)
@@ -1097,7 +1097,7 @@ DEPENDENCIES
xorcist (~> 1.1)
RUBY VERSION
ruby 4.0.3
ruby 4.0.5
BUNDLED WITH
4.0.11

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

@@ -4,13 +4,48 @@ module Admin
class CollectionsController < BaseController
before_action :set_account
before_action :set_collection, only: :show
before_action :set_collections, except: :show
PER_PAGE = 20
def index
authorize [:admin, :collection], :index?
@collection_batch_action = Admin::CollectionBatchAction.new
end
def show
authorize @collection, :show?
end
def batch
authorize [:admin, :collection], :index?
@collection_batch_action = Admin::CollectionBatchAction.new(admin_collection_batch_action_params.merge(current_account: current_account, report_id: params[:report_id], type: action_from_button))
@collection_batch_action.save!
rescue ActionController::ParameterMissing
flash[:alert] = I18n.t('admin.collections.no_collection_selected')
ensure
redirect_to after_create_redirect_path
end
private
def after_create_redirect_path
report_id = @collections_batch_action&.report_id || params[:report_id]
if report_id.present?
admin_report_path(report_id)
else
admin_account_collections_path(params[:account_id], params[:page])
end
end
def admin_collection_batch_action_params
params
.expect(admin_collection_batch_action: [collection_ids: []])
end
def set_account
@account = Account.find(params[:account_id])
end
@@ -18,5 +53,17 @@ module Admin
def set_collection
@collection = @account.collections.includes(accepted_collection_items: :account).find(params[:id])
end
def set_collections
@collections = @account.collections.includes(accepted_collection_items: :account).page(params[:page]).per(PER_PAGE)
end
def action_from_button
if params[:report]
'report'
elsif params[:remove_from_report]
'remove_from_report'
end
end
end
end

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

@@ -0,0 +1,10 @@
# frozen_string_literal: true
class Redirect::CollectionsController < Redirect::BaseController
private
def set_resource
@resource = Collection.find(params[:id])
not_found if @resource.local? || @resource&.account&.suspended?
end
end

View File

@@ -48,9 +48,9 @@ module ContextHelper
},
quote_authorizations: {
'gts' => 'https://gotosocial.org/ns#',
'quoteAuthorization' => { '@id' => 'https://w3id.org/fep/044f#quoteAuthorization', '@type' => '@id' },
'interactingObject' => { '@id' => 'gts:interactingObject' },
'interactionTarget' => { '@id' => 'gts:interactionTarget' },
'QuoteAuthorization' => 'https://w3id.org/fep/044f#QuoteAuthorization',
'interactingObject' => { '@id' => 'gts:interactingObject', '@type' => '@id' },
'interactionTarget' => { '@id' => 'gts:interactionTarget', '@type' => '@id' },
},
}.freeze

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

@@ -13,14 +13,17 @@ import axios from 'axios';
import { on } from 'delegated-events';
import { throttle } from 'lodash';
import { determineEmojiMode } from '@/mastodon/features/emoji/mode';
import { updateHtmlWithEmoji } from '@/mastodon/features/emoji/render';
import loadKeyboardExtensions from '@/mastodon/load_keyboard_extensions';
import { loadLocale, getLocale } from '@/mastodon/locales';
import { loadPolyfills } from '@/mastodon/polyfills';
import ready from '@/mastodon/ready';
import { assetHost } from '@/mastodon/utils/config';
import { getNestedProperty } from '@/mastodon/utils/objects';
import { isDarkMode } from '@/mastodon/utils/theme';
import { formatTime } from '@/mastodon/utils/time';
import emojify from '../mastodon/features/emoji/emoji';
import loadKeyboardExtensions from '../mastodon/load_keyboard_extensions';
import { loadLocale, getLocale } from '../mastodon/locales';
import { loadPolyfills } from '../mastodon/polyfills';
import ready from '../mastodon/ready';
import 'cocoon-js-vanilla';
const messages = defineMessages({
@@ -38,7 +41,7 @@ const messages = defineMessages({
},
});
function loaded() {
async function loaded() {
const { messages: localeData } = getLocale();
const locale = document.documentElement.lang;
@@ -75,9 +78,30 @@ function loaded() {
return messageFormat.format(values) as string;
};
document.querySelectorAll('.emojify').forEach((content) => {
content.innerHTML = emojify(content.innerHTML);
});
let emojiStyle = 'auto';
const initialStateText =
document.getElementById('initial-state')?.textContent;
if (initialStateText) {
const stateEmojiStyle = getNestedProperty(
JSON.parse(initialStateText) as unknown,
'meta',
'emoji_style',
);
if (typeof stateEmojiStyle === 'string') {
emojiStyle = stateEmojiStyle;
}
}
const emojiMode = determineEmojiMode(emojiStyle);
const darkTheme = isDarkMode();
for (const element of document.querySelectorAll('.emojify')) {
await updateHtmlWithEmoji({
assetHost,
element,
locale,
mode: emojiMode,
darkTheme,
});
}
document
.querySelectorAll<HTMLTimeElement>('time.formatted')

View File

@@ -6,9 +6,8 @@ import { throttle } from 'lodash';
import api from 'mastodon/api';
import { browserHistory } from 'mastodon/components/router';
import { countableText } from 'mastodon/features/compose/util/counter';
import { search as emojiSearch } from 'mastodon/features/emoji/emoji_mart_search_light';
import { tagHistory } from 'mastodon/settings';
import { fetchCustomEmojiData } from '@/mastodon/features/emoji/picker';
import { emojiMartSearch } from '@/mastodon/features/emoji/picker';
import { showAlert, showAlertForError } from './alerts';
import { useEmoji } from './emojis';
@@ -100,7 +99,7 @@ export const ensureComposeIsVisible = (getState) => {
export function setComposeToStatus(status, text, spoiler_text) {
return (dispatch, getState) => {
const maxOptions = getState().server.getIn(['server', 'configuration', 'polls', 'max_options']);
const maxOptions = getState().server.server.item?.configuration.polls.max_options;
dispatch({
type: COMPOSE_SET_STATUS,
@@ -592,8 +591,9 @@ const fetchComposeSuggestionsAccounts = throttle((dispatch, token) => {
}, 200, { leading: true, trailing: true });
const fetchComposeSuggestionsEmojis = async (dispatch, token) => {
const custom = await fetchCustomEmojiData();
const results = emojiSearch(token.replace(':', ''), { maxResults: 5, custom });
// Right now we are hard-coding the locale to English since the picker search only supports English.
// Once we replace the legacy picker we can remove this and use the actual locale of the user.
const results = await emojiMartSearch(token, 'en', 5);
dispatch(readyComposeSuggestionsEmojis(token, results));
};
@@ -671,7 +671,7 @@ export function selectComposeSuggestion(position, token, suggestion, path) {
let completion, startPosition;
if (suggestion.type === 'emoji') {
completion = suggestion.native || suggestion.colons;
completion = suggestion.native || `:${suggestion.id}:`;
startPosition = position - 1;
dispatch(useEmoji(suggestion));

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

@@ -1,35 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<AutosuggestEmoji /> > renders emoji with custom url 1`] = `
<div
className="autosuggest-emoji"
>
<img
alt="foobar"
className="emojione"
src="http://example.com/emoji.png"
/>
<div
className="autosuggest-emoji__name"
>
:foobar:
</div>
</div>
`;
exports[`<AutosuggestEmoji /> > renders native emoji 1`] = `
<div
className="autosuggest-emoji"
>
<img
alt="💙"
className="emojione"
src="/emoji/1f499.svg"
/>
<div
className="autosuggest-emoji__name"
>
:foobar:
</div>
</div>
`;

View File

@@ -1,29 +0,0 @@
import renderer from 'react-test-renderer';
import AutosuggestEmoji from '../autosuggest_emoji';
describe('<AutosuggestEmoji />', () => {
it('renders native emoji', () => {
const emoji = {
native: '💙',
colons: ':foobar:',
};
const component = renderer.create(<AutosuggestEmoji emoji={emoji} />);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
it('renders emoji with custom url', () => {
const emoji = {
custom: true,
imageUrl: 'http://example.com/emoji.png',
native: 'foobar',
colons: ':foobar:',
};
const component = renderer.create(<AutosuggestEmoji emoji={emoji} />);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,140 @@
import { useCallback } from 'react';
import type { FC, ReactElement, ReactNode } from 'react';
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import {
authorizeFollowRequest,
rejectFollowRequest,
} from '@/mastodon/actions/accounts';
import { useAccountVisibility } from '@/mastodon/hooks/useAccountVisibility';
import { useRelationship } from '@/mastodon/hooks/useRelationship';
import type { Account } from '@/mastodon/models/account';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { AvatarOverlay } from '../avatar_overlay';
import { Button } from '../button';
import { DisplayName } from '../display_name';
import { Icon } from '../icon';
import classes from './styles.module.scss';
export const AccountBanners: FC<{ account: Account }> = ({ account }) => {
const { suspended, hidden } = useAccountVisibility(account.id);
const relationship = useRelationship(account.id);
if (hidden) {
return null;
}
let banner: ReactNode = null;
if (account.memorial) {
banner = (
<MessageText>
<FormattedMessage
id='account.in_memoriam'
defaultMessage='In Memoriam.'
/>
</MessageText>
);
}
if (account.moved) {
banner = <MovedNote account={account} targetAccountId={account.moved} />;
}
if (!suspended && relationship?.requested_by) {
banner = <FollowRequestNote account={account} />;
}
if (!banner) {
return null;
}
return <div className={classes.bannerWrapper}>{banner}</div>;
};
const FollowRequestNote: FC<{ account: Account }> = ({ account }) => {
const accountId = account.id;
const dispatch = useAppDispatch();
const handleAuthorize = useCallback(() => {
dispatch(authorizeFollowRequest(accountId));
}, [accountId, dispatch]);
const handleReject = useCallback(() => {
dispatch(rejectFollowRequest(accountId));
}, [accountId, dispatch]);
return (
<>
<MessageText>
<FormattedMessage
id='account.requested_follow'
defaultMessage='{name} has requested to follow you'
values={{ name: <DisplayName account={account} variant='simple' /> }}
/>
</MessageText>
<div className={classes.bannerActions}>
<Button secondary onClick={handleAuthorize}>
<Icon id='check' icon={CheckIcon} />
<FormattedMessage
id='follow_request.authorize'
defaultMessage='Authorize'
/>
</Button>
<Button secondary onClick={handleReject}>
<Icon id='times' icon={CloseIcon} />
<FormattedMessage
id='follow_request.reject'
defaultMessage='Reject'
/>
</Button>
</div>
</>
);
};
const MovedNote: React.FC<{
account: Account;
targetAccountId: string;
}> = ({ account: from, targetAccountId }) => {
const to = useAppSelector((state) => state.accounts.get(targetAccountId));
return (
<>
<MessageText>
<FormattedMessage
id='account.moved_to'
defaultMessage='{name} has indicated that their new account is now:'
values={{
name: <DisplayName account={from} variant='simple' />,
}}
/>
</MessageText>
<div className={classes.bannerActions}>
<Link to={`/@${to?.acct}`} className={classes.bannerActionsDisplayName}>
<AvatarOverlay account={to} friend={from} />
<DisplayName account={to} />
</Link>
<Link to={`/@${to?.acct}`} className='button'>
<FormattedMessage
id='account.go_to_profile'
defaultMessage='Go to profile'
/>
</Link>
</div>
</>
);
};
const MessageText: React.FC<{ children: ReactElement }> = ({ children }) => (
<div className={classes.bannerText}>{children}</div>
);

View File

@@ -107,8 +107,13 @@ const FieldCard: FC<{
field: AccountField;
}> = ({ htmlHandlers, field }) => {
const intl = useIntl();
const { name, nameHasEmojis, value_plain, valueHasEmojis, verified_at } =
field;
const {
name_emojified,
nameHasEmojis,
value_emojified,
valueHasEmojis,
verified_at,
} = field;
const { wrapperRef, isLabelOverflowing, isValueOverflowing } =
useFieldOverflow();
@@ -131,7 +136,7 @@ const FieldCard: FC<{
)}
label={
<FieldHTML
text={name}
text={name_emojified}
textHasCustomEmoji={nameHasEmojis}
className='translate'
isOverflowing={isLabelOverflowing}
@@ -141,7 +146,7 @@ const FieldCard: FC<{
}
value={
<FieldHTML
text={value_plain}
text={value_emojified}
textHasCustomEmoji={valueHasEmojis}
isOverflowing={isValueOverflowing}
onOverflowClick={handleOverflowClick}
@@ -312,9 +317,10 @@ function useColumnWrap() {
if (element) {
listRef.current = element;
observer.observe(element);
handleRecalculate();
}
},
[observer],
[handleRecalculate, observer],
);
return { wrapperRef: wrapperRefCallback };

View File

@@ -5,7 +5,6 @@ import classNames from 'classnames';
import { Helmet } from '@unhead/react/helmet';
import { openModal } from '@/mastodon/actions/modal';
import FollowRequestNoteContainer from '@/mastodon/features/account/containers/follow_request_note_container';
import { useLayout } from '@/mastodon/hooks/useLayout';
import { useVisibility } from '@/mastodon/hooks/useVisibility';
import {
@@ -22,10 +21,9 @@ import { Avatar } from '../avatar';
import { AnimateEmojiProvider } from '../emoji/context';
import { FamiliarFollowers } from '../familiar_followers';
import { AccountBanners } from './banners';
import { AccountButtons } from './buttons';
import { AccountHeaderFields } from './fields';
import { MemorialNote } from './memorial_note';
import { MovedNote } from './moved_note';
import { AccountName } from './name';
import { AccountNote } from './note';
import { AccountNumberFields } from './number_fields';
@@ -51,9 +49,6 @@ export const AccountHeader: React.FC<{
}> = ({ accountId, hideTabs }) => {
const dispatch = useAppDispatch();
const account = useAppSelector((state) => state.accounts.get(accountId));
const relationship = useAppSelector((state) =>
state.relationships.get(accountId),
);
const hidden = useAppSelector((state) => getAccountHidden(state, accountId));
const handleOpenAvatar = useCallback(
@@ -98,18 +93,11 @@ export const AccountHeader: React.FC<{
return (
<div>
{!hidden && account.memorial && <MemorialNote />}
{!hidden && account.moved && (
<MovedNote accountId={account.id} targetAccountId={account.moved} />
)}
<AccountBanners account={account} />
<AnimateEmojiProvider
className={classNames(!!account.moved && classes.moved)}
>
{!suspendedOrHidden && !account.moved && relationship?.requested_by && (
<FollowRequestNoteContainer account={account} />
)}
<div className={classes.header}>
{!suspendedOrHidden && (
<img

View File

@@ -1,12 +0,0 @@
import { FormattedMessage } from 'react-intl';
export const MemorialNote: React.FC = () => (
<div className='account-memorial-banner'>
<div className='account-memorial-banner__message'>
<FormattedMessage
id='account.in_memoriam'
defaultMessage='In Memoriam.'
/>
</div>
</div>
);

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,46 +0,0 @@
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import { useAppSelector } from '@/mastodon/store';
import { AvatarOverlay } from '../avatar_overlay';
import { DisplayName } from '../display_name';
export const MovedNote: React.FC<{
accountId: string;
targetAccountId: string;
}> = ({ accountId, targetAccountId }) => {
const from = useAppSelector((state) => state.accounts.get(accountId));
const to = useAppSelector((state) => state.accounts.get(targetAccountId));
return (
<div className='moved-account-banner'>
<div className='moved-account-banner__message'>
<FormattedMessage
id='account.moved_to'
defaultMessage='{name} has indicated that their new account is now:'
values={{
name: <DisplayName account={from} variant='simple' />,
}}
/>
</div>
<div className='moved-account-banner__action'>
<Link to={`/@${to?.acct}`} className='detailed-status__display-name'>
<div className='detailed-status__display-avatar'>
<AvatarOverlay account={to} friend={from} />
</div>
<DisplayName account={to} />
</Link>
<Link to={`/@${to?.acct}`} className='button'>
<FormattedMessage
id='account.go_to_profile'
defaultMessage='Go to profile'
/>
</Link>
</div>
</div>
);
};

View File

@@ -11,6 +11,8 @@ import { FormattedDateWrapper } from '../formatted_date';
import { NumberFields, NumberFieldsItem } from '../number_fields';
import { ShortNumber } from '../short_number';
import classes from './styles.module.scss';
export const AccountNumberFields: FC<{ accountId: string }> = ({
accountId,
}) => {
@@ -33,7 +35,7 @@ export const AccountNumberFields: FC<{ accountId: string }> = ({
}
return (
<NumberFields>
<NumberFields className={classes.numberFields}>
<NumberFieldsItem
label={
<FormattedMessage id='account.followers' defaultMessage='Followers' />

View File

@@ -21,7 +21,7 @@
height: 160px;
}
:global(.inactive) & {
.moved & {
filter: grayscale(100%);
}
}
@@ -78,6 +78,8 @@
.nameWrapper {
flex-grow: 1;
min-width: 0;
overflow-wrap: break-word;
}
.name {
@@ -263,9 +265,32 @@ $button-fallback-breakpoint: $button-breakpoint + 55px;
}
}
.numberFields {
@container (width >= #{$button-breakpoint}) {
--number-fields-gap: 40px;
}
}
.bio {
font-size: 15px;
color: var(--color-text-primary);
unicode-bidi: plaintext;
p {
margin-bottom: 20px;
&:last-child {
margin-bottom: 0;
}
}
:any-link {
color: var(--color-text-status-links);
&:hover {
text-decoration: none;
}
}
}
.familiarFollowers {
@@ -433,13 +458,24 @@ $button-fallback-breakpoint: $button-breakpoint + 55px;
border-bottom: 1px solid var(--color-border-primary);
}
// Banners
.bannerWrapper,
.bannerBase {
box-sizing: border-box;
padding: 16px;
border-radius: 12px;
background: var(--color-bg-secondary);
display: flex;
flex-direction: column;
}
.bannerWrapper {
background: var(--color-bg-tertiary);
padding: 16px;
align-items: center;
}
.bannerBase {
border-radius: 12px;
background: var(--color-bg-secondary);
gap: 12px;
justify-content: center;
align-items: flex-start;
@@ -455,6 +491,13 @@ $button-fallback-breakpoint: $button-breakpoint + 55px;
}
}
.bannerText {
color: var(--color-text-secondary);
font-size: 14px;
font-weight: 500;
text-align: center;
}
.bannerTextAndActions {
display: flex;
flex-direction: column;
@@ -469,6 +512,19 @@ $button-fallback-breakpoint: $button-breakpoint + 55px;
}
}
.bannerActions {
display: flex;
justify-content: space-between;
align-items: center;
gap: 15px;
width: 100%;
margin-top: 16px;
button {
width: 100%;
}
}
.bannerDisclaimer {
a {
color: inherit;
@@ -499,6 +555,32 @@ $button-fallback-breakpoint: $button-breakpoint + 55px;
}
}
.bannerActionsDisplayName {
color: var(--color-text-secondary);
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
line-height: 22px;
overflow: hidden;
text-decoration: none;
&:hover strong {
text-decoration: underline;
}
strong,
span {
display: block;
text-overflow: ellipsis;
overflow: hidden;
}
strong {
color: var(--color-text-primary);
}
}
// Buttons
.followButton {

View File

@@ -104,8 +104,6 @@ export const AccountSubscriptionForm: React.FC<{ accountId: string }> = ({
.then(() => {
setSubmitting(false);
setSubmitted(true);
return '';
})
.catch((err: unknown) => {
setSubmitting(false);
@@ -133,12 +131,11 @@ export const AccountSubscriptionForm: React.FC<{ accountId: string }> = ({
className={classNames(classes.bannerBase, classes.bannerBaseCentered)}
>
<div className={classes.bannerTextAndActions}>
<h2>
<FormattedMessage
id='email_subscriptions.submitted.title'
defaultMessage='One more step'
/>
</h2>
<FormattedMessage
id='email_subscriptions.submitted.title'
defaultMessage='One more step'
tagName='h2'
/>
<FormattedMessage
id='email_subscriptions.submitted.lead'
defaultMessage='Check your inbox for an email to finish signing up for email updates.'
@@ -151,15 +148,14 @@ export const AccountSubscriptionForm: React.FC<{ accountId: string }> = ({
return (
<form onSubmit={handleSubmit} className={classes.bannerBase} noValidate>
<div className={classes.bannerTextAndActions}>
<h2>
<FormattedMessage
id='email_subscriptions.form.title'
defaultMessage='Sign up for email updates from {name}'
values={{
name: <DisplayName account={account} variant='simple' />,
}}
/>
</h2>
<FormattedMessage
id='email_subscriptions.form.title'
defaultMessage='Sign up for email updates from {name}'
tagName='h2'
values={{
name: <DisplayName account={account} variant='simple' />,
}}
/>
</div>
<div className={classes.bannerInputButton}>

View File

@@ -90,7 +90,7 @@ export const AccountListItem: React.FC<Props> = ({
<ListItemLink
to={`/@${account.acct}`}
data-hover-card-account={accountId}
subtitle={handle}
subtitle={<span className={classes.handle}>{handle}</span>}
>
<DisplayNameSimple
account={account}

View File

@@ -1,7 +1,6 @@
.wrapper {
display: flex;
flex-direction: column;
align-items: start;
gap: 12px;
padding: 16px;
@@ -24,6 +23,13 @@
vertical-align: -4px;
}
.handle {
display: block;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.button {
align-self: start;
}

View File

@@ -1,43 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { assetHost } from 'mastodon/utils/config';
import { unicodeMapping } from '../features/emoji/emoji_unicode_mapping_light';
export default class AutosuggestEmoji extends PureComponent {
static propTypes = {
emoji: PropTypes.object.isRequired,
};
render () {
const { emoji } = this.props;
let url;
if (emoji.custom) {
url = emoji.imageUrl;
} else {
const mapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')];
if (!mapping) {
return null;
}
url = `${assetHost}/emoji/${mapping.filename}.svg`;
}
return (
<div className='autosuggest-emoji'>
<img
className='emojione'
src={url}
alt={emoji.native || emoji.colons}
/>
<div className='autosuggest-emoji__name'>{emoji.colons}</div>
</div>
);
}
}

View File

@@ -0,0 +1,20 @@
import type { FC } from 'react';
import { Emoji } from './emoji';
interface LegacyEmoji {
id: string;
custom?: boolean;
native?: string;
imageUrl?: string;
}
export const AutosuggestEmoji: FC<{ emoji: LegacyEmoji }> = ({ emoji }) => {
const colons = `:${emoji.id}:`;
return (
<div className='autosuggest-emoji'>
<Emoji code={emoji.native ?? colons} />
<div className='autosuggest-emoji__name'>{colons}</div>
</div>
);
};

View File

@@ -9,8 +9,9 @@ import Overlay from 'react-overlays/Overlay';
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
import AutosuggestEmoji from './autosuggest_emoji';
import { AutosuggestEmoji } from './autosuggest_emoji';
import { AutosuggestHashtag } from './autosuggest_hashtag';
import { LocalCustomEmojiProvider } from './emoji/context';
const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => {
let word;
@@ -219,15 +220,17 @@ export default class AutosuggestInput extends ImmutablePureComponent {
spellCheck={spellCheck}
/>
<Overlay show={!(suggestionsHidden || suggestions.isEmpty())} offset={[0, 0]} placement='bottom' target={this.input} popperConfig={{ strategy: 'fixed' }}>
{({ props }) => (
<div {...props}>
<div className='autosuggest-textarea__suggestions' style={{ width: this.input?.clientWidth }}>
{suggestions.map(this.renderSuggestion)}
<LocalCustomEmojiProvider>
<Overlay show={!(suggestionsHidden || suggestions.isEmpty())} offset={[0, 0]} placement='bottom' target={this.input} popperConfig={{ strategy: 'fixed' }}>
{({ props }) => (
<div {...props}>
<div className='autosuggest-textarea__suggestions' style={{ width: this.input?.clientWidth }}>
{suggestions.map(this.renderSuggestion)}
</div>
</div>
</div>
)}
</Overlay>
)}
</Overlay>
</LocalCustomEmojiProvider>
</div>
);
}

View File

@@ -10,8 +10,9 @@ import Textarea from 'react-textarea-autosize';
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
import AutosuggestEmoji from './autosuggest_emoji';
import { AutosuggestEmoji } from './autosuggest_emoji';
import { AutosuggestHashtag } from './autosuggest_hashtag';
import { LocalCustomEmojiProvider } from './emoji/context';
const textAtCursorMatchesToken = (str, caretPosition) => {
let word;
@@ -218,15 +219,17 @@ const AutosuggestTextarea = forwardRef(({
lang={lang}
/>
<Overlay show={!(suggestionsHidden || suggestions.isEmpty())} offset={[0, 0]} placement='bottom' target={textareaRef} popperConfig={{ strategy: 'fixed' }}>
{({ props }) => (
<div {...props}>
<div className='autosuggest-textarea__suggestions' style={{ width: textareaRef.current?.clientWidth }}>
{suggestions.map(renderSuggestion)}
<LocalCustomEmojiProvider>
<Overlay show={!(suggestionsHidden || suggestions.isEmpty())} offset={[0, 0]} placement='bottom' target={textareaRef} popperConfig={{ strategy: 'fixed' }}>
{({ props }) => (
<div {...props}>
<div className='autosuggest-textarea__suggestions' style={{ width: textareaRef.current?.clientWidth }}>
{suggestions.map(renderSuggestion)}
</div>
</div>
</div>
)}
</Overlay>
)}
</Overlay>
</LocalCustomEmojiProvider>
</div>
);
});

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

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

@@ -30,7 +30,7 @@ export const ListItemWrapper: React.FC<WrapperProps> = ({
return (
<div {...otherProps} className={classNames(classes.wrapper, className)}>
{icon}
<div>{children}</div>
<div className={classes.main}>{children}</div>
{sideContent && (
<span className={classes.sideContent}>{sideContent}</span>
)}
@@ -67,9 +67,14 @@ interface LinkProps
extends React.ComponentPropsWithoutRef<typeof Link>, ContentProps {}
export const ListItemLink = polymorphicForwardRef<'h3', LinkProps>(
({ as, subtitle, children, className, ...otherProps }, ref) => {
({ as, subtitle, subtitleId, children, className, ...otherProps }, ref) => {
return (
<ListItemContent ref={ref} as={as} subtitle={subtitle}>
<ListItemContent
ref={ref}
as={as}
subtitle={subtitle}
subtitleId={subtitleId}
>
<Link className={classNames(className, 'focusable')} {...otherProps}>
{children}
</Link>
@@ -82,9 +87,14 @@ interface ButtonProps
extends React.ComponentPropsWithoutRef<'button'>, ContentProps {}
export const ListItemButton = polymorphicForwardRef<'h3', ButtonProps>(
({ as, subtitle, children, className, ...otherProps }, ref) => {
({ as, subtitle, subtitleId, children, className, ...otherProps }, ref) => {
return (
<ListItemContent as={as} ref={ref} subtitle={subtitle}>
<ListItemContent
as={as}
ref={ref}
subtitle={subtitle}
subtitleId={subtitleId}
>
<button
type='button'
className={classNames(className, 'focusable')}

View File

@@ -14,6 +14,11 @@
color: var(--color-text-primary);
}
.main {
min-width: 0;
overflow-wrap: break-word;
}
.title {
font-weight: 500;

View File

@@ -1,9 +1,11 @@
.list {
--_item-gap: var(--number-fields-gap, 24px);
display: flex;
flex-wrap: wrap;
margin: 0;
padding: 0;
gap: 4px 24px;
gap: 4px var(--_item-gap);
font-size: 13px;
color: var(--color-text-secondary);
}

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

@@ -32,6 +32,7 @@ import StatusActionBar from './status_action_bar';
import StatusContent from './status_content';
import { StatusThreadLabel } from './status_thread_label';
import { CollectionPreviewCard } from '../features/collections/components/collection_preview_card';
import { compareUrls } from '../utils/compare_urls';
const domParser = new DOMParser();
@@ -555,7 +556,7 @@ class Status extends ImmutablePureComponent {
).find((item) => compareUrls(item.get('url'), cardUrl));
if (taggedCollection) {
media = <CollectionPreviewCard collection={taggedCollection} />;
media = <CollectionPreviewCard collection={taggedCollection.toJS()} />;
} else {
media = (
<Card
@@ -565,7 +566,7 @@ class Status extends ImmutablePureComponent {
/>
);
}
} else if (status.get('tagged_collections').size) {
} else if (status.get('tagged_collections').size && !status.get('quote')) {
const firstLinkedCollection = status.get('tagged_collections').first();
if (firstLinkedCollection) {
media = (

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

@@ -1,131 +0,0 @@
import type { ChangeEventHandler, KeyboardEventHandler } from 'react';
import { useState, useRef, useCallback, useId } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import Textarea from 'react-textarea-autosize';
import { submitAccountNote } from '@/mastodon/actions/account_notes';
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
const messages = defineMessages({
placeholder: {
id: 'account_note.placeholder',
defaultMessage: 'Click to add a note',
},
});
const AccountNoteUI: React.FC<{
initialValue: string | undefined;
onSubmit: (newNote: string) => void;
wasSaved: boolean;
}> = ({ initialValue, onSubmit, wasSaved }) => {
const intl = useIntl();
const uniqueId = useId();
const [value, setValue] = useState(initialValue ?? '');
const isLoading = initialValue === undefined;
const canSubmitOnBlurRef = useRef(true);
const handleChange = useCallback<ChangeEventHandler<HTMLTextAreaElement>>(
(e) => {
setValue(e.target.value);
},
[],
);
const handleKeyDown = useCallback<KeyboardEventHandler<HTMLTextAreaElement>>(
(e) => {
if (e.key === 'Escape') {
e.preventDefault();
setValue(initialValue ?? '');
canSubmitOnBlurRef.current = false;
e.currentTarget.blur();
} else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
onSubmit(value);
canSubmitOnBlurRef.current = false;
e.currentTarget.blur();
}
},
[initialValue, onSubmit, value],
);
const handleBlur = useCallback(() => {
if (initialValue !== value && canSubmitOnBlurRef.current) {
onSubmit(value);
}
canSubmitOnBlurRef.current = true;
}, [initialValue, onSubmit, value]);
return (
<div className='account__header__account-note'>
<label htmlFor={`account-note-${uniqueId}`}>
<FormattedMessage
id='account.account_note_header'
defaultMessage='Personal note'
/>{' '}
<span
aria-live='polite'
role='status'
className='inline-alert'
style={{ opacity: wasSaved ? 1 : 0 }}
>
{wasSaved && (
<FormattedMessage id='generic.saved' defaultMessage='Saved' />
)}
</span>
</label>
{isLoading ? (
<div className='account__header__account-note__loading-indicator-wrapper'>
<LoadingIndicator />
</div>
) : (
<Textarea
id={`account-note-${uniqueId}`}
className='account__header__account-note__content'
placeholder={intl.formatMessage(messages.placeholder)}
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
/>
)}
</div>
);
};
export const AccountNote: React.FC<{
accountId: string;
}> = ({ accountId }) => {
const dispatch = useAppDispatch();
const initialValue = useAppSelector((state) =>
state.relationships.get(accountId)?.get('note'),
);
const [wasSaved, setWasSaved] = useState(false);
const handleSubmit = useCallback(
(note: string) => {
setWasSaved(true);
void dispatch(submitAccountNote({ accountId, note }));
setTimeout(() => {
setWasSaved(false);
}, 2000);
},
[dispatch, accountId],
);
return (
<AccountNoteUI
key={`${accountId}-${initialValue}`}
initialValue={initialValue}
wasSaved={wasSaved}
onSubmit={handleSubmit}
/>
);
};

View File

@@ -1,210 +0,0 @@
import type { ReactNode } from 'react';
import { useState, useRef, useCallback, useId } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import Overlay from 'react-overlays/Overlay';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import BadgeIcon from '@/material-icons/400-24px/badge.svg?react';
import GlobeIcon from '@/material-icons/400-24px/globe.svg?react';
import { Icon } from 'mastodon/components/icon';
export const DomainPill: React.FC<{
domain: string;
username: string;
isSelf: boolean;
children?: ReactNode;
className?: string;
}> = ({ domain, username, isSelf, children, className }) => {
const accessibilityId = useId();
const [open, setOpen] = useState(false);
const [expanded, setExpanded] = useState(false);
const triggerRef = useRef(null);
const handleClick = useCallback(() => {
setOpen(!open);
}, [open, setOpen]);
const handleExpandClick = useCallback(() => {
setExpanded(!expanded);
}, [expanded, setExpanded]);
return (
<>
<button
className={classNames('account__domain-pill', className, {
active: open,
})}
ref={triggerRef}
onClick={handleClick}
aria-expanded={open}
aria-controls={accessibilityId}
type='button'
>
{children ?? domain}
</button>
<Overlay
show={open}
rootClose
onHide={handleClick}
offset={[5, 5]}
target={triggerRef}
>
{({ props }) => (
<div
{...props}
role='region'
id={accessibilityId}
className='account__domain-pill__popout dropdown-animation'
>
<div className='account__domain-pill__popout__header'>
<div className='account__domain-pill__popout__header__icon'>
<Icon id='' icon={BadgeIcon} />
</div>
<h3>
<FormattedMessage
id='domain_pill.whats_in_a_handle'
defaultMessage="What's in a handle?"
/>
</h3>
</div>
<div className='account__domain-pill__popout__handle'>
<div className='account__domain-pill__popout__handle__label'>
{isSelf ? (
<FormattedMessage
id='domain_pill.your_handle'
defaultMessage='Your handle:'
/>
) : (
<FormattedMessage
id='domain_pill.their_handle'
defaultMessage='Their handle:'
/>
)}
</div>
<div className='account__domain-pill__popout__handle__handle'>
@{username}@{domain}
</div>
</div>
<div className='account__domain-pill__popout__parts'>
<div>
<div className='account__domain-pill__popout__parts__icon'>
<Icon id='' icon={AlternateEmailIcon} />
</div>
<div>
<h6>
<FormattedMessage
id='domain_pill.username'
defaultMessage='Username'
/>
</h6>
<p>
{isSelf ? (
<FormattedMessage
id='domain_pill.your_username'
defaultMessage='Your unique identifier on this server. Its possible to find users with the same username on different servers.'
/>
) : (
<FormattedMessage
id='domain_pill.their_username'
defaultMessage='Their unique identifier on their server. Its possible to find users with the same username on different servers.'
/>
)}
</p>
</div>
</div>
<div>
<div className='account__domain-pill__popout__parts__icon'>
<Icon id='' icon={GlobeIcon} />
</div>
<div>
<h6>
<FormattedMessage
id='domain_pill.server'
defaultMessage='Server'
/>
</h6>
<p>
{isSelf ? (
<FormattedMessage
id='domain_pill.your_server'
defaultMessage='Your digital home, where all of your posts live. Dont like this one? Transfer servers at any time and bring your followers, too.'
/>
) : (
<FormattedMessage
id='domain_pill.their_server'
defaultMessage='Their digital home, where all of their posts live.'
/>
)}
</p>
</div>
</div>
</div>
<p>
{isSelf ? (
<FormattedMessage
id='domain_pill.who_you_are'
defaultMessage='Because your handle says who you are and where you are, people can interact with you across the social web of <button>ActivityPub-powered platforms</button>.'
values={{
button: (x) => (
<button
onClick={handleExpandClick}
className='link-button'
type='button'
>
{x}
</button>
),
}}
/>
) : (
<FormattedMessage
id='domain_pill.who_they_are'
defaultMessage='Since handles say who someone is and where they are, you can interact with people across the social web of <button>ActivityPub-powered platforms</button>.'
values={{
button: (x) => (
<button
onClick={handleExpandClick}
className='link-button'
type='button'
>
{x}
</button>
),
}}
/>
)}
</p>
{expanded && (
<>
<p>
<FormattedMessage
id='domain_pill.activitypub_like_language'
defaultMessage='ActivityPub is like the language Mastodon speaks with other social networks.'
/>
</p>
<p>
<FormattedMessage
id='domain_pill.activitypub_lets_connect'
defaultMessage='It lets you connect and interact with people not just on Mastodon, but across different social apps too.'
/>
</p>
</>
)}
</div>
)}
</Overlay>
</>
);
};

View File

@@ -1,41 +0,0 @@
import { FormattedMessage } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { Icon } from 'mastodon/components/icon';
import { DisplayName } from '@/mastodon/components/display_name';
export default class FollowRequestNote extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.record.isRequired,
};
render () {
const { account, onAuthorize, onReject } = this.props;
return (
<div className='follow-request-banner'>
<div className='follow-request-banner__message'>
<FormattedMessage id='account.requested_follow' defaultMessage='{name} has requested to follow you' values={{ name: <DisplayName account={account} variant='simple' /> }} />
</div>
<div className='follow-request-banner__action'>
<button type='button' className='button button-secondary button--confirmation' onClick={onAuthorize}>
<Icon id='check' icon={CheckIcon} />
<FormattedMessage id='follow_request.authorize' defaultMessage='Authorize' />
</button>
<button type='button' className='button button-secondary button--destructive' onClick={onReject}>
<Icon id='times' icon={CloseIcon} />
<FormattedMessage id='follow_request.reject' defaultMessage='Reject' />
</button>
</div>
</div>
);
}
}

View File

@@ -1,17 +0,0 @@
import { connect } from 'react-redux';
import { authorizeFollowRequest, rejectFollowRequest } from 'mastodon/actions/accounts';
import FollowRequestNote from '../components/follow_request_note';
const mapDispatchToProps = (dispatch, { account }) => ({
onAuthorize () {
dispatch(authorizeFollowRequest(account.get('id')));
},
onReject () {
dispatch(rejectFollowRequest(account.get('id')));
},
});
export default connect(null, mapDispatchToProps)(FollowRequestNote);

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

@@ -24,6 +24,7 @@ import Column from '@/mastodon/features/ui/components/column';
import { useAccount } from '@/mastodon/hooks/useAccount';
import { useAccountId } from '@/mastodon/hooks/useAccountId';
import { useAccountVisibility } from '@/mastodon/hooks/useAccountVisibility';
import { me } from '@/mastodon/initial_state';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import AddIcon from '@/material-icons/400-24px/add.svg?react';
@@ -173,12 +174,14 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
defaultMessage='Collections'
/>
</h2>
<SubheadingLink to='/collections/new' icon={AddIcon}>
<FormattedMessage
id='account.featured.new_collection'
defaultMessage='New collection'
/>
</SubheadingLink>
{accountId === me && (
<SubheadingLink to='/collections/new' icon={AddIcon}>
<FormattedMessage
id='account.featured.new_collection'
defaultMessage='New collection'
/>
</SubheadingLink>
)}
</Subheading>
{hasCollections ? (
<ItemList>

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

@@ -46,8 +46,16 @@ import { WizardStepTitle } from './wizard_step_title';
export const CollectionDetails: React.FC = () => {
const dispatch = useAppDispatch();
const history = useHistory();
const { id, name, description, topic, discoverable, sensitive, items } =
useAppSelector((state) => state.collections.editor);
const {
id,
name,
description,
topic,
language,
discoverable,
sensitive,
items,
} = useAppSelector((state) => state.collections.editor);
const handleNameChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
@@ -110,6 +118,7 @@ export const CollectionDetails: React.FC = () => {
name,
description,
tag_name: topic || null,
language: language || null,
discoverable,
sensitive,
};
@@ -125,6 +134,9 @@ export const CollectionDetails: React.FC = () => {
sensitive,
account_ids: items.map((item) => item.account_id),
};
if (language) {
payload.language = language;
}
if (topic) {
payload.tag_name = topic;
}
@@ -149,6 +161,7 @@ export const CollectionDetails: React.FC = () => {
description,
topic,
discoverable,
language,
sensitive,
dispatch,
history,
@@ -392,13 +405,8 @@ const renderTagItem = (item: TagSearchResult) => (
const LanguageField: React.FC = () => {
const dispatch = useAppDispatch();
const initialLanguage = useAppSelector(
(state) => state.compose.get('default_language') as string,
);
const { language } = useAppSelector((state) => state.collections.editor);
const selectedLanguage = language ?? initialLanguage;
const handleLanguageChange = useCallback(
(event: React.ChangeEvent<HTMLSelectElement>) => {
dispatch(
@@ -419,7 +427,7 @@ const LanguageField: React.FC = () => {
defaultMessage='Language'
/>
}
value={selectedLanguage}
value={language}
onChange={handleLanguageChange}
>
<option value=''>

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,97 +0,0 @@
import emojify from '../emoji';
describe('emoji', () => {
describe('.emojify', () => {
it('ignores unknown shortcodes', () => {
expect(emojify(':foobarbazfake:')).toEqual(':foobarbazfake:');
});
it('ignores shortcodes inside of tags', () => {
expect(emojify('<p data-foo=":smile:"></p>')).toEqual('<p data-foo=":smile:"></p>');
});
it('works with unclosed tags', () => {
expect(emojify('hello>')).toEqual('hello&gt;');
expect(emojify('<hello')).toEqual('');
});
it('works with unclosed shortcodes', () => {
expect(emojify('smile:')).toEqual('smile:');
expect(emojify(':smile')).toEqual(':smile');
});
it('does unicode', () => {
expect(emojify('\uD83D\uDC69\u200D\uD83D\uDC69\u200D\uD83D\uDC66\u200D\uD83D\uDC66')).toEqual(
'<img draggable="false" class="emojione" alt="👩‍👩‍👦‍👦" title=":woman-woman-boy-boy:" src="/emoji/1f469-200d-1f469-200d-1f466-200d-1f466.svg">');
expect(emojify('👨‍👩‍👧‍👧')).toEqual(
'<img draggable="false" class="emojione" alt="👨‍👩‍👧‍👧" title=":man-woman-girl-girl:" src="/emoji/1f468-200d-1f469-200d-1f467-200d-1f467.svg">');
expect(emojify('👩‍👩‍👦')).toEqual('<img draggable="false" class="emojione" alt="👩‍👩‍👦" title=":woman-woman-boy:" src="/emoji/1f469-200d-1f469-200d-1f466.svg">');
expect(emojify('\u2757')).toEqual(
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg">');
});
it('does multiple unicode', () => {
expect(emojify('\u2757 #\uFE0F\u20E3')).toEqual(
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg"> <img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg">');
expect(emojify('\u2757#\uFE0F\u20E3')).toEqual(
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg"><img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg">');
expect(emojify('\u2757 #\uFE0F\u20E3 \u2757')).toEqual(
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg"> <img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg"> <img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg">');
expect(emojify('foo \u2757 #\uFE0F\u20E3 bar')).toEqual(
'foo <img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg"> <img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg"> bar');
});
it('ignores unicode inside of tags', () => {
expect(emojify('<p data-foo="\uD83D\uDC69\uD83D\uDC69\uD83D\uDC66"></p>')).toEqual('<p data-foo="\uD83D\uDC69\uD83D\uDC69\uD83D\uDC66"></p>');
});
it('does multiple emoji properly (issue 5188)', () => {
expect(emojify('👌🌈💕')).toEqual('<img draggable="false" class="emojione" alt="👌" title=":ok_hand:" src="/emoji/1f44c.svg"><img draggable="false" class="emojione" alt="🌈" title=":rainbow:" src="/emoji/1f308.svg"><img draggable="false" class="emojione" alt="💕" title=":two_hearts:" src="/emoji/1f495.svg">');
expect(emojify('👌 🌈 💕')).toEqual('<img draggable="false" class="emojione" alt="👌" title=":ok_hand:" src="/emoji/1f44c.svg"> <img draggable="false" class="emojione" alt="🌈" title=":rainbow:" src="/emoji/1f308.svg"> <img draggable="false" class="emojione" alt="💕" title=":two_hearts:" src="/emoji/1f495.svg">');
});
it('does an emoji that has no shortcode', () => {
expect(emojify('👁‍🗨')).toEqual('<img draggable="false" class="emojione" alt="👁‍🗨" title="" src="/emoji/1f441-200d-1f5e8.svg">');
});
it('does an emoji whose filename is irregular', () => {
expect(emojify('↙️')).toEqual('<img draggable="false" class="emojione" alt="↙️" title=":arrow_lower_left:" src="/emoji/2199.svg">');
});
it('avoid emojifying on invisible text', () => {
expect(emojify('<a href="http://example.com/test%F0%9F%98%84"><span class="invisible">http://</span><span class="ellipsis">example.com/te</span><span class="invisible">st😄</span></a>'))
.toEqual('<a href="http://example.com/test%F0%9F%98%84"><span class="invisible">http://</span><span class="ellipsis">example.com/te</span><span class="invisible">st😄</span></a>');
expect(emojify('<span class="invisible">:luigi:</span>', { ':luigi:': { static_url: 'luigi.exe' } }))
.toEqual('<span class="invisible">:luigi:</span>');
});
it('avoid emojifying on invisible text with nested tags', () => {
expect(emojify('<span class="invisible">😄<span class="foo">bar</span>😴</span>😇'))
.toEqual('<span class="invisible">😄<span class="foo">bar</span>😴</span><img draggable="false" class="emojione" alt="😇" title=":innocent:" src="/emoji/1f607.svg">');
expect(emojify('<span class="invisible">😄<span class="invisible">😕</span>😴</span>😇'))
.toEqual('<span class="invisible">😄<span class="invisible">😕</span>😴</span><img draggable="false" class="emojione" alt="😇" title=":innocent:" src="/emoji/1f607.svg">');
expect(emojify('<span class="invisible">😄<br>😴</span>😇'))
.toEqual('<span class="invisible">😄<br>😴</span><img draggable="false" class="emojione" alt="😇" title=":innocent:" src="/emoji/1f607.svg">');
});
it('does not emojify emojis with textual presentation VS15 character', () => {
expect(emojify('✴︎')) // This is U+2734 EIGHT POINTED BLACK STAR then U+FE0E VARIATION SELECTOR-15
.toEqual('✴︎');
});
it('does a simple emoji properly', () => {
expect(emojify('♀♂'))
.toEqual('<img draggable="false" class="emojione" alt="♀" title=":female_sign:" src="/emoji/2640.svg"><img draggable="false" class="emojione" alt="♂" title=":male_sign:" src="/emoji/2642.svg">');
});
it('does an emoji containing ZWJ properly', () => {
expect(emojify('💂‍♀️💂‍♂️'))
.toEqual('<img draggable="false" class="emojione" alt="💂‍♀️" title=":female-guard:" src="/emoji/1f482-200d-2640-fe0f.svg"><img draggable="false" class="emojione" alt="💂‍♂️" title=":male-guard:" src="/emoji/1f482-200d-2642-fe0f.svg">');
});
it('keeps ordering as expected (issue fixed by PR 20677)', () => {
expect(emojify('<p>💕 <a class="hashtag" href="https://example.com/tags/foo" rel="nofollow noopener" target="_blank">#<span>foo</span></a> test: foo.</p>'))
.toEqual('<p><img draggable="false" class="emojione" alt="💕" title=":two_hearts:" src="/emoji/1f495.svg"> <a class="hashtag" href="https://example.com/tags/foo" rel="nofollow noopener" target="_blank">#<span>foo</span></a> test: foo.</p>');
});
});
});

Some files were not shown because too many files have changed in this diff Show More