Compare commits

..

54 Commits
main ... v4.4.2

Author SHA1 Message Date
github-actions[bot]
77d2cdb302
New Crowdin Translations for stable-4.4 (automated) (#35477)
Some checks failed
Check i18n / check-i18n (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (ruby) (push) Has been cancelled
Crowdin / Upload translations / upload-translations (push) Has been cancelled
Check formatting / lint (push) Has been cancelled
CSS Linting / lint (push) Has been cancelled
Haml Linting / lint (push) Has been cancelled
JavaScript Linting / lint (push) Has been cancelled
Ruby Linting / lint (push) Has been cancelled
JavaScript Testing / test (push) Has been cancelled
Historical data migration test / test (14-alpine) (push) Has been cancelled
Historical data migration test / test (15-alpine) (push) Has been cancelled
Historical data migration test / test (16-alpine) (push) Has been cancelled
Historical data migration test / test (17-alpine) (push) Has been cancelled
Ruby Testing / build (production) (push) Has been cancelled
Ruby Testing / build (test) (push) Has been cancelled
Ruby Testing / test (.ruby-version) (push) Has been cancelled
Ruby Testing / test (3.2) (push) Has been cancelled
Ruby Testing / test (3.3) (push) Has been cancelled
Ruby Testing / ImageMagick tests (.ruby-version) (push) Has been cancelled
Ruby Testing / ImageMagick tests (3.2) (push) Has been cancelled
Ruby Testing / ImageMagick tests (3.3) (push) Has been cancelled
Ruby Testing / End to End testing (.ruby-version) (push) Has been cancelled
Ruby Testing / End to End testing (3.2) (push) Has been cancelled
Ruby Testing / End to End testing (3.3) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:8.10.2) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (.ruby-version, opensearchproject/opensearch:2) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (3.2, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (3.3, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Has been cancelled
Co-authored-by: GitHub Actions <noreply@github.com>
2025-07-23 16:28:50 +02:00
David Roetzel
c727197760 Combine two items 2025-07-23 16:08:43 +02:00
David Roetzel
d6859c9658 Update CHANGELOG.md
Co-authored-by: Claire <claire.github-309c@sitedethib.com>
2025-07-23 16:08:43 +02:00
David Roetzel
7a9e98f4d6 Update CHANGELOG.md
Co-authored-by: Claire <claire.github-309c@sitedethib.com>
2025-07-23 16:08:43 +02:00
David Roetzel
7924a27ae7 Update CHANGELOG.md
Co-authored-by: Claire <claire.github-309c@sitedethib.com>
2025-07-23 16:08:43 +02:00
David Roetzel
d664b9d8ff Update "Security" section...
...to account for multiple updates that have been added since.
2025-07-23 16:08:43 +02:00
David Roetzel
4558cfadd8 Update dependency thor 2025-07-23 16:08:43 +02:00
David Roetzel
713965467d Update dependency axios 2025-07-23 16:08:43 +02:00
David Roetzel
aec6d0f807 Bump version to v4.4.2 2025-07-23 16:08:43 +02:00
diondiondion
e103815d2d Don't require JSDoc params & return in TS (#35426) 2025-07-23 16:08:43 +02:00
renovate[bot]
d73b9fba90 chore(deps): update dependency nokogiri to v1.18.9 (#35433)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-23 16:08:43 +02:00
diondiondion
a89d11bc08 refactor: Disable useDrag hook when main menu is not openable (#35414) 2025-07-23 16:08:43 +02:00
diondiondion
a250928934 fix: Add lang attribute to current composer language in alt text modal (#35412) 2025-07-23 16:08:43 +02:00
diondiondion
1d1b17b04b fix: Fix quote posts styling on notifications page (#35411) 2025-07-23 16:08:43 +02:00
diondiondion
2aff51013c fix: Improve a11y of custom select menus in notifications settings (#35403) 2025-07-23 16:08:43 +02:00
diondiondion
8c3c1faaec fix: Fix selected item in poll select menus is unreadable in Firefox (#35402) 2025-07-23 16:08:43 +02:00
diondiondion
a2888f1bb2 refactor: Only remove pointer-events when necessary (#35390) 2025-07-23 16:08:43 +02:00
diondiondion
77fe044f03 Update age limit wording (#35387) 2025-07-23 16:08:43 +02:00
Claire
da0cc0f5b9 Fix support for quote verification in implicit status updates (#35384) 2025-07-23 16:08:43 +02:00
Claire
ee83f3a8b9 Always give local quote of remote posts a quote request URI (#35383) 2025-07-23 16:08:43 +02:00
Claire
7ae78b1032 Refactor ActivityPub::Activity::Accept and ActivityPub::Activity::Reject specs (#35382) 2025-07-23 16:08:43 +02:00
Claire
c4b7c3bdda Fix quoteAuthorization type in JSON-LD context (#35380) 2025-07-23 16:08:43 +02:00
diondiondion
a79dbf8334 fix: Improve Dropdown component accessibility (#35373) 2025-07-23 16:08:43 +02:00
Claire
ef6f5f9357 Fix quote attributes missing from Mastodon's context (#35354) 2025-07-23 16:08:43 +02:00
Echo
f65f6ad6f1 Make bio hashtags open the local page instead of the remote instance (#35349) 2025-07-23 16:08:43 +02:00
Claire
c0e242cb73 Fix styling of external log-in button (#35320) 2025-07-23 16:08:43 +02:00
Claire
609a40181e Bump version to v4.4.1 2025-07-09 17:16:57 +02:00
Claire
93ce44d21d Fix nearly every sub-directory being crawled as part of Vite build (#35323) 2025-07-09 17:16:57 +02:00
David Roetzel
fb3ff194b5 Relax error restriction in initializer (#35321) 2025-07-09 17:16:57 +02:00
Claire
81b363b338 Fix replying from media modal or pop-in-player tagging user @undefined (#35317) 2025-07-09 17:16:57 +02:00
Claire
1151b05c2d Fix support for special characters in various environment variables (#35314)
Co-authored-by: Matt Jankowski <matt@jankowski.online>
2025-07-09 17:16:57 +02:00
Matt Jankowski
f96743fcfb Use if_exists: true when removing duplicate indexes (#35309) 2025-07-09 17:16:57 +02:00
Claire
69e14246b8
Fix 4.4 container images not being marked as latest (#35294) 2025-07-08 16:07:41 +02:00
Claire
c1794fb948
Bump version to v4.4.0 (#35268) 2025-07-08 15:25:26 +02:00
David Roetzel
333a17a478 Better error response to malformed headers (#35278) 2025-07-08 11:45:24 +02:00
github-actions[bot]
388e09e1a3
New Crowdin Translations for stable-4.4 (automated) (#35288)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-07-08 11:22:46 +02:00
diondiondion
2dcededcf0 fix: Fix can't skip search field by tabbing (#35281) 2025-07-07 17:48:13 +02:00
github-actions[bot]
2db8a328cd New Crowdin Translations (automated) (#35269)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-07-07 17:48:13 +02:00
Matt Jankowski
b4a950c2fc Remove unused scopes in Account model (#35276) 2025-07-07 17:48:13 +02:00
Claire
194645aada Add ability to manually trigger i18n uploads (#35279) 2025-07-07 15:40:54 +02:00
Claire
0c5ce23ae4 Fix incorrect name in scheduler configuration (#35263) 2025-07-04 15:10:17 +02:00
github-actions[bot]
cb937a920e New Crowdin Translations (automated) (#35261)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-07-04 15:10:17 +02:00
David Roetzel
7051458467 Raise better exception on FASP error responses (#35262) 2025-07-04 15:10:17 +02:00
Matt Jankowski
025abf7325 Fix intermittent failure of TOS model spec from effective date collision (#35244) 2025-07-04 15:10:17 +02:00
Matt Jankowski
28373a9c88 Use ActiveModel::Attributes in admin/status_batch_action (#35255) 2025-07-04 15:10:17 +02:00
Claire
42884d8727 Fix error handling for blank actions in account moderation action form (#35246) 2025-07-04 15:10:17 +02:00
github-actions[bot]
000ff9c05f New Crowdin Translations (automated) (#35250)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-07-04 15:10:17 +02:00
diondiondion
921af5d27d fix: Remove focus highlight when status is clicked in light mode (#35251) 2025-07-04 15:10:17 +02:00
Matt Jankowski
878e1e65eb Use ActiveModel::Attributes for admin/account_action boolean values (#35247) 2025-07-04 15:10:17 +02:00
Matt Jankowski
06f5f270cc Use Account#targeted_reports association where needed (#35249) 2025-07-04 15:10:17 +02:00
Matt Jankowski
961c22a6fd Add coverage for TOS interstitial interruption flow of web app controller concern (#35235) 2025-07-04 15:10:17 +02:00
github-actions[bot]
07b4fa55c8 New Crowdin Translations (automated) (#35238)
Co-authored-by: GitHub Actions <noreply@github.com>
2025-07-04 15:10:17 +02:00
Matt Jankowski
041bce9ed6 Add coverage for valid_locale_or_nil languages helper method (#34866) 2025-07-04 15:10:17 +02:00
Claire
d7a08d81b6 Fix error on log-in from old users requiring ToS interstitial when said ToS has been removed (#35233) 2025-07-04 15:10:17 +02:00
552 changed files with 3475 additions and 12943 deletions

View File

@ -5,7 +5,6 @@
.gitattributes .gitattributes
.gitignore .gitignore
.github .github
.vscode
public/system public/system
public/assets public/assets
public/packs public/packs
@ -21,7 +20,6 @@ postgres14
redis redis
elasticsearch elasticsearch
chart chart
storybook-static
.yarn/ .yarn/
!.yarn/patches !.yarn/patches
!.yarn/plugins !.yarn/plugins

View File

@ -1,6 +1,6 @@
name: Bug Report (Web Interface) name: Bug Report (Web Interface)
description: There is a problem using Mastodon's web interface. description: There is a problem using Mastodon's web interface.
labels: ['area/web interface'] labels: ['status/to triage', 'area/web interface']
type: Bug type: Bug
body: body:
- type: markdown - type: markdown

View File

@ -1,6 +1,7 @@
name: Bug Report (server / API) name: Bug Report (server / API)
description: | description: |
There is a problem with the HTTP server, REST API, ActivityPub interaction, etc. There is a problem with the HTTP server, REST API, ActivityPub interaction, etc.
labels: ['status/to triage']
type: 'Bug' type: 'Bug'
body: body:
- type: markdown - type: markdown

View File

@ -23,6 +23,7 @@
matchManagers: ['npm'], matchManagers: ['npm'],
matchPackageNames: [ matchPackageNames: [
'tesseract.js', // Requires code changes 'tesseract.js', // Requires code changes
'react-hotkeys', // Requires code changes
// react-router: Requires manual upgrade // react-router: Requires manual upgrade
'history', 'history',

View File

@ -21,7 +21,7 @@ jobs:
# Only tag with latest when ran against the latest stable branch # Only tag with latest when ran against the latest stable branch
# This needs to be updated after each minor version release # This needs to be updated after each minor version release
flavor: | flavor: |
latest=${{ startsWith(github.ref, 'refs/tags/v4.3.') }} latest=${{ startsWith(github.ref, 'refs/tags/v4.4.') }}
tags: | tags: |
type=pep440,pattern={{raw}} type=pep440,pattern={{raw}}
type=pep440,pattern=v{{major}}.{{minor}} type=pep440,pattern=v{{major}}.{{minor}}
@ -39,7 +39,7 @@ jobs:
# Only tag with latest when ran against the latest stable branch # Only tag with latest when ran against the latest stable branch
# This needs to be updated after each minor version release # This needs to be updated after each minor version release
flavor: | flavor: |
latest=${{ startsWith(github.ref, 'refs/tags/v4.3.') }} latest=${{ startsWith(github.ref, 'refs/tags/v4.4.') }}
tags: | tags: |
type=pep440,pattern={{raw}} type=pep440,pattern={{raw}}
type=pep440,pattern=v{{major}}.{{minor}} type=pep440,pattern=v{{major}}.{{minor}}

View File

@ -50,7 +50,7 @@ jobs:
# Create or update the pull request # Create or update the pull request
- name: Create Pull Request - name: Create Pull Request
uses: peter-evans/create-pull-request@v7.0.8 uses: peter-evans/create-pull-request@v7.0.6
with: with:
commit-message: 'New Crowdin translations' commit-message: 'New Crowdin translations'
title: 'New Crowdin Translations for ${{ github.base_ref || github.ref_name }} (automated)' title: 'New Crowdin Translations for ${{ github.base_ref || github.ref_name }} (automated)'

2
.nvmrc
View File

@ -1 +1 @@
22.18 22.17

View File

@ -1,11 +1,15 @@
# This configuration was generated by # This configuration was generated by
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp` # `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp`
# using RuboCop version 1.79.2. # using RuboCop version 1.77.0.
# The point is for the user to remove these configuration records # The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base. # one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new # Note that changes in the inspected code, or installation of new
# versions of RuboCop, may require this file to be generated again. # versions of RuboCop, may require this file to be generated again.
Lint/NonLocalExitFromIterator:
Exclude:
- 'app/helpers/json_ld_helper.rb'
# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes. # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes.
Metrics/AbcSize: Metrics/AbcSize:
Max: 82 Max: 82

View File

@ -1 +1 @@
3.4.5 3.4.4

View File

@ -1,5 +1,3 @@
import { resolve } from 'node:path';
import type { StorybookConfig } from '@storybook/react-vite'; import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = { const config: StorybookConfig = {
@ -28,12 +26,6 @@ const config: StorybookConfig = {
'oops.png', 'oops.png',
].map((path) => ({ from: `../public/${path}`, to: `/${path}` })), ].map((path) => ({ from: `../public/${path}`, to: `/${path}` })),
], ],
viteFinal(config) {
// For an unknown reason, Storybook does not use the root
// from the Vite config so we need to set it manually.
config.root = resolve(__dirname, '../app/javascript');
return config;
},
}; };
export default config; export default config;

View File

@ -2,28 +2,6 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [4.4.3] - 2025-08-05
### Security
- Update dependencies
- Fix incorrect rate-limit handling [GHSA-84ch-6436-c7mg](https://github.com/mastodon/mastodon/security/advisories/GHSA-84ch-6436-c7mg)
### Fixed
- Fix race condition caused by ActiveRecord query cache in `Create` critical path (#35662 by @ClearlyClaire)
- Fix race condition caused by quote post processing (#35657 by @ClearlyClaire)
- Fix WebUI crashing for accounts with `null` URL (#35651 by @ClearlyClaire)
- Fix friends-of-friends recommendations suggesting already-requested accounts (#35604 by @ClearlyClaire)
- Fix synchronous recursive fetching of deeply-nested quoted posts (#35600 by @ClearlyClaire)
- Fix “Expand this post” link including user `@undefined` (#35478 by @ClearlyClaire)
### Changed
- Change `StatusReachFinder` to consider quotes as well as reblogs (#35601 by @ClearlyClaire)
- Add restrictions on which quote posts can trend (#35507 by @ClearlyClaire)
- Change quote verification to not bypass authorization flow for mentions (#35528 by @ClearlyClaire)
## [4.4.2] - 2025-07-23 ## [4.4.2] - 2025-07-23
### Security ### 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="3.4.x"] # Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.4.x"]
# renovate: datasource=docker depName=docker.io/ruby # renovate: datasource=docker depName=docker.io/ruby
ARG RUBY_VERSION="3.4.5" ARG RUBY_VERSION="3.4.4"
# # Node.js version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"] # # Node.js version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"]
# renovate: datasource=node-version depName=node # renovate: datasource=node-version depName=node
ARG NODE_MAJOR_VERSION="22" ARG NODE_MAJOR_VERSION="22"
@ -186,7 +186,7 @@ FROM build AS libvips
# libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"] # libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"]
# renovate: datasource=github-releases depName=libvips packageName=libvips/libvips # renovate: datasource=github-releases depName=libvips packageName=libvips/libvips
ARG VIPS_VERSION=8.17.1 ARG VIPS_VERSION=8.17.0
# libvips download URL, change with [--build-arg VIPS_URL="https://github.com/libvips/libvips/releases/download"] # libvips download URL, change with [--build-arg VIPS_URL="https://github.com/libvips/libvips/releases/download"]
ARG VIPS_URL=https://github.com/libvips/libvips/releases/download ARG VIPS_URL=https://github.com/libvips/libvips/releases/download

View File

@ -62,7 +62,7 @@ gem 'inline_svg'
gem 'irb', '~> 1.8' gem 'irb', '~> 1.8'
gem 'kaminari', '~> 1.2' gem 'kaminari', '~> 1.2'
gem 'link_header', '~> 0.0' gem 'link_header', '~> 0.0'
gem 'linzer', '~> 0.7.7' gem 'linzer', '~> 0.7.2'
gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock' gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock'
gem 'mime-types', '~> 3.7.0', require: 'mime/types/columnar' gem 'mime-types', '~> 3.7.0', require: 'mime/types/columnar'
gem 'mutex_m' gem 'mutex_m'
@ -84,7 +84,7 @@ gem 'sanitize', '~> 7.0'
gem 'scenic', '~> 1.7' gem 'scenic', '~> 1.7'
gem 'sidekiq', '< 8' gem 'sidekiq', '< 8'
gem 'sidekiq-bulk', '~> 0.2.0' gem 'sidekiq-bulk', '~> 0.2.0'
gem 'sidekiq-scheduler', '~> 6.0' gem 'sidekiq-scheduler', '~> 5.0'
gem 'sidekiq-unique-jobs', '> 8' gem 'sidekiq-unique-jobs', '> 8'
gem 'simple_form', '~> 5.2' gem 'simple_form', '~> 5.2'
gem 'simple-navigation', '~> 4.4' gem 'simple-navigation', '~> 4.4'

View File

@ -90,13 +90,13 @@ GEM
public_suffix (>= 2.0.2, < 7.0) public_suffix (>= 2.0.2, < 7.0)
aes_key_wrap (1.1.0) aes_key_wrap (1.1.0)
android_key_attestation (0.3.0) android_key_attestation (0.3.0)
annotaterb (4.18.0) annotaterb (4.16.0)
activerecord (>= 6.0.0) activerecord (>= 6.0.0)
activesupport (>= 6.0.0) activesupport (>= 6.0.0)
ast (2.4.3) ast (2.4.3)
attr_required (1.0.2) attr_required (1.0.2)
aws-eventstream (1.4.0) aws-eventstream (1.3.2)
aws-partitions (1.1135.0) aws-partitions (1.1103.0)
aws-sdk-core (3.215.1) aws-sdk-core (3.215.1)
aws-eventstream (~> 1, >= 1.3.0) aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0) aws-partitions (~> 1, >= 1.992.0)
@ -109,9 +109,9 @@ GEM
aws-sdk-core (~> 3, >= 3.210.0) aws-sdk-core (~> 3, >= 3.210.0)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1) aws-sigv4 (1.11.0)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
azure-blob (0.5.9.1) azure-blob (0.5.8)
rexml rexml
base64 (0.3.0) base64 (0.3.0)
bcp47_spec (0.2.1) bcp47_spec (0.2.1)
@ -144,7 +144,7 @@ GEM
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0) regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2) xpath (~> 3.2)
capybara-playwright-driver (0.5.7) capybara-playwright-driver (0.5.6)
addressable addressable
capybara capybara
playwright-ruby-client (>= 1.16.0) playwright-ruby-client (>= 1.16.0)
@ -175,9 +175,9 @@ GEM
css_parser (1.21.1) css_parser (1.21.1)
addressable addressable
csv (3.3.5) csv (3.3.5)
database_cleaner-active_record (2.2.2) database_cleaner-active_record (2.2.1)
activerecord (>= 5.a) activerecord (>= 5.a)
database_cleaner-core (~> 2.0) database_cleaner-core (~> 2.0.0)
database_cleaner-core (2.0.1) database_cleaner-core (2.0.1)
date (3.4.1) date (3.4.1)
debug (1.11.0) debug (1.11.0)
@ -224,16 +224,16 @@ GEM
mail (~> 2.7) mail (~> 2.7)
email_validator (2.2.4) email_validator (2.2.4)
activemodel activemodel
erb (5.0.2) erb (5.0.1)
erubi (1.13.1) erubi (1.13.1)
et-orbi (1.2.11) et-orbi (1.2.11)
tzinfo tzinfo
excon (1.2.8) excon (1.2.5)
logger logger
fabrication (3.0.0) fabrication (3.0.0)
faker (3.5.2) faker (3.5.1)
i18n (>= 1.8.11, < 2) i18n (>= 1.8.11, < 2)
faraday (2.13.4) faraday (2.13.1)
faraday-net_http (>= 2.0, < 3.5) faraday-net_http (>= 2.0, < 3.5)
json json
logger logger
@ -241,7 +241,7 @@ GEM
faraday (>= 1, < 3) faraday (>= 1, < 3)
faraday-httpclient (2.0.2) faraday-httpclient (2.0.2)
httpclient (>= 2.2) httpclient (>= 2.2)
faraday-net_http (3.4.1) faraday-net_http (3.4.0)
net-http (>= 0.5.0) net-http (>= 0.5.0)
fast_blank (1.0.1) fast_blank (1.0.1)
fastimage (2.4.0) fastimage (2.4.0)
@ -266,14 +266,14 @@ GEM
fog-openstack (1.1.5) fog-openstack (1.1.5)
fog-core (~> 2.1) fog-core (~> 2.1)
fog-json (>= 1.0) fog-json (>= 1.0)
formatador (1.1.1) formatador (1.1.0)
forwardable (1.3.3) forwardable (1.3.3)
fugit (1.11.1) fugit (1.11.1)
et-orbi (~> 1, >= 1.2.11) et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4) raabro (~> 1.4)
globalid (1.2.1) globalid (1.2.1)
activesupport (>= 6.1) activesupport (>= 6.1)
google-protobuf (4.31.1) google-protobuf (4.31.0)
bigdecimal bigdecimal
rake (>= 13) rake (>= 13)
googleapis-common-protos-types (1.20.0) googleapis-common-protos-types (1.20.0)
@ -287,21 +287,21 @@ GEM
activesupport (>= 5.1) activesupport (>= 5.1)
haml (>= 4.0.6) haml (>= 4.0.6)
railties (>= 5.1) railties (>= 5.1)
haml_lint (0.66.0) haml_lint (0.64.0)
haml (>= 5.0) haml (>= 5.0)
parallel (~> 1.10) parallel (~> 1.10)
rainbow rainbow
rubocop (>= 1.0) rubocop (>= 1.0)
sysexits (~> 1.1) sysexits (~> 1.1)
hashdiff (1.2.0) hashdiff (1.1.2)
hashie (5.0.0) hashie (5.0.0)
hcaptcha (7.1.0) hcaptcha (7.1.0)
json json
highline (3.1.2) highline (3.1.2)
reline reline
hiredis (0.6.3) hiredis (0.6.3)
hiredis-client (0.25.1) hiredis-client (0.24.0)
redis-client (= 0.25.1) redis-client (= 0.24.0)
hkdf (0.3.0) hkdf (0.3.0)
htmlentities (4.3.4) htmlentities (4.3.4)
http (5.3.1) http (5.3.1)
@ -315,7 +315,7 @@ GEM
http_accept_language (2.1.1) http_accept_language (2.1.1)
httpclient (2.9.0) httpclient (2.9.0)
mutex_m mutex_m
httplog (1.7.3) httplog (1.7.0)
rack (>= 2.0) rack (>= 2.0)
rainbow (>= 2.0.0) rainbow (>= 2.0.0)
i18n (1.14.7) i18n (1.14.7)
@ -335,7 +335,7 @@ GEM
inline_svg (1.10.0) inline_svg (1.10.0)
activesupport (>= 3.0) activesupport (>= 3.0)
nokogiri (>= 1.6) nokogiri (>= 1.6)
io-console (0.8.1) io-console (0.8.0)
irb (1.15.2) irb (1.15.2)
pp (>= 0.6.0) pp (>= 0.6.0)
rdoc (>= 4.0.0) rdoc (>= 4.0.0)
@ -345,7 +345,7 @@ GEM
azure-blob (~> 0.5.2) azure-blob (~> 0.5.2)
hashie (~> 5.0) hashie (~> 5.0)
jmespath (1.6.2) jmespath (1.6.2)
json (2.13.2) json (2.12.2)
json-canonicalization (1.0.0) json-canonicalization (1.0.0)
json-jwt (1.16.7) json-jwt (1.16.7)
activesupport (>= 4.2) activesupport (>= 4.2)
@ -362,14 +362,14 @@ GEM
rack (>= 2.2, < 4) rack (>= 2.2, < 4)
rdf (~> 3.3) rdf (~> 3.3)
rexml (~> 3.2) rexml (~> 3.2)
json-ld-preloaded (3.3.2) json-ld-preloaded (3.3.1)
json-ld (~> 3.3) json-ld (~> 3.3)
rdf (~> 3.3) rdf (~> 3.3)
json-schema (5.2.1) json-schema (5.1.1)
addressable (~> 2.8) addressable (~> 2.8)
bigdecimal (~> 3.1) bigdecimal (~> 3.1)
jsonapi-renderer (0.2.2) jsonapi-renderer (0.2.2)
jwt (2.10.2) jwt (2.10.1)
base64 base64
kaminari (1.2.2) kaminari (1.2.2)
activesupport (>= 4.1.0) activesupport (>= 4.1.0)
@ -403,7 +403,7 @@ GEM
rexml rexml
link_header (0.0.8) link_header (0.0.8)
lint_roller (1.1.0) lint_roller (1.1.0)
linzer (0.7.7) linzer (0.7.3)
cgi (~> 0.4.2) cgi (~> 0.4.2)
forwardable (~> 1.3, >= 1.3.3) forwardable (~> 1.3, >= 1.3.3)
logger (~> 1.7, >= 1.7.0) logger (~> 1.7, >= 1.7.0)
@ -433,21 +433,21 @@ GEM
marcel (1.0.4) marcel (1.0.4)
mario-redis-lock (1.2.1) mario-redis-lock (1.2.1)
redis (>= 3.0.5) redis (>= 3.0.5)
matrix (0.4.3) matrix (0.4.2)
memory_profiler (1.1.0) memory_profiler (1.1.0)
mime-types (3.7.0) mime-types (3.7.0)
logger logger
mime-types-data (~> 3.2025, >= 3.2025.0507) mime-types-data (~> 3.2025, >= 3.2025.0507)
mime-types-data (3.2025.0729) mime-types-data (3.2025.0514)
mini_mime (1.1.5) mini_mime (1.1.5)
mini_portile2 (2.8.9) mini_portile2 (2.8.9)
minitest (5.25.5) minitest (5.25.5)
msgpack (1.8.0) msgpack (1.8.0)
multi_json (1.17.0) multi_json (1.15.0)
mutex_m (0.3.0) mutex_m (0.3.0)
net-http (0.6.0) net-http (0.6.0)
uri uri
net-imap (0.5.9) net-imap (0.5.8)
date date
net-protocol net-protocol
net-ldap (0.19.0) net-ldap (0.19.0)
@ -468,7 +468,7 @@ GEM
hashie (>= 3.4.6) hashie (>= 3.4.6)
rack (>= 2.2.3) rack (>= 2.2.3)
rack-protection rack-protection
omniauth-cas (3.0.2) omniauth-cas (3.0.1)
addressable (~> 2.8) addressable (~> 2.8)
nokogiri (~> 1.12) nokogiri (~> 1.12)
omniauth (~> 2.1) omniauth (~> 2.1)
@ -515,7 +515,7 @@ GEM
opentelemetry-api (~> 1.0) opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-active_support (~> 0.7) opentelemetry-instrumentation-active_support (~> 0.7)
opentelemetry-instrumentation-base (~> 0.23.0) opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-action_pack (0.12.3) opentelemetry-instrumentation-action_pack (0.12.1)
opentelemetry-api (~> 1.0) opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.23.0) opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-rack (~> 0.21) opentelemetry-instrumentation-rack (~> 0.21)
@ -553,7 +553,7 @@ GEM
opentelemetry-instrumentation-faraday (0.27.0) opentelemetry-instrumentation-faraday (0.27.0)
opentelemetry-api (~> 1.0) opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.23.0) opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-http (0.25.1) opentelemetry-instrumentation-http (0.25.0)
opentelemetry-api (~> 1.0) opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.23.0) opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-http_client (0.23.0) opentelemetry-instrumentation-http_client (0.23.0)
@ -597,20 +597,20 @@ GEM
opentelemetry-semantic_conventions (1.11.0) opentelemetry-semantic_conventions (1.11.0)
opentelemetry-api (~> 1.0) opentelemetry-api (~> 1.0)
orm_adapter (0.5.0) orm_adapter (0.5.0)
ostruct (0.6.3) ostruct (0.6.1)
ox (2.14.23) ox (2.14.23)
bigdecimal (>= 3.0) bigdecimal (>= 3.0)
parallel (1.27.0) parallel (1.27.0)
parser (3.3.9.0) parser (3.3.8.0)
ast (~> 2.4.1) ast (~> 2.4.1)
racc racc
parslet (2.0.0) parslet (2.0.0)
pastel (0.8.0) pastel (0.8.0)
tty-color (~> 0.5) tty-color (~> 0.5)
pg (1.6.1) pg (1.5.9)
pghero (3.7.0) pghero (3.7.0)
activerecord (>= 7.1) activerecord (>= 7.1)
playwright-ruby-client (1.54.1) playwright-ruby-client (1.52.0)
concurrent-ruby (>= 1.1.6) concurrent-ruby (>= 1.1.6)
mime-types (>= 3.0) mime-types (>= 3.0)
pp (0.6.2) pp (0.6.2)
@ -627,15 +627,16 @@ GEM
prism (1.4.0) prism (1.4.0)
prometheus_exporter (2.2.0) prometheus_exporter (2.2.0)
webrick webrick
propshaft (1.2.1) propshaft (1.1.0)
actionpack (>= 7.0.0) actionpack (>= 7.0.0)
activesupport (>= 7.0.0) activesupport (>= 7.0.0)
rack rack
railties (>= 7.0.0)
psych (5.2.6) psych (5.2.6)
date date
stringio stringio
public_suffix (6.0.2) public_suffix (6.0.2)
puma (6.6.1) puma (6.6.0)
nio4r (~> 2.0) nio4r (~> 2.0)
pundit (2.5.0) pundit (2.5.0)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
@ -681,7 +682,7 @@ GEM
activesupport (= 8.0.2) activesupport (= 8.0.2)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 8.0.2) railties (= 8.0.2)
rails-dom-testing (2.3.0) rails-dom-testing (2.2.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
nokogiri (>= 1.6) nokogiri (>= 1.6)
@ -701,28 +702,23 @@ GEM
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.3.0) rake (13.3.0)
rdf (3.3.4) rdf (3.3.2)
bcp47_spec (~> 0.2) bcp47_spec (~> 0.2)
bigdecimal (~> 3.1, >= 3.1.5) bigdecimal (~> 3.1, >= 3.1.5)
link_header (~> 0.0, >= 0.0.8) link_header (~> 0.0, >= 0.0.8)
logger (~> 1.5)
ostruct (~> 0.6)
readline (~> 0.0)
rdf-normalize (0.7.0) rdf-normalize (0.7.0)
rdf (~> 3.3) rdf (~> 3.3)
rdoc (6.14.2) rdoc (6.14.1)
erb erb
psych (>= 4.0.0) psych (>= 4.0.0)
readline (0.0.4)
reline
redcarpet (3.6.1) redcarpet (3.6.1)
redis (4.8.1) redis (4.8.1)
redis-client (0.25.1) redis-client (0.24.0)
connection_pool connection_pool
redlock (1.3.2) redlock (1.3.2)
redis (>= 3.0.0, < 6.0) redis (>= 3.0.0, < 6.0)
regexp_parser (2.11.0) regexp_parser (2.10.0)
reline (0.6.2) reline (0.6.1)
io-console (~> 0.5) io-console (~> 0.5)
request_store (1.7.0) request_store (1.7.0)
rack (>= 1.4) rack (>= 1.4)
@ -731,17 +727,17 @@ GEM
railties (>= 5.2) railties (>= 5.2)
rexml (3.4.1) rexml (3.4.1)
rotp (6.3.0) rotp (6.3.0)
rouge (4.6.0) rouge (4.5.2)
rpam2 (4.0.2) rpam2 (4.0.2)
rqrcode (3.1.0) rqrcode (3.1.0)
chunky_png (~> 1.0) chunky_png (~> 1.0)
rqrcode_core (~> 2.0) rqrcode_core (~> 2.0)
rqrcode_core (2.0.0) rqrcode_core (2.0.0)
rspec (3.13.1) rspec (3.13.0)
rspec-core (~> 3.13.0) rspec-core (~> 3.13.0)
rspec-expectations (~> 3.13.0) rspec-expectations (~> 3.13.0)
rspec-mocks (~> 3.13.0) rspec-mocks (~> 3.13.0)
rspec-core (3.13.5) rspec-core (3.13.4)
rspec-support (~> 3.13.0) rspec-support (~> 3.13.0)
rspec-expectations (3.13.5) rspec-expectations (3.13.5)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
@ -759,13 +755,13 @@ GEM
rspec-expectations (~> 3.13) rspec-expectations (~> 3.13)
rspec-mocks (~> 3.13) rspec-mocks (~> 3.13)
rspec-support (~> 3.13) rspec-support (~> 3.13)
rspec-sidekiq (5.2.0) rspec-sidekiq (5.1.0)
rspec-core (~> 3.0) rspec-core (~> 3.0)
rspec-expectations (~> 3.0) rspec-expectations (~> 3.0)
rspec-mocks (~> 3.0) rspec-mocks (~> 3.0)
sidekiq (>= 5, < 9) sidekiq (>= 5, < 9)
rspec-support (3.13.4) rspec-support (3.13.4)
rubocop (1.79.2) rubocop (1.77.0)
json (~> 2.3) json (~> 2.3)
language_server-protocol (~> 3.17.0.2) language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0) lint_roller (~> 1.1.0)
@ -773,10 +769,10 @@ GEM
parser (>= 3.3.0.2) parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0) regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.46.0, < 2.0) rubocop-ast (>= 1.45.1, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0) unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.46.0) rubocop-ast (1.45.1)
parser (>= 3.3.7.2) parser (>= 3.3.7.2)
prism (~> 1.4) prism (~> 1.4)
rubocop-capybara (2.22.1) rubocop-capybara (2.22.1)
@ -805,7 +801,7 @@ GEM
ruby-prof (1.7.2) ruby-prof (1.7.2)
base64 base64
ruby-progressbar (1.13.0) ruby-progressbar (1.13.0)
ruby-saml (1.18.1) ruby-saml (1.18.0)
nokogiri (>= 1.13.10) nokogiri (>= 1.13.10)
rexml rexml
ruby-vips (2.2.4) ruby-vips (2.2.4)
@ -819,7 +815,7 @@ GEM
sanitize (7.0.0) sanitize (7.0.0)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.16.8) nokogiri (>= 1.16.8)
scenic (1.9.0) scenic (1.8.0)
activerecord (>= 4.0.0) activerecord (>= 4.0.0)
railties (>= 4.0.0) railties (>= 4.0.0)
securerandom (0.4.1) securerandom (0.4.1)
@ -833,9 +829,10 @@ GEM
redis-client (>= 0.22.2) redis-client (>= 0.22.2)
sidekiq-bulk (0.2.0) sidekiq-bulk (0.2.0)
sidekiq sidekiq
sidekiq-scheduler (6.0.1) sidekiq-scheduler (5.0.6)
rufus-scheduler (~> 3.2) rufus-scheduler (~> 3.2)
sidekiq (>= 7.3, < 9) sidekiq (>= 6, < 8)
tilt (>= 1.4.0, < 3)
sidekiq-unique-jobs (8.0.11) sidekiq-unique-jobs (8.0.11)
concurrent-ruby (~> 1.0, >= 1.0.5) concurrent-ruby (~> 1.0, >= 1.0.5)
sidekiq (>= 7.0.0, < 9.0.0) sidekiq (>= 7.0.0, < 9.0.0)
@ -849,7 +846,7 @@ GEM
docile (~> 1.1) docile (~> 1.1)
simplecov-html (~> 0.11) simplecov-html (~> 0.11)
simplecov_json_formatter (~> 0.1) simplecov_json_formatter (~> 0.1)
simplecov-html (0.13.2) simplecov-html (0.13.1)
simplecov-lcov (0.8.0) simplecov-lcov (0.8.0)
simplecov_json_formatter (0.1.4) simplecov_json_formatter (0.1.4)
stackprof (0.2.27) stackprof (0.2.27)
@ -858,7 +855,7 @@ GEM
stoplight (4.1.1) stoplight (4.1.1)
redlock (~> 1.0) redlock (~> 1.0)
stringio (3.1.7) stringio (3.1.7)
strong_migrations (2.5.0) strong_migrations (2.4.0)
activerecord (>= 7.1) activerecord (>= 7.1)
swd (2.0.3) swd (2.0.3)
activesupport (>= 3) activesupport (>= 3)
@ -866,14 +863,14 @@ GEM
faraday (~> 2.0) faraday (~> 2.0)
faraday-follow_redirects faraday-follow_redirects
sysexits (1.2.0) sysexits (1.2.0)
temple (0.10.4) temple (0.10.3)
terminal-table (4.0.0) terminal-table (4.0.0)
unicode-display_width (>= 1.1.1, < 4) unicode-display_width (>= 1.1.1, < 4)
terrapin (1.1.1) terrapin (1.1.0)
climate_control climate_control
test-prof (1.4.4) test-prof (1.4.4)
thor (1.4.0) thor (1.4.0)
tilt (2.6.1) tilt (2.6.0)
timeout (0.4.3) timeout (0.4.3)
tpm-key_attestation (0.14.1) tpm-key_attestation (0.14.1)
bindata (~> 2.4) bindata (~> 2.4)
@ -935,7 +932,7 @@ GEM
crack (>= 0.3.2) crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0) hashdiff (>= 0.4.0, < 2.0.0)
webrick (1.9.1) webrick (1.9.1)
websocket-driver (0.8.0) websocket-driver (0.7.7)
base64 base64
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
@ -1011,7 +1008,7 @@ DEPENDENCIES
letter_opener (~> 1.8) letter_opener (~> 1.8)
letter_opener_web (~> 3.0) letter_opener_web (~> 3.0)
link_header (~> 0.0) link_header (~> 0.0)
linzer (~> 0.7.7) linzer (~> 0.7.2)
lograge (~> 0.12) lograge (~> 0.12)
mail (~> 2.8) mail (~> 2.8)
mario-redis-lock (~> 1.2) mario-redis-lock (~> 1.2)
@ -1081,7 +1078,7 @@ DEPENDENCIES
shoulda-matchers shoulda-matchers
sidekiq (< 8) sidekiq (< 8)
sidekiq-bulk (~> 0.2.0) sidekiq-bulk (~> 0.2.0)
sidekiq-scheduler (~> 6.0) sidekiq-scheduler (~> 5.0)
sidekiq-unique-jobs (> 8) sidekiq-unique-jobs (> 8)
simple-navigation (~> 4.4) simple-navigation (~> 4.4)
simple_form (~> 5.2) simple_form (~> 5.2)
@ -1105,4 +1102,4 @@ RUBY VERSION
ruby 3.4.1p0 ruby 3.4.1p0
BUNDLED WITH BUNDLED WITH
2.7.1 2.6.9

View File

@ -17,71 +17,71 @@
<img src="https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg" alt="Crowdin" /></a> <img src="https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg" alt="Crowdin" /></a>
</p> </p>
Mastodon is a **free, open-source social network server** based on [ActivityPub](https://www.w3.org/TR/activitypub/) where users can follow friends and discover new ones. On Mastodon, users can publish anything they want: links, pictures, text, and video. All Mastodon servers are interoperable as a federated network (users on one server can seamlessly communicate with users from another one, including non-Mastodon software that implements ActivityPub!) Mastodon is a **free, open-source social network server** based on ActivityPub where users can follow friends and discover new ones. On Mastodon, users can publish anything they want: links, pictures, text, and video. All Mastodon servers are interoperable as a federated network (users on one server can seamlessly communicate with users from another one, including non-Mastodon software that implements ActivityPub!)
## Navigation ## Navigation
- [Project homepage 🐘](https://joinmastodon.org) - [Project homepage 🐘](https://joinmastodon.org)
- [Donate to support development 🎁](https://joinmastodon.org/sponsors#donate) - [Support the development via Patreon][patreon]
- [View sponsors](https://joinmastodon.org/sponsors) - [View sponsors](https://joinmastodon.org/sponsors)
- [Blog 📰](https://blog.joinmastodon.org) - [Blog](https://blog.joinmastodon.org)
- [Documentation 📚](https://docs.joinmastodon.org) - [Documentation](https://docs.joinmastodon.org)
- [Official container image 🚢](https://github.com/mastodon/mastodon/pkgs/container/mastodon) - [Roadmap](https://joinmastodon.org/roadmap)
- [Official Docker image](https://github.com/mastodon/mastodon/pkgs/container/mastodon)
- [Browse Mastodon servers](https://joinmastodon.org/communities)
- [Browse Mastodon apps](https://joinmastodon.org/apps)
[patreon]: https://www.patreon.com/mastodon
## Features ## Features
<img src="./app/javascript/images/elephant_ui_working.svg?raw=true" align="right" width="30%" /> <img src="/app/javascript/images/elephant_ui_working.svg?raw=true" align="right" width="30%" />
**Part of the Fediverse. Based on open standards, with no vendor lock-in.** - the network goes beyond just Mastodon; anything that implements ActivityPub is part of a broader social network known as [the Fediverse](https://jointhefediverse.net/). You can follow and interact with users on other servers (including those running different software), and they can follow you back. **No vendor lock-in: Fully interoperable with any conforming platform** - It doesn't have to be Mastodon; whatever implements ActivityPub is part of the social network! [Learn more](https://blog.joinmastodon.org/2018/06/why-activitypub-is-the-future/)
**Real-time, chronological timeline updates** - updates of people you're following appear in real-time in the UI. **Real-time, chronological timeline updates** - updates of people you're following appear in real-time in the UI via WebSockets. There's a firehose view as well!
**Media attachments** - upload and view images and videos attached to the updates. Videos with no audio track are treated like animated GIFs; normal videos loop continuously. **Media attachments like images and short videos** - upload and view images and WebM/MP4 videos attached to the updates. Videos with no audio track are treated like GIFs; normal videos loop continuously!
**Safety and moderation tools** - Mastodon includes private posts, locked accounts, phrase filtering, muting, blocking, and many other features, along with a reporting and moderation system. **Safety and moderation tools** - Mastodon includes private posts, locked accounts, phrase filtering, muting, blocking, and all sorts of other features, along with a reporting and moderation system. [Learn more](https://blog.joinmastodon.org/2018/07/cage-the-mastodon/)
**OAuth2 and a straightforward REST API** - Mastodon acts as an OAuth2 provider, and third party apps can use the REST and Streaming APIs. This results in a [rich app ecosystem](https://joinmastodon.org/apps) with a variety of choices! **OAuth2 and a straightforward REST API** - Mastodon acts as an OAuth2 provider, so 3rd party apps can use the REST and Streaming APIs. This results in a rich app ecosystem with a lot of choices!
## Deployment ## Deployment
### Tech stack ### Tech stack
- [Ruby on Rails](https://github.com/rails/rails) powers the REST API and other web pages. - **Ruby on Rails** powers the REST API and other web pages
- [PostgreSQL](https://www.postgresql.org/) is the main database. - **React.js** and **Redux** are used for the dynamic parts of the interface
- [Redis](https://redis.io/) and [Sidekiq](https://sidekiq.org/) are used for caching and queueing. - **Node.js** powers the streaming API
- [Node.js](https://nodejs.org/) powers the streaming API.
- [React.js](https://reactjs.org/) and [Redux](https://redux.js.org/) are used for the dynamic parts of the interface.
- [BrowserStack](https://www.browserstack.com/) supports testing on real devices and browsers. (This project is tested with BrowserStack)
- [Chromatic](https://www.chromatic.com/) provides visual regression testing. (This project is tested with Chromatic)
### Requirements ### Requirements
- **Ruby** 3.2+
- **PostgreSQL** 13+ - **PostgreSQL** 13+
- **Redis** 6.2+ - **Redis** 6.2+
- **Ruby** 3.2+
- **Node.js** 20+ - **Node.js** 20+
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. The repository includes deployment configurations for **Docker and docker-compose** as well as specific platforms like **Heroku**, and **Scalingo**. For Helm charts, reference the [mastodon/chart repository](https://github.com/mastodon/chart). The [**standalone** installation guide](https://docs.joinmastodon.org/admin/install/) is available in the documentation.
## Contributing ## Contributing
Mastodon is **free, open-source software** licensed under **AGPLv3**. We welcome contributions and help from anyone who wants to improve the project. Mastodon is **free, open-source software** licensed under **AGPLv3**.
You should read the overall [CONTRIBUTING](https://github.com/mastodon/.github/blob/main/CONTRIBUTING.md) guide, which covers our development processes. You can open issues for bugs you've found or features you think are missing. You
can also submit pull requests to this repository or translations via Crowdin. To
get started, look at the [CONTRIBUTING] and [DEVELOPMENT] guides. For changes
accepted into Mastodon, you can request to be paid through our [OpenCollective].
You should also read and understand the [CODE OF CONDUCT](https://github.com/mastodon/.github/blob/main/CODE_OF_CONDUCT.md) that enables us to maintain a welcoming and inclusive community. Collaboration begins with mutual respect and understanding. **IRC channel**: #mastodon on [`irc.libera.chat`](https://libera.chat)
You can learn about setting up a development environment in the [DEVELOPMENT](docs/DEVELOPMENT.md) documentation. ## License
If you would like to help with translations 🌐 you can do so on [Crowdin](https://crowdin.com/project/mastodon).
## LICENSE
Copyright (c) 2016-2025 Eugen Rochko (+ [`mastodon authors`](AUTHORS.md)) Copyright (c) 2016-2025 Eugen Rochko (+ [`mastodon authors`](AUTHORS.md))
Licensed under GNU Affero General Public License as stated in the [LICENSE](LICENSE): Licensed under GNU Affero General Public License as stated in the [LICENSE](LICENSE):
```text ```
Copyright (c) 2016-2025 Eugen Rochko & other Mastodon contributors Copyright (c) 2016-2025 Eugen Rochko & other Mastodon contributors
This program is free software: you can redistribute it and/or modify it under This program is free software: you can redistribute it and/or modify it under
@ -97,3 +97,7 @@ details.
You should have received a copy of the GNU Affero General Public License along You should have received a copy of the GNU Affero General Public License along
with this program. If not, see https://www.gnu.org/licenses/ with this program. If not, see https://www.gnu.org/licenses/
``` ```
[CONTRIBUTING]: CONTRIBUTING.md
[DEVELOPMENT]: docs/DEVELOPMENT.md
[OpenCollective]: https://opencollective.com/mastodon

View File

@ -1,28 +0,0 @@
# frozen_string_literal: true
class ActivityPub::QuoteAuthorizationsController < ActivityPub::BaseController
include Authorization
vary_by -> { 'Signature' if authorized_fetch_mode? }
before_action :require_account_signature!, if: :authorized_fetch_mode?
before_action :set_quote_authorization
def show
expires_in 0, public: @quote.status.distributable? && public_fetch_mode?
render json: @quote, serializer: ActivityPub::QuoteAuthorizationSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
end
private
def pundit_user
signed_request_account
end
def set_quote_authorization
@quote = Quote.accepted.where(quoted_account: @account).find(params[:id])
authorize @quote.status, :show?
rescue Mastodon::NotPermittedError
not_found
end
end

View File

@ -16,14 +16,11 @@ module Admin
def batch def batch
authorize :account, :index? authorize :account, :index?
@form = Form::AccountBatch.new( @form = Form::AccountBatch.new(form_account_batch_params)
form_account_batch_params.merge( @form.current_account = current_account
action: action_from_button, @form.action = action_from_button
current_account:, @form.select_all_matching = params[:select_all_matching]
query: filtered_accounts, @form.query = filtered_accounts
select_all_matching: params[:select_all_matching]
)
)
@form.save @form.save
rescue ActionController::ParameterMissing rescue ActionController::ParameterMissing
flash[:alert] = I18n.t('admin.accounts.no_account_selected') flash[:alert] = I18n.t('admin.accounts.no_account_selected')

View File

@ -6,7 +6,7 @@ module Admin
def index def index
authorize :audit_log, :index? authorize :audit_log, :index?
@auditable_accounts = Account.auditable.select(:id, :username).order(username: :asc) @auditable_accounts = Account.auditable.select(:id, :username)
end end
private private

View File

@ -19,13 +19,15 @@ module Admin
log_action :resend, @user log_action :resend, @user
redirect_to admin_accounts_path, notice: t('admin.accounts.resend_confirmation.success') flash[:notice] = I18n.t('admin.accounts.resend_confirmation.success')
redirect_to admin_accounts_path
end end
private private
def redirect_confirmed_user def redirect_confirmed_user
redirect_to admin_accounts_path, flash: { error: t('admin.accounts.resend_confirmation.already_confirmed') } flash[:error] = I18n.t('admin.accounts.resend_confirmation.already_confirmed')
redirect_to admin_accounts_path
end end
def user_confirmed? def user_confirmed?

View File

@ -18,7 +18,7 @@ class Admin::Disputes::AppealsController < Admin::BaseController
end end
def reject def reject
authorize @appeal, :reject? authorize @appeal, :approve?
log_action :reject, @appeal log_action :reject, @appeal
@appeal.reject!(current_account) @appeal.reject!(current_account)
UserMailer.appeal_rejected(@appeal.account.user, @appeal).deliver_later UserMailer.appeal_rejected(@appeal.account.user, @appeal).deliver_later

View File

@ -36,7 +36,7 @@ module Admin
end end
def edit def edit
authorize :domain_block, :update? authorize :domain_block, :create?
end end
def create def create
@ -129,7 +129,7 @@ module Admin
end end
def requires_confirmation? def requires_confirmation?
@domain_block.valid? && (@domain_block.new_record? || @domain_block.severity_changed?) && @domain_block.suspend? && !params[:confirm] @domain_block.valid? && (@domain_block.new_record? || @domain_block.severity_changed?) && @domain_block.severity.to_s == 'suspend' && !params[:confirm]
end end
end end
end end

View File

@ -13,9 +13,27 @@ class Admin::Reports::ActionsController < Admin::BaseController
case action_from_button case action_from_button
when 'delete', 'mark_as_sensitive' when 'delete', 'mark_as_sensitive'
Admin::StatusBatchAction.new(status_batch_action_params).save! status_batch_action = Admin::StatusBatchAction.new(
type: action_from_button,
status_ids: @report.status_ids,
current_account: current_account,
report_id: @report.id,
send_email_notification: !@report.spam?,
text: params[:text]
)
status_batch_action.save!
when 'silence', 'suspend' when 'silence', 'suspend'
Admin::AccountAction.new(account_action_params).save! account_action = Admin::AccountAction.new(
type: action_from_button,
report_id: @report.id,
target_account: @report.target_account,
current_account: current_account,
send_email_notification: !@report.spam?,
text: params[:text]
)
account_action.save!
else else
return redirect_to admin_report_path(@report), alert: I18n.t('admin.reports.unknown_action_msg', action: action_from_button) return redirect_to admin_report_path(@report), alert: I18n.t('admin.reports.unknown_action_msg', action: action_from_button)
end end
@ -25,26 +43,6 @@ class Admin::Reports::ActionsController < Admin::BaseController
private private
def status_batch_action_params
shared_params
.merge(status_ids: @report.status_ids)
end
def account_action_params
shared_params
.merge(target_account: @report.target_account)
end
def shared_params
{
current_account: current_account,
report_id: @report.id,
send_email_notification: !@report.spam?,
text: params[:text],
type: action_from_button,
}
end
def set_report def set_report
@report = Report.find(params[:report_id]) @report = Report.find(params[:report_id])
end end

View File

@ -14,7 +14,8 @@ module Admin
@admin_settings = Form::AdminSettings.new(settings_params) @admin_settings = Form::AdminSettings.new(settings_params)
if @admin_settings.save if @admin_settings.save
redirect_to after_update_redirect_path, notice: t('generic.changes_saved_msg') flash[:notice] = I18n.t('generic.changes_saved_msg')
redirect_to after_update_redirect_path
else else
render :show render :show
end end

View File

@ -5,7 +5,6 @@ module Admin
before_action :set_tag, except: [:index] before_action :set_tag, except: [:index]
PER_PAGE = 20 PER_PAGE = 20
PERIOD_DAYS = 6.days
def index def index
authorize :tag, :index? authorize :tag, :index?
@ -16,7 +15,7 @@ module Admin
def show def show
authorize @tag, :show? authorize @tag, :show?
@time_period = report_range @time_period = (6.days.ago.to_date...Time.now.utc.to_date)
end end
def update def update
@ -25,7 +24,7 @@ module Admin
if @tag.update(tag_params.merge(reviewed_at: Time.now.utc)) if @tag.update(tag_params.merge(reviewed_at: Time.now.utc))
redirect_to admin_tag_path(@tag.id), notice: I18n.t('admin.tags.updated_msg') redirect_to admin_tag_path(@tag.id), notice: I18n.t('admin.tags.updated_msg')
else else
@time_period = report_range @time_period = (6.days.ago.to_date...Time.now.utc.to_date)
render :show render :show
end end
@ -37,10 +36,6 @@ module Admin
@tag = Tag.find(params[:id]) @tag = Tag.find(params[:id])
end end
def report_range
(PERIOD_DAYS.ago.to_date...Time.now.utc.to_date)
end
def tag_params def tag_params
params params
.expect(tag: [:name, :display_name, :trendable, :usable, :listable]) .expect(tag: [:name, :display_name, :trendable, :usable, :listable])

View File

@ -1,77 +0,0 @@
# frozen_string_literal: true
class Admin::UsernameBlocksController < Admin::BaseController
before_action :set_username_block, only: [:edit, :update]
def index
authorize :username_block, :index?
@username_blocks = UsernameBlock.order(username: :asc).page(params[:page])
@form = Form::UsernameBlockBatch.new
end
def batch
authorize :username_block, :index?
@form = Form::UsernameBlockBatch.new(form_username_block_batch_params.merge(current_account: current_account, action: action_from_button))
@form.save
rescue ActionController::ParameterMissing
flash[:alert] = I18n.t('admin.username_blocks.no_username_block_selected')
rescue Mastodon::NotPermittedError
flash[:alert] = I18n.t('admin.username_blocks.not_permitted')
ensure
redirect_to admin_username_blocks_path
end
def new
authorize :username_block, :create?
@username_block = UsernameBlock.new(exact: true)
end
def edit
authorize @username_block, :update?
end
def create
authorize :username_block, :create?
@username_block = UsernameBlock.new(resource_params)
if @username_block.save
log_action :create, @username_block
redirect_to admin_username_blocks_path, notice: I18n.t('admin.username_blocks.created_msg')
else
render :new
end
end
def update
authorize @username_block, :update?
if @username_block.update(resource_params)
log_action :update, @username_block
redirect_to admin_username_blocks_path, notice: I18n.t('admin.username_blocks.updated_msg')
else
render :new
end
end
private
def set_username_block
@username_block = UsernameBlock.find(params[:id])
end
def form_username_block_batch_params
params
.expect(form_username_block_batch: [username_block_ids: []])
end
def resource_params
params
.expect(username_block: [:username, :comparison, :allow_with_approval])
end
def action_from_button
'delete' if params[:delete]
end
end

View File

@ -2,7 +2,6 @@
class Api::V1::Admin::TagsController < Api::BaseController class Api::V1::Admin::TagsController < Api::BaseController
include Authorization include Authorization
before_action -> { authorize_if_got_token! :'admin:read' }, only: [:index, :show] before_action -> { authorize_if_got_token! :'admin:read' }, only: [:index, :show]
before_action -> { authorize_if_got_token! :'admin:write' }, only: :update before_action -> { authorize_if_got_token! :'admin:write' }, only: :update

View File

@ -7,7 +7,6 @@ class Api::V1::InvitesController < Api::BaseController
skip_around_action :set_locale skip_around_action :set_locale
before_action :set_invite before_action :set_invite
before_action :check_valid_usage!
before_action :check_enabled_registrations! before_action :check_enabled_registrations!
# Override `current_user` to avoid reading session cookies # Override `current_user` to avoid reading session cookies
@ -23,11 +22,9 @@ class Api::V1::InvitesController < Api::BaseController
@invite = Invite.find_by!(code: params[:invite_code]) @invite = Invite.find_by!(code: params[:invite_code])
end end
def check_valid_usage!
render json: { error: I18n.t('invites.invalid') }, status: 401 unless @invite.valid_for_use?
end
def check_enabled_registrations! def check_enabled_registrations!
return render json: { error: I18n.t('invites.invalid') }, status: 401 unless @invite.valid_for_use?
raise Mastodon::NotPermittedError unless allowed_registration?(request.remote_ip, @invite) raise Mastodon::NotPermittedError unless allowed_registration?(request.remote_ip, @invite)
end end
end end

View File

@ -16,7 +16,16 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController
def create def create
with_redis_lock("push_subscription:#{current_user.id}") do with_redis_lock("push_subscription:#{current_user.id}") do
destroy_web_push_subscriptions! destroy_web_push_subscriptions!
@push_subscription = Web::PushSubscription.create!(web_push_subscription_params)
@push_subscription = Web::PushSubscription.create!(
endpoint: subscription_params[:endpoint],
key_p256dh: subscription_params[:keys][:p256dh],
key_auth: subscription_params[:keys][:auth],
standard: subscription_params[:standard] || false,
data: data_params,
user_id: current_user.id,
access_token_id: doorkeeper_token.id
)
end end
render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer
@ -46,18 +55,6 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController
not_found if @push_subscription.nil? not_found if @push_subscription.nil?
end end
def web_push_subscription_params
{
access_token_id: doorkeeper_token.id,
data: data_params,
endpoint: subscription_params[:endpoint],
key_auth: subscription_params[:keys][:auth],
key_p256dh: subscription_params[:keys][:p256dh],
standard: subscription_params[:standard] || false,
user_id: current_user.id,
}
end
def subscription_params def subscription_params
params.expect(subscription: [:endpoint, :standard, keys: [:auth, :p256dh]]) params.expect(subscription: [:endpoint, :standard, keys: [:auth, :p256dh]])
end end

View File

@ -1,72 +0,0 @@
# frozen_string_literal: true
class Api::V1::Statuses::QuotesController < Api::V1::Statuses::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, only: :index
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: :revoke
before_action :check_owner!
before_action :set_quote, only: :revoke
after_action :insert_pagination_headers, only: :index
def index
cache_if_unauthenticated!
@statuses = load_statuses
render json: @statuses, each_serializer: REST::StatusSerializer
end
def revoke
authorize @quote, :revoke?
RevokeQuoteService.new.call(@quote)
render json: @quote.status, serializer: REST::StatusSerializer
end
private
def check_owner!
authorize @status, :list_quotes?
end
def set_quote
@quote = @status.quotes.find_by!(status_id: params[:id])
end
def load_statuses
scope = default_statuses
scope = scope.not_excluded_by_account(current_account) unless current_account.nil?
scope.merge(paginated_quotes).to_a
end
def default_statuses
Status.includes(:quote).references(:quote)
end
def paginated_quotes
@status.quotes.accepted.paginate_by_max_id(
limit_param(DEFAULT_STATUSES_LIMIT),
params[:max_id],
params[:since_id]
)
end
def next_path
api_v1_status_quotes_url pagination_params(max_id: pagination_max_id) if records_continue?
end
def prev_path
api_v1_status_quotes_url pagination_params(since_id: pagination_since_id) unless @statuses.empty?
end
def pagination_max_id
@statuses.last.quote.id
end
def pagination_since_id
@statuses.first.quote.id
end
def records_continue?
@statuses.size == limit_param(DEFAULT_STATUSES_LIMIT)
end
end

View File

@ -2,7 +2,6 @@
class Api::V1::StatusesController < Api::BaseController class Api::V1::StatusesController < Api::BaseController
include Authorization include Authorization
include AsyncRefreshesConcern
before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [:create, :update, :destroy] 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 -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:create, :update, :destroy]
@ -10,7 +9,6 @@ class Api::V1::StatusesController < Api::BaseController
before_action :set_statuses, only: [:index] before_action :set_statuses, only: [:index]
before_action :set_status, only: [:show, :context] before_action :set_status, only: [:show, :context]
before_action :set_thread, only: [:create] before_action :set_thread, only: [:create]
before_action :set_quoted_status, only: [:create]
before_action :check_statuses_limit, only: [:index] before_action :check_statuses_limit, only: [:index]
override_rate_limit_headers :create, family: :statuses override_rate_limit_headers :create, family: :statuses
@ -59,21 +57,9 @@ class Api::V1::StatusesController < Api::BaseController
@context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants) @context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants)
statuses = [@status] + @context.ancestors + @context.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))
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) render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id)
ActivityPub::FetchAllRepliesWorker.perform_async(@status.id) if !current_account.nil? && @status.should_fetch_replies?
end end
def create def create
@ -81,8 +67,6 @@ class Api::V1::StatusesController < Api::BaseController
current_user.account, current_user.account,
text: status_params[:status], text: status_params[:status],
thread: @thread, thread: @thread,
quoted_status: @quoted_status,
quote_approval_policy: quote_approval_policy,
media_ids: status_params[:media_ids], media_ids: status_params[:media_ids],
sensitive: status_params[:sensitive], sensitive: status_params[:sensitive],
spoiler_text: status_params[:spoiler_text], spoiler_text: status_params[:spoiler_text],
@ -114,8 +98,7 @@ class Api::V1::StatusesController < Api::BaseController
sensitive: status_params[:sensitive], sensitive: status_params[:sensitive],
language: status_params[:language], language: status_params[:language],
spoiler_text: status_params[:spoiler_text], spoiler_text: status_params[:spoiler_text],
poll: status_params[:poll], poll: status_params[:poll]
quote_approval_policy: quote_approval_policy
) )
render json: @status, serializer: REST::StatusSerializer render json: @status, serializer: REST::StatusSerializer
@ -155,16 +138,6 @@ class Api::V1::StatusesController < Api::BaseController
render json: { error: I18n.t('statuses.errors.in_reply_not_found') }, status: 404 render json: { error: I18n.t('statuses.errors.in_reply_not_found') }, status: 404
end end
def set_quoted_status
return unless Mastodon::Feature.outgoing_quotes_enabled?
@quoted_status = Status.find(status_params[:quoted_status_id]) if status_params[:quoted_status_id].present?
authorize(@quoted_status, :quote?) if @quoted_status.present?
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
# TODO: distinguish between non-existing and non-quotable posts
render json: { error: I18n.t('statuses.errors.quoted_status_not_found') }, status: 404
end
def check_statuses_limit def check_statuses_limit
raise(Mastodon::ValidationError) if status_ids.size > DEFAULT_STATUSES_LIMIT raise(Mastodon::ValidationError) if status_ids.size > DEFAULT_STATUSES_LIMIT
end end
@ -181,8 +154,6 @@ class Api::V1::StatusesController < Api::BaseController
params.permit( params.permit(
:status, :status,
:in_reply_to_id, :in_reply_to_id,
:quoted_status_id,
:quote_approval_policy,
:sensitive, :sensitive,
:spoiler_text, :spoiler_text,
:visibility, :visibility,
@ -205,23 +176,6 @@ class Api::V1::StatusesController < Api::BaseController
) )
end end
def quote_approval_policy
# TODO: handle `nil` separately
return nil unless Mastodon::Feature.outgoing_quotes_enabled? && status_params[:quote_approval_policy].present?
case status_params[:quote_approval_policy]
when 'public'
Status::QUOTE_APPROVAL_POLICY_FLAGS[:public] << 16
when 'followers'
Status::QUOTE_APPROVAL_POLICY_FLAGS[:followers] << 16
when 'nobody'
0
else
# TODO: raise more useful message
raise ActiveRecord::RecordInvalid
end
end
def serializer_for_status def serializer_for_status
@status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer
end end

View File

@ -20,7 +20,7 @@ class Api::V2::SearchController < Api::BaseController
@search = Search.new(search_results) @search = Search.new(search_results)
render json: @search, serializer: REST::SearchSerializer render json: @search, serializer: REST::SearchSerializer
rescue Mastodon::SyntaxError rescue Mastodon::SyntaxError
unprocessable_content unprocessable_entity
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
not_found not_found
end end

View File

@ -49,7 +49,7 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
{ {
policy: 'all', policy: 'all',
alerts: Notification::TYPES.index_with { alerts_enabled }, alerts: Notification::TYPES.index_with { alerts_enabled },
}.deep_stringify_keys }
end end
def alerts_enabled def alerts_enabled

View File

@ -28,7 +28,7 @@ class ApplicationController < ActionController::Base
rescue_from Mastodon::NotPermittedError, with: :forbidden rescue_from Mastodon::NotPermittedError, with: :forbidden
rescue_from ActionController::RoutingError, ActiveRecord::RecordNotFound, with: :not_found rescue_from ActionController::RoutingError, ActiveRecord::RecordNotFound, with: :not_found
rescue_from ActionController::UnknownFormat, with: :not_acceptable rescue_from ActionController::UnknownFormat, with: :not_acceptable
rescue_from ActionController::InvalidAuthenticityToken, with: :unprocessable_content rescue_from ActionController::InvalidAuthenticityToken, with: :unprocessable_entity
rescue_from Mastodon::RateLimitExceededError, with: :too_many_requests rescue_from Mastodon::RateLimitExceededError, with: :too_many_requests
rescue_from(*Mastodon::HTTP_CONNECTION_ERRORS, with: :internal_server_error) rescue_from(*Mastodon::HTTP_CONNECTION_ERRORS, with: :internal_server_error)
@ -123,7 +123,7 @@ class ApplicationController < ActionController::Base
respond_with_error(410) respond_with_error(410)
end end
def unprocessable_content def unprocessable_entity
respond_with_error(422) respond_with_error(422)
end end

View File

@ -38,7 +38,8 @@ class Auth::OmniauthCallbacksController < Devise::OmniauthCallbacksController
private private
def record_login_activity def record_login_activity
@user.login_activities.create( LoginActivity.create(
user: @user,
success: true, success: true,
authentication_method: :omniauth, authentication_method: :omniauth,
provider: @provider, provider: @provider,

View File

@ -19,7 +19,8 @@ class Auth::PasswordsController < Devise::PasswordsController
private private
def redirect_invalid_reset_token def redirect_invalid_reset_token
redirect_to new_password_path(resource_name), flash: { error: t('auth.invalid_reset_password_token') } flash[:error] = I18n.t('auth.invalid_reset_password_token')
redirect_to new_password_path(resource_name)
end end
def reset_password_token_is_valid? def reset_password_token_is_valid?

View File

@ -12,8 +12,6 @@ class Auth::SessionsController < Devise::SessionsController
skip_before_action :require_functional! skip_before_action :require_functional!
skip_before_action :update_user_sign_in skip_before_action :update_user_sign_in
around_action :preserve_stored_location, only: :destroy, if: :continue_after?
prepend_before_action :check_suspicious!, only: [:create] prepend_before_action :check_suspicious!, only: [:create]
include Auth::TwoFactorAuthenticationConcern include Auth::TwoFactorAuthenticationConcern
@ -33,9 +31,11 @@ class Auth::SessionsController < Devise::SessionsController
end end
def destroy def destroy
tmp_stored_location = stored_location_for(:user)
super super
session.delete(:challenge_passed_at) session.delete(:challenge_passed_at)
flash.delete(:notice) flash.delete(:notice)
store_location_for(:user, tmp_stored_location) if continue_after?
end end
def webauthn_options def webauthn_options
@ -96,12 +96,6 @@ class Auth::SessionsController < Devise::SessionsController
private private
def preserve_stored_location
original_stored_location = stored_location_for(:user)
yield
store_location_for(:user, original_stored_location)
end
def check_suspicious! def check_suspicious!
user = find_user user = find_user
@login_is_suspicious = suspicious_sign_in?(user) unless user.nil? @login_is_suspicious = suspicious_sign_in?(user) unless user.nil?
@ -157,11 +151,12 @@ class Auth::SessionsController < Devise::SessionsController
sign_in(user) sign_in(user)
flash.delete(:notice) flash.delete(:notice)
user.login_activities.create( LoginActivity.create(
request_details.merge( user: user,
success: true,
authentication_method: security_measure, authentication_method: security_measure,
success: true ip: request.remote_ip,
) user_agent: request.user_agent
) )
UserMailer.suspicious_sign_in(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later! if @login_is_suspicious UserMailer.suspicious_sign_in(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later! if @login_is_suspicious
@ -172,12 +167,13 @@ class Auth::SessionsController < Devise::SessionsController
end end
def on_authentication_failure(user, security_measure, failure_reason) def on_authentication_failure(user, security_measure, failure_reason)
user.login_activities.create( LoginActivity.create(
request_details.merge( user: user,
success: false,
authentication_method: security_measure, authentication_method: security_measure,
failure_reason: failure_reason, failure_reason: failure_reason,
success: false ip: request.remote_ip,
) user_agent: request.user_agent
) )
# Only send a notification email every hour at most # Only send a notification email every hour at most
@ -186,13 +182,6 @@ class Auth::SessionsController < Devise::SessionsController
UserMailer.failed_2fa(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later! UserMailer.failed_2fa(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later!
end end
def request_details
{
ip: request.remote_ip,
user_agent: request.user_agent,
}
end
def second_factor_attempts_key(user) def second_factor_attempts_key(user)
"2fa_auth_attempts:#{user.id}:#{Time.now.utc.hour}" "2fa_auth_attempts:#{user.id}:#{Time.now.utc.hour}"
end end

View File

@ -5,18 +5,6 @@ module Auth::CaptchaConcern
include Hcaptcha::Adapters::ViewMethods include Hcaptcha::Adapters::ViewMethods
CAPTCHA_DIRECTIVES = %w(
connect_src
frame_src
script_src
style_src
).freeze
CAPTCHA_SOURCES = %w(
https://*.hcaptcha.com
https://hcaptcha.com
).freeze
included do included do
helper_method :render_captcha helper_method :render_captcha
end end
@ -54,9 +42,20 @@ module Auth::CaptchaConcern
end end
def extend_csp_for_captcha! def extend_csp_for_captcha!
return unless captcha_required? && request.content_security_policy.present? policy = request.content_security_policy&.clone
request.content_security_policy = captcha_adjusted_policy return unless captcha_required? && policy.present?
%w(script_src frame_src style_src connect_src).each do |directive|
values = policy.send(directive)
values << 'https://hcaptcha.com' unless values.include?('https://hcaptcha.com') || values.include?('https:')
values << 'https://*.hcaptcha.com' unless values.include?('https://*.hcaptcha.com') || values.include?('https:')
policy.send(directive, *values)
end
request.content_security_policy = policy
end end
def render_captcha def render_captcha
@ -64,24 +63,4 @@ module Auth::CaptchaConcern
hcaptcha_tags hcaptcha_tags
end end
private
def captcha_adjusted_policy
request.content_security_policy.clone.tap do |policy|
populate_captcha_policy(policy)
end
end
def populate_captcha_policy(policy)
CAPTCHA_DIRECTIVES.each do |directive|
values = policy.send(directive)
CAPTCHA_SOURCES.each do |source|
values << source unless values.include?(source) || values.include?('https:')
end
policy.send(directive, *values)
end
end
end end

View File

@ -5,6 +5,6 @@ class Settings::LoginActivitiesController < Settings::BaseController
skip_before_action :require_functional! skip_before_action :require_functional!
def index def index
@login_activities = current_user.login_activities.order(id: :desc).page(params[:page]) @login_activities = LoginActivity.where(user: current_user).order(id: :desc).page(params[:page])
end end
end end

View File

@ -22,7 +22,7 @@ class Settings::Migration::RedirectsController < Settings::BaseController
end end
def destroy def destroy
if current_account.moved? if current_account.moved_to_account_id.present?
current_account.update!(moved_to_account: nil) current_account.update!(moved_to_account: nil)
ActivityPub::UpdateDistributionWorker.perform_async(current_account.id) ActivityPub::UpdateDistributionWorker.perform_async(current_account.id)
end end

View File

@ -8,7 +8,8 @@ class Settings::SessionsController < Settings::BaseController
def destroy def destroy
@session.destroy! @session.destroy!
redirect_to edit_user_registration_path, notice: t('sessions.revoke_success') flash[:notice] = I18n.t('sessions.revoke_success')
redirect_to edit_user_registration_path
end end
private private

View File

@ -52,7 +52,7 @@ module Settings
end end
else else
flash[:error] = I18n.t('webauthn_credentials.create.error') flash[:error] = I18n.t('webauthn_credentials.create.error')
status = :unprocessable_content status = :unprocessable_entity
end end
else else
flash[:error] = t('webauthn_credentials.create.error') flash[:error] = t('webauthn_credentials.create.error')
@ -86,11 +86,13 @@ module Settings
private private
def redirect_invalid_otp def redirect_invalid_otp
redirect_to settings_two_factor_authentication_methods_path, flash: { error: t('webauthn_credentials.otp_required') } flash[:error] = t('webauthn_credentials.otp_required')
redirect_to settings_two_factor_authentication_methods_path
end end
def redirect_invalid_webauthn def redirect_invalid_webauthn
redirect_to settings_two_factor_authentication_methods_path, flash: { error: t('webauthn_credentials.not_enabled') } flash[:error] = t('webauthn_credentials.not_enabled')
redirect_to settings_two_factor_authentication_methods_path
end end
end end
end end

View File

@ -11,7 +11,6 @@ class StatusesController < ApplicationController
before_action :require_account_signature!, only: [:show, :activity], if: -> { request.format == :json && authorized_fetch_mode? } before_action :require_account_signature!, only: [:show, :activity], if: -> { request.format == :json && authorized_fetch_mode? }
before_action :set_status before_action :set_status
before_action :redirect_to_original, only: :show before_action :redirect_to_original, only: :show
before_action :verify_embed_allowed, only: :embed
after_action :set_link_headers after_action :set_link_headers
@ -41,6 +40,8 @@ class StatusesController < ApplicationController
end end
def embed def embed
return not_found if @status.hidden? || @status.reblog?
expires_in 180, public: true expires_in 180, public: true
response.headers.delete('X-Frame-Options') response.headers.delete('X-Frame-Options')
@ -49,10 +50,6 @@ class StatusesController < ApplicationController
private private
def verify_embed_allowed
not_found if @status.hidden? || @status.reblog?
end
def set_link_headers def set_link_headers
response.headers['Link'] = LinkHeader.new( response.headers['Link'] = LinkHeader.new(
[[ActivityPub::TagManager.instance.uri_for(@status), [%w(rel alternate), %w(type application/activity+json)]]] [[ActivityPub::TagManager.instance.uri_for(@status), [%w(rel alternate), %w(type application/activity+json)]]]

View File

@ -13,8 +13,6 @@ module Admin::ActionLogsHelper
end end
when 'UserRole' when 'UserRole'
link_to log.human_identifier, admin_roles_path(log.target_id) link_to log.human_identifier, admin_roles_path(log.target_id)
when 'UsernameBlock'
link_to log.human_identifier, edit_admin_username_block_path(log.target_id)
when 'Report' when 'Report'
link_to "##{log.human_identifier.presence || log.target_id}", admin_report_path(log.target_id) link_to "##{log.human_identifier.presence || log.target_id}", admin_report_path(log.target_id)
when 'Instance', 'DomainBlock', 'DomainAllow', 'UnavailableDomain' when 'Instance', 'DomainBlock', 'DomainAllow', 'UnavailableDomain'

View File

@ -39,12 +39,6 @@ module ContextHelper
'automaticApproval' => { '@id' => 'gts:automaticApproval', '@type' => '@id' }, 'automaticApproval' => { '@id' => 'gts:automaticApproval', '@type' => '@id' },
'manualApproval' => { '@id' => 'gts:manualApproval', '@type' => '@id' }, 'manualApproval' => { '@id' => 'gts:manualApproval', '@type' => '@id' },
}, },
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' },
},
}.freeze }.freeze
def full_context def full_context

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
module EmailHelper
def self.included(base)
base.extend(self)
end
def email_to_canonical_email(str)
username, domain = str.downcase.split('@', 2)
username, = username.delete('.').split('+', 2)
"#{username}@#{domain}"
end
def email_to_canonical_email_hash(str)
Digest::SHA2.new(256).hexdigest(email_to_canonical_email(str))
end
end

View File

@ -65,13 +65,13 @@ module FormattingHelper
end end
def rss_content_preroll(status) def rss_content_preroll(status)
return unless status.spoiler_text? if status.spoiler_text?
safe_join [ safe_join [
tag.p { spoiler_with_warning(status) }, tag.p { spoiler_with_warning(status) },
tag.hr, tag.hr,
] ]
end end
end
def spoiler_with_warning(status) def spoiler_with_warning(status)
safe_join [ safe_join [
@ -81,12 +81,12 @@ module FormattingHelper
end end
def rss_content_postroll(status) def rss_content_postroll(status)
return unless status.preloadable_poll if status.preloadable_poll
tag.p do tag.p do
poll_option_tags(status) poll_option_tags(status)
end end
end end
end
def poll_option_tags(status) def poll_option_tags(status)
safe_join( safe_join(

View File

@ -39,8 +39,18 @@ module HomeHelper
end end
end end
def field_verified_class(verified) def obscured_counter(count)
if verified if count <= 0
'0'
elsif count == 1
'1'
else
'1+'
end
end
def custom_field_classes(field)
if field.verified?
'verified' 'verified'
else else
'emojify' 'emojify'

View File

@ -134,7 +134,7 @@ module JsonLdHelper
patch_for_forwarding!(value, compacted_value) patch_for_forwarding!(value, compacted_value)
elsif value.is_a?(Array) elsif value.is_a?(Array)
compacted_value = [compacted_value] unless compacted_value.is_a?(Array) compacted_value = [compacted_value] unless compacted_value.is_a?(Array)
return nil if value.size != compacted_value.size return if value.size != compacted_value.size
compacted[key] = value.zip(compacted_value).map do |v, vc| compacted[key] = value.zip(compacted_value).map do |v, vc|
if v.is_a?(Hash) && vc.is_a?(Hash) if v.is_a?(Hash) && vc.is_a?(Hash)

View File

@ -24,8 +24,7 @@ module ThemeHelper
end end
def custom_stylesheet def custom_stylesheet
return if active_custom_stylesheet.blank? if active_custom_stylesheet.present?
stylesheet_link_tag( stylesheet_link_tag(
custom_css_path(active_custom_stylesheet), custom_css_path(active_custom_stylesheet),
host: root_url, host: root_url,
@ -33,16 +32,17 @@ module ThemeHelper
skip_pipeline: true skip_pipeline: true
) )
end end
end
private private
def active_custom_stylesheet def active_custom_stylesheet
return if cached_custom_css_digest.blank? if cached_custom_css_digest.present?
[:custom, cached_custom_css_digest.to_s.first(8)] [:custom, cached_custom_css_digest.to_s.first(8)]
.compact_blank .compact_blank
.join('-') .join('-')
end end
end
def cached_custom_css_digest def cached_custom_css_digest
Rails.cache.fetch(:setting_digest_custom_css) do Rails.cache.fetch(:setting_digest_custom_css) do

View File

@ -1,3 +1 @@
Images in this folder are based on [Tabler.io icons](https://tabler.io/icons). Images in this folder are based on [Tabler.io icons](https://tabler.io/icons).
Seems to be 1.5 width icons scaled to 64×64px and centered above a blue square with round corners (24px).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -228,8 +228,6 @@ export function submitCompose() {
visibility: getState().getIn(['compose', 'privacy']), visibility: getState().getIn(['compose', 'privacy']),
poll: getState().getIn(['compose', 'poll'], null), poll: getState().getIn(['compose', 'poll'], null),
language: getState().getIn(['compose', 'language']), language: getState().getIn(['compose', 'language']),
quoted_status_id: getState().getIn(['compose', 'quoted_status_id']),
quote_approval_policy: getState().getIn(['compose', 'quote_policy']),
}, },
headers: { headers: {
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']), 'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),

View File

@ -1,18 +1,9 @@
import { createAction } from '@reduxjs/toolkit';
import type { List as ImmutableList, Map as ImmutableMap } from 'immutable'; import type { List as ImmutableList, Map as ImmutableMap } from 'immutable';
import { apiUpdateMedia } from 'mastodon/api/compose'; import { apiUpdateMedia } from 'mastodon/api/compose';
import type { ApiMediaAttachmentJSON } from 'mastodon/api_types/media_attachments'; import type { ApiMediaAttachmentJSON } from 'mastodon/api_types/media_attachments';
import type { MediaAttachment } from 'mastodon/models/media_attachment'; import type { MediaAttachment } from 'mastodon/models/media_attachment';
import { import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
createDataLoadingThunk,
createAppThunk,
} from 'mastodon/store/typed_functions';
import type { ApiQuotePolicy } from '../api_types/quotes';
import type { Status } from '../models/status';
import { ensureComposeIsVisible } from './compose';
type SimulatedMediaAttachmentJSON = ApiMediaAttachmentJSON & { type SimulatedMediaAttachmentJSON = ApiMediaAttachmentJSON & {
unattached?: boolean; unattached?: boolean;
@ -77,26 +68,3 @@ export const changeUploadCompose = createDataLoadingThunk(
useLoadingBar: false, useLoadingBar: false,
}, },
); );
export const quoteComposeByStatus = createAppThunk(
'compose/quoteComposeStatus',
(status: Status, { getState }) => {
ensureComposeIsVisible(getState);
return status;
},
);
export const quoteComposeById = createAppThunk(
(statusId: string, { dispatch, getState }) => {
const status = getState().statuses.get(statusId);
if (status) {
dispatch(quoteComposeByStatus(status));
}
},
);
export const quoteComposeCancel = createAction('compose/quoteComposeCancel');
export const setQuotePolicy = createAction<ApiQuotePolicy>(
'compose/setQuotePolicy',
);

View File

@ -1,8 +1,4 @@
import { import { apiReblog, apiUnreblog } from 'mastodon/api/interactions';
apiReblog,
apiUnreblog,
apiRevokeQuote,
} from 'mastodon/api/interactions';
import type { StatusVisibility } from 'mastodon/models/status'; import type { StatusVisibility } from 'mastodon/models/status';
import { createDataLoadingThunk } from 'mastodon/store/typed_functions'; import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
@ -37,19 +33,3 @@ export const unreblog = createDataLoadingThunk(
return discardLoadData; return discardLoadData;
}, },
); );
export const revokeQuote = createDataLoadingThunk(
'status/revoke_quote',
({
statusId,
quotedStatusId,
}: {
statusId: string;
quotedStatusId: string;
}) => apiRevokeQuote(quotedStatusId, statusId),
(data, { dispatch, discardLoadData }) => {
dispatch(importFetchedStatus(data));
return discardLoadData;
},
);

View File

@ -31,9 +31,7 @@ import { NOTIFICATIONS_FILTER_SET } from './notifications';
import { saveSettings } from './settings'; import { saveSettings } from './settings';
function excludeAllTypesExcept(filter: string) { function excludeAllTypesExcept(filter: string) {
return allNotificationTypes.filter( return allNotificationTypes.filter((item) => item !== filter);
(item) => item !== filter && !(item === 'quote' && filter === 'mention'),
);
} }
function getExcludedTypes(state: RootState) { function getExcludedTypes(state: RootState) {
@ -158,15 +156,12 @@ export const processNewNotificationForGroups = createAppAsyncThunk(
const showInColumn = const showInColumn =
activeFilter === 'all' activeFilter === 'all'
? notificationShows[notification.type] !== false ? notificationShows[notification.type] !== false
: activeFilter === notification.type || : activeFilter === notification.type;
(activeFilter === 'mention' && notification.type === 'quote');
if (!showInColumn) return; if (!showInColumn) return;
if ( if (
(notification.type === 'mention' || (notification.type === 'mention' || notification.type === 'update') &&
notification.type === 'update' ||
notification.type === 'quote') &&
notification.status?.filtered notification.status?.filtered
) { ) {
const filters = notification.status.filtered.filter((result) => const filters = notification.status.filtered.filter((result) =>

View File

@ -31,7 +31,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
let filtered = false; let filtered = false;
if (['mention', 'status', 'quote'].includes(notification.type) && notification.status.filtered) { if (['mention', 'status'].includes(notification.type) && notification.status.filtered) {
const filters = notification.status.filtered.filter(result => result.filter.context.includes('notifications')); const filters = notification.status.filtered.filter(result => result.filter.context.includes('notifications'));
if (filters.some(result => result.filter.filter_action === 'hide')) { if (filters.some(result => result.filter.filter_action === 'hide')) {

View File

@ -1,5 +1,3 @@
import { createAction } from '@reduxjs/toolkit';
import { apiGetContext } from 'mastodon/api/statuses'; import { apiGetContext } from 'mastodon/api/statuses';
import { createDataLoadingThunk } from 'mastodon/store/typed_functions'; import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
@ -8,18 +6,13 @@ import { importFetchedStatuses } from './importer';
export const fetchContext = createDataLoadingThunk( export const fetchContext = createDataLoadingThunk(
'status/context', 'status/context',
({ statusId }: { statusId: string }) => apiGetContext(statusId), ({ statusId }: { statusId: string }) => apiGetContext(statusId),
({ context, refresh }, { dispatch }) => { (context, { dispatch }) => {
const statuses = context.ancestors.concat(context.descendants); const statuses = context.ancestors.concat(context.descendants);
dispatch(importFetchedStatuses(statuses)); dispatch(importFetchedStatuses(statuses));
return { return {
context, context,
refresh,
}; };
}, },
); );
export const completeContextRefresh = createAction<{ statusId: string }>(
'status/context/complete',
);

View File

@ -20,50 +20,6 @@ export const getLinks = (response: AxiosResponse) => {
return LinkHeader.parse(value); return LinkHeader.parse(value);
}; };
export interface AsyncRefreshHeader {
id: string;
retry: number;
}
const isAsyncRefreshHeader = (obj: object): obj is AsyncRefreshHeader =>
'id' in obj && 'retry' in obj;
export const getAsyncRefreshHeader = (
response: AxiosResponse,
): AsyncRefreshHeader | null => {
const value = response.headers['mastodon-async-refresh'] as
| string
| undefined;
if (!value) {
return null;
}
const asyncRefreshHeader: Record<string, unknown> = {};
value.split(/,\s*/).forEach((pair) => {
const [key, val] = pair.split('=', 2);
let typedValue: string | number;
if (key && ['id', 'retry'].includes(key) && val) {
if (val.startsWith('"')) {
typedValue = val.slice(1, -1);
} else {
typedValue = parseInt(val);
}
asyncRefreshHeader[key] = typedValue;
}
});
if (isAsyncRefreshHeader(asyncRefreshHeader)) {
return asyncRefreshHeader;
}
return null;
};
const csrfHeader: RawAxiosRequestHeaders = {}; const csrfHeader: RawAxiosRequestHeaders = {};
const setCSRFHeader = () => { const setCSRFHeader = () => {
@ -127,7 +83,7 @@ export default function api(withAuthorization = true) {
return instance; return instance;
} }
type ApiUrl = `v${1 | '1_alpha' | 2}/${string}`; type ApiUrl = `v${1 | 2}/${string}`;
type RequestParamsOrData = Record<string, unknown>; type RequestParamsOrData = Record<string, unknown>;
export async function apiRequest<ApiResponse = unknown>( export async function apiRequest<ApiResponse = unknown>(

View File

@ -1,5 +0,0 @@
import { apiRequestGet } from 'mastodon/api';
import type { ApiAsyncRefreshJSON } from 'mastodon/api_types/async_refreshes';
export const apiGetAsyncRefresh = (id: string) =>
apiRequestGet<ApiAsyncRefreshJSON>(`v1_alpha/async_refreshes/${id}`);

View File

@ -8,8 +8,3 @@ export const apiReblog = (statusId: string, visibility: StatusVisibility) =>
export const apiUnreblog = (statusId: string) => export const apiUnreblog = (statusId: string) =>
apiRequestPost<Status>(`v1/statuses/${statusId}/unreblog`); apiRequestPost<Status>(`v1/statuses/${statusId}/unreblog`);
export const apiRevokeQuote = (quotedStatusId: string, statusId: string) =>
apiRequestPost<Status>(
`v1/statuses/${quotedStatusId}/quotes/${statusId}/revoke`,
);

View File

@ -1,14 +1,5 @@
import api, { getAsyncRefreshHeader } from 'mastodon/api'; import { apiRequestGet } from 'mastodon/api';
import type { ApiContextJSON } from 'mastodon/api_types/statuses'; import type { ApiContextJSON } from 'mastodon/api_types/statuses';
export const apiGetContext = async (statusId: string) => { export const apiGetContext = (statusId: string) =>
const response = await api().request<ApiContextJSON>({ apiRequestGet<ApiContextJSON>(`v1/statuses/${statusId}/context`);
method: 'GET',
url: `/api/v1/statuses/${statusId}/context`,
});
return {
context: response.data,
refresh: getAsyncRefreshHeader(response),
};
};

View File

@ -37,7 +37,7 @@ export interface BaseApiAccountJSON {
roles?: ApiAccountJSON[]; roles?: ApiAccountJSON[];
statuses_count: number; statuses_count: number;
uri: string; uri: string;
url?: string; url: string;
username: string; username: string;
moved?: ApiAccountJSON; moved?: ApiAccountJSON;
suspended?: boolean; suspended?: boolean;

View File

@ -1,7 +0,0 @@
export interface ApiAsyncRefreshJSON {
async_refresh: {
id: string;
status: 'running' | 'finished';
result_count: number;
};
}

View File

@ -13,7 +13,6 @@ export const allNotificationTypes = [
'favourite', 'favourite',
'reblog', 'reblog',
'mention', 'mention',
'quote',
'poll', 'poll',
'status', 'status',
'update', 'update',
@ -29,7 +28,6 @@ export type NotificationWithStatusType =
| 'reblog' | 'reblog'
| 'status' | 'status'
| 'mention' | 'mention'
| 'quote'
| 'poll' | 'poll'
| 'update'; | 'update';

View File

@ -1,23 +0,0 @@
import type { ApiStatusJSON } from './statuses';
export type ApiQuoteState = 'accepted' | 'pending' | 'revoked' | 'unauthorized';
export type ApiQuotePolicy = 'public' | 'followers' | 'nobody';
interface ApiQuoteEmptyJSON {
state: Exclude<ApiQuoteState, 'accepted'>;
quoted_status: null;
}
interface ApiNestedQuoteJSON {
state: 'accepted';
quoted_status_id: string;
}
interface ApiQuoteAcceptedJSON {
state: 'accepted';
quoted_status: Omit<ApiStatusJSON, 'quote'> & {
quote: ApiNestedQuoteJSON | ApiQuoteEmptyJSON;
};
}
export type ApiQuoteJSON = ApiQuoteAcceptedJSON | ApiQuoteEmptyJSON;

View File

@ -4,7 +4,6 @@ import type { ApiAccountJSON } from './accounts';
import type { ApiCustomEmojiJSON } from './custom_emoji'; import type { ApiCustomEmojiJSON } from './custom_emoji';
import type { ApiMediaAttachmentJSON } from './media_attachments'; import type { ApiMediaAttachmentJSON } from './media_attachments';
import type { ApiPollJSON } from './polls'; import type { ApiPollJSON } from './polls';
import type { ApiQuoteJSON } from './quotes';
// See app/modals/status.rb // See app/modals/status.rb
export type StatusVisibility = export type StatusVisibility =
@ -119,7 +118,6 @@ export interface ApiStatusJSON {
card?: ApiPreviewCardJSON; card?: ApiPreviewCardJSON;
poll?: ApiPollJSON; poll?: ApiPollJSON;
quote?: ApiQuoteJSON;
} }
export interface ApiContextJSON { export interface ApiContextJSON {

View File

@ -2,42 +2,27 @@ import { useCallback } from 'react';
import { useLinks } from 'mastodon/hooks/useLinks'; import { useLinks } from 'mastodon/hooks/useLinks';
import { EmojiHTML } from '../features/emoji/emoji_html';
import { useAppSelector } from '../store';
import { isModernEmojiEnabled } from '../utils/environment';
interface AccountBioProps { interface AccountBioProps {
note: string;
className: string; className: string;
accountId: string; dropdownAccountId?: string;
showDropdown?: boolean;
} }
export const AccountBio: React.FC<AccountBioProps> = ({ export const AccountBio: React.FC<AccountBioProps> = ({
note,
className, className,
accountId, dropdownAccountId,
showDropdown = false,
}) => { }) => {
const handleClick = useLinks(showDropdown); const handleClick = useLinks(!!dropdownAccountId);
const handleNodeChange = useCallback( const handleNodeChange = useCallback(
(node: HTMLDivElement | null) => { (node: HTMLDivElement | null) => {
if (!showDropdown || !node || node.childNodes.length === 0) { if (!dropdownAccountId || !node || node.childNodes.length === 0) {
return; return;
} }
addDropdownToHashtags(node, accountId); addDropdownToHashtags(node, dropdownAccountId);
}, },
[showDropdown, accountId], [dropdownAccountId],
); );
const note = useAppSelector((state) => {
const account = state.accounts.get(accountId);
if (!account) {
return '';
}
return isModernEmojiEnabled() ? account.note : account.note_emojified;
});
const extraEmojis = useAppSelector((state) => {
const account = state.accounts.get(accountId);
return account?.emojis;
});
if (note.length === 0) { if (note.length === 0) {
return null; return null;
@ -46,11 +31,10 @@ export const AccountBio: React.FC<AccountBioProps> = ({
return ( return (
<div <div
className={`${className} translate`} className={`${className} translate`}
dangerouslySetInnerHTML={{ __html: note }}
onClickCapture={handleClick} onClickCapture={handleClick}
ref={handleNodeChange} ref={handleNodeChange}
> />
<EmojiHTML htmlString={note} extraEmojis={extraEmojis} />
</div>
); );
}; };

View File

@ -37,6 +37,7 @@ export const GIFV = forwardRef<HTMLVideoElement, Props>(
role='button' role='button'
tabIndex={0} tabIndex={0}
aria-label={alt} aria-label={alt}
title={alt}
lang={lang} lang={lang}
onClick={handleClick} onClick={handleClick}
/> />
@ -48,6 +49,7 @@ export const GIFV = forwardRef<HTMLVideoElement, Props>(
role='button' role='button'
tabIndex={0} tabIndex={0}
aria-label={alt} aria-label={alt}
title={alt}
lang={lang} lang={lang}
width={width} width={width}
height={height} height={height}

View File

@ -1,171 +0,0 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect } from 'storybook/test';
import type { HandlerMap } from '.';
import { Hotkeys } from '.';
const meta = {
title: 'Components/Hotkeys',
component: Hotkeys,
args: {
global: undefined,
focusable: undefined,
handlers: {},
},
tags: ['test'],
} satisfies Meta<typeof Hotkeys>;
export default meta;
type Story = StoryObj<typeof meta>;
const hotkeyTest: Story['play'] = async ({ canvas, userEvent }) => {
async function confirmHotkey(name: string, shouldFind = true) {
// 'status' is the role of the 'output' element
const output = await canvas.findByRole('status');
if (shouldFind) {
await expect(output).toHaveTextContent(name);
} else {
await expect(output).not.toHaveTextContent(name);
}
}
const button = await canvas.findByRole('button');
await userEvent.click(button);
await userEvent.keyboard('n');
await confirmHotkey('new');
await userEvent.keyboard('/');
await confirmHotkey('search');
await userEvent.keyboard('o');
await confirmHotkey('open');
await userEvent.keyboard('{Alt>}N{/Alt}');
await confirmHotkey('forceNew');
await userEvent.keyboard('gh');
await confirmHotkey('goToHome');
await userEvent.keyboard('gn');
await confirmHotkey('goToNotifications');
await userEvent.keyboard('gf');
await confirmHotkey('goToFavourites');
/**
* Ensure that hotkeys are not triggered when certain
* interactive elements are focused:
*/
await userEvent.keyboard('{enter}');
await confirmHotkey('open', false);
const input = await canvas.findByRole('textbox');
await userEvent.click(input);
await userEvent.keyboard('n');
await confirmHotkey('new', false);
await userEvent.keyboard('{backspace}');
await confirmHotkey('None', false);
/**
* Reset playground:
*/
await userEvent.click(button);
await userEvent.keyboard('{backspace}');
};
export const Default = {
render: function Render() {
const [matchedHotkey, setMatchedHotkey] = useState<keyof HandlerMap | null>(
null,
);
const handlers = {
back: () => {
setMatchedHotkey(null);
},
new: () => {
setMatchedHotkey('new');
},
forceNew: () => {
setMatchedHotkey('forceNew');
},
search: () => {
setMatchedHotkey('search');
},
open: () => {
setMatchedHotkey('open');
},
goToHome: () => {
setMatchedHotkey('goToHome');
},
goToNotifications: () => {
setMatchedHotkey('goToNotifications');
},
goToFavourites: () => {
setMatchedHotkey('goToFavourites');
},
};
return (
<Hotkeys handlers={handlers}>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 8,
padding: '1em',
border: '1px dashed #ccc',
fontSize: 14,
color: '#222',
}}
>
<h1
style={{
fontSize: 22,
marginBottom: '0.3em',
}}
>
Hotkey playground
</h1>
<p>
Last pressed hotkey: <output>{matchedHotkey ?? 'None'}</output>
</p>
<p>
Click within the dashed border and press the &quot;<kbd>n</kbd>
&quot; or &quot;<kbd>/</kbd>&quot; key. Press &quot;
<kbd>Backspace</kbd>&quot; to clear the displayed hotkey.
</p>
<p>
Try typing a sequence, like &quot;<kbd>g</kbd>&quot; shortly
followed by &quot;<kbd>h</kbd>&quot;, &quot;<kbd>n</kbd>&quot;, or
&quot;<kbd>f</kbd>&quot;
</p>
<p>
Note that this playground doesn&apos;t support all hotkeys we use in
the app.
</p>
<p>
When a <button>Button</button> is focused, &quot;
<kbd>Enter</kbd>
&quot; should not trigger &quot;open&quot;, but &quot;<kbd>o</kbd>
&quot; should.
</p>
<p>
When an input element is focused, hotkeys should not interfere with
regular typing:
</p>
<input type='text' />
</div>
</Hotkeys>
);
},
play: hotkeyTest,
};

View File

@ -1,286 +0,0 @@
import { useEffect, useRef } from 'react';
import { normalizeKey, isKeyboardEvent } from './utils';
/**
* In case of multiple hotkeys matching the pressed key(s),
* the hotkey with a higher priority is selected. All others
* are ignored.
*/
const hotkeyPriority = {
singleKey: 0,
combo: 1,
sequence: 2,
} as const;
/**
* This type of function receives a keyboard event and an array of
* previously pressed keys (within the last second), and returns
* `isMatch` (whether the pressed keys match a hotkey) and `priority`
* (a weighting used to resolve conflicts when two hotkeys match the
* pressed keys)
*/
type KeyMatcher = (
event: KeyboardEvent,
bufferedKeys?: string[],
) => {
/**
* Whether the event.key matches the hotkey
*/
isMatch: boolean;
/**
* If there are multiple matching hotkeys, the
* first one with the highest priority will be handled
*/
priority: (typeof hotkeyPriority)[keyof typeof hotkeyPriority];
};
/**
* Matches a single key
*/
function just(keyName: string): KeyMatcher {
return (event) => ({
isMatch:
normalizeKey(event.key) === keyName &&
!event.altKey &&
!event.ctrlKey &&
!event.metaKey,
priority: hotkeyPriority.singleKey,
});
}
/**
* Matches any single key out of those provided
*/
function any(...keys: string[]): KeyMatcher {
return (event) => ({
isMatch: keys.some((keyName) => just(keyName)(event).isMatch),
priority: hotkeyPriority.singleKey,
});
}
/**
* Matches a single key combined with the option/alt modifier
*/
function optionPlus(key: string): KeyMatcher {
return (event) => ({
// Matching against event.code here as alt combos are often
// mapped to other characters
isMatch: event.altKey && event.code === `Key${key.toUpperCase()}`,
priority: hotkeyPriority.combo,
});
}
/**
* Matches when all provided keys are pressed in sequence.
*/
function sequence(...sequence: string[]): KeyMatcher {
return (event, bufferedKeys) => {
const lastKeyInSequence = sequence.at(-1);
const startOfSequence = sequence.slice(0, -1);
const relevantBufferedKeys = bufferedKeys?.slice(-startOfSequence.length);
const bufferMatchesStartOfSequence =
!!relevantBufferedKeys &&
startOfSequence.join('') === relevantBufferedKeys.join('');
return {
isMatch:
bufferMatchesStartOfSequence &&
normalizeKey(event.key) === lastKeyInSequence,
priority: hotkeyPriority.sequence,
};
};
}
/**
* This is a map of all global hotkeys we support.
* To trigger a hotkey, a handler with a matching name must be
* provided to the `useHotkeys` hook or `Hotkeys` component.
*/
const hotkeyMatcherMap = {
help: just('?'),
search: any('s', '/'),
back: just('backspace'),
new: just('n'),
forceNew: optionPlus('n'),
focusColumn: any('1', '2', '3', '4', '5', '6', '7', '8', '9'),
reply: just('r'),
favourite: just('f'),
boost: just('b'),
mention: just('m'),
open: any('enter', 'o'),
openProfile: just('p'),
moveDown: any('down', 'j'),
moveUp: any('up', 'k'),
toggleHidden: just('x'),
toggleSensitive: just('h'),
toggleComposeSpoilers: optionPlus('x'),
openMedia: just('e'),
onTranslate: just('t'),
goToHome: sequence('g', 'h'),
goToNotifications: sequence('g', 'n'),
goToLocal: sequence('g', 'l'),
goToFederated: sequence('g', 't'),
goToDirect: sequence('g', 'd'),
goToStart: sequence('g', 's'),
goToFavourites: sequence('g', 'f'),
goToPinned: sequence('g', 'p'),
goToProfile: sequence('g', 'u'),
goToBlocked: sequence('g', 'b'),
goToMuted: sequence('g', 'm'),
goToRequests: sequence('g', 'r'),
cheat: sequence(
'up',
'up',
'down',
'down',
'left',
'right',
'left',
'right',
'b',
'a',
'enter',
),
} as const;
type HotkeyName = keyof typeof hotkeyMatcherMap;
export type HandlerMap = Partial<
Record<HotkeyName, (event: KeyboardEvent) => void>
>;
export function useHotkeys<T extends HTMLElement>(handlers: HandlerMap) {
const ref = useRef<T>(null);
const bufferedKeys = useRef<string[]>([]);
const sequenceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
/**
* Store the latest handlers object in a ref so we don't need to
* add it as a dependency to the main event listener effect
*/
const handlersRef = useRef(handlers);
useEffect(() => {
handlersRef.current = handlers;
}, [handlers]);
useEffect(() => {
const element = ref.current ?? document;
function listener(event: Event) {
// Ignore key presses from input, textarea, or select elements
const tagName = (event.target as HTMLElement).tagName.toLowerCase();
const shouldHandleEvent =
isKeyboardEvent(event) &&
!event.defaultPrevented &&
!['input', 'textarea', 'select'].includes(tagName) &&
!(
['a', 'button'].includes(tagName) &&
normalizeKey(event.key) === 'enter'
);
if (shouldHandleEvent) {
const matchCandidates: {
handler: (event: KeyboardEvent) => void;
priority: number;
}[] = [];
(Object.keys(hotkeyMatcherMap) as HotkeyName[]).forEach(
(handlerName) => {
const handler = handlersRef.current[handlerName];
if (handler) {
const hotkeyMatcher = hotkeyMatcherMap[handlerName];
const { isMatch, priority } = hotkeyMatcher(
event,
bufferedKeys.current,
);
if (isMatch) {
matchCandidates.push({ handler, priority });
}
}
},
);
// Sort all matches by priority
matchCandidates.sort((a, b) => b.priority - a.priority);
const bestMatchingHandler = matchCandidates.at(0)?.handler;
if (bestMatchingHandler) {
bestMatchingHandler(event);
event.stopPropagation();
event.preventDefault();
}
// Add last keypress to buffer
bufferedKeys.current.push(normalizeKey(event.key));
// Reset the timeout
if (sequenceTimer.current) {
clearTimeout(sequenceTimer.current);
}
sequenceTimer.current = setTimeout(() => {
bufferedKeys.current = [];
}, 1000);
}
}
element.addEventListener('keydown', listener);
return () => {
element.removeEventListener('keydown', listener);
if (sequenceTimer.current) {
clearTimeout(sequenceTimer.current);
}
};
}, []);
return ref;
}
/**
* The Hotkeys component allows us to globally register keyboard combinations
* under a name and assign actions to them, either globally or scoped to a portion
* of the app.
*
* ### How to use
*
* To add a new hotkey, add its key combination to the `hotkeyMatcherMap` object
* and give it a name.
*
* Use the `<Hotkeys>` component or the `useHotkeys` hook in the part of of the app
* where you want to handle the action, and pass in a handlers object.
*
* ```tsx
* <Hotkeys handlers={{open: openStatus}} />
* ```
*
* Now this function will be called when the 'open' hotkey is pressed by the user.
*/
export const Hotkeys: React.FC<{
/**
* An object containing functions to be run when a hotkey is pressed.
* The key must be the name of a registered hotkey, e.g. "help" or "search"
*/
handlers: HandlerMap;
/**
* When enabled, hotkeys will be matched against the document root
* rather than only inside of this component's DOM node.
*/
global?: boolean;
/**
* Allow the rendered `div` to be focused
*/
focusable?: boolean;
children: React.ReactNode;
}> = ({ handlers, global, focusable = true, children }) => {
const ref = useHotkeys<HTMLDivElement>(handlers);
return (
<div ref={global ? undefined : ref} tabIndex={focusable ? -1 : undefined}>
{children}
</div>
);
};

View File

@ -1,29 +0,0 @@
export function isKeyboardEvent(event: Event): event is KeyboardEvent {
return 'key' in event;
}
export function normalizeKey(key: string): string {
const lowerKey = key.toLowerCase();
switch (lowerKey) {
case ' ':
case 'spacebar': // for older browsers
return 'space';
case 'arrowup':
return 'up';
case 'arrowdown':
return 'down';
case 'arrowleft':
return 'left';
case 'arrowright':
return 'right';
case 'esc':
case 'escape':
return 'escape';
default:
return lowerKey;
}
}

View File

@ -102,7 +102,7 @@ export const HoverCardAccount = forwardRef<
<> <>
<div className='hover-card__text-row'> <div className='hover-card__text-row'>
<AccountBio <AccountBio
accountId={account.id} note={account.note_emojified}
className='hover-card__bio' className='hover-card__bio'
/> />
<AccountFields fields={account.fields} limit={2} /> <AccountFields fields={account.fields} limit={2} />

View File

@ -1,63 +0,0 @@
import { useState, useRef, useCallback, useId } from 'react';
import { FormattedMessage } from 'react-intl';
import Overlay from 'react-overlays/Overlay';
export const LearnMoreLink: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const accessibilityId = useId();
const [open, setOpen] = useState(false);
const triggerRef = useRef(null);
const handleClick = useCallback(() => {
setOpen(!open);
}, [open, setOpen]);
return (
<>
<button
className='link-button'
ref={triggerRef}
onClick={handleClick}
aria-expanded={open}
aria-controls={accessibilityId}
>
<FormattedMessage
id='learn_more_link.learn_more'
defaultMessage='Learn more'
/>
</button>
<Overlay
show={open}
rootClose
onHide={handleClick}
offset={[5, 5]}
placement='bottom-end'
target={triggerRef}
>
{({ props }) => (
<div
{...props}
role='region'
id={accessibilityId}
className='account__domain-pill__popout learn-more__popout dropdown-animation'
>
<div className='learn-more__popout__content'>{children}</div>
<div>
<button className='link-button' onClick={handleClick}>
<FormattedMessage
id='learn_more_link.got_it'
defaultMessage='Got it'
/>
</button>
</div>
</div>
)}
</Overlay>
</>
);
};

View File

@ -8,9 +8,10 @@ import { Link } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { HotKeys } from 'react-hotkeys';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react'; import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react'; import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
import { Hotkeys } from 'mastodon/components/hotkeys';
import { ContentWarning } from 'mastodon/components/content_warning'; import { ContentWarning } from 'mastodon/components/content_warning';
import { FilterWarning } from 'mastodon/components/filter_warning'; import { FilterWarning } from 'mastodon/components/filter_warning';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
@ -34,6 +35,7 @@ import StatusActionBar from './status_action_bar';
import StatusContent from './status_content'; import StatusContent from './status_content';
import { StatusThreadLabel } from './status_thread_label'; import { StatusThreadLabel } from './status_thread_label';
import { VisibilityIcon } from './visibility_icon'; import { VisibilityIcon } from './visibility_icon';
const domParser = new DOMParser(); const domParser = new DOMParser();
export const textForScreenReader = (intl, status, rebloggedByText = false) => { export const textForScreenReader = (intl, status, rebloggedByText = false) => {
@ -323,11 +325,11 @@ class Status extends ImmutablePureComponent {
}; };
handleHotkeyMoveUp = e => { handleHotkeyMoveUp = e => {
this.props.onMoveUp?.(this.props.status.get('id'), this.node.getAttribute('data-featured')); this.props.onMoveUp(this.props.status.get('id'), e.target.getAttribute('data-featured'));
}; };
handleHotkeyMoveDown = e => { handleHotkeyMoveDown = e => {
this.props.onMoveDown?.(this.props.status.get('id'), this.node.getAttribute('data-featured')); this.props.onMoveDown(this.props.status.get('id'), e.target.getAttribute('data-featured'));
}; };
handleHotkeyToggleHidden = () => { handleHotkeyToggleHidden = () => {
@ -435,13 +437,13 @@ class Status extends ImmutablePureComponent {
if (hidden) { if (hidden) {
return ( return (
<Hotkeys handlers={handlers} focusable={!unfocusable}> <HotKeys handlers={handlers} tabIndex={unfocusable ? null : -1}>
<div ref={this.handleRef} className={classNames('status__wrapper', { focusable: !this.props.muted })} tabIndex={unfocusable ? null : 0}> <div ref={this.handleRef} className={classNames('status__wrapper', { focusable: !this.props.muted })} tabIndex={unfocusable ? null : 0}>
<span>{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}</span> <span>{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}</span>
{status.get('spoiler_text').length > 0 && (<span>{status.get('spoiler_text')}</span>)} {status.get('spoiler_text').length > 0 && (<span>{status.get('spoiler_text')}</span>)}
{expanded && <span>{status.get('content')}</span>} {expanded && <span>{status.get('content')}</span>}
</div> </div>
</Hotkeys> </HotKeys>
); );
} }
@ -541,7 +543,7 @@ class Status extends ImmutablePureComponent {
const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status); const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status);
return ( return (
<Hotkeys handlers={handlers} focusable={!unfocusable}> <HotKeys handlers={handlers} tabIndex={unfocusable ? null : -1}>
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted || unfocusable ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef} data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined}> <div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted || unfocusable ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef} data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined}>
{!skipPrepend && prepend} {!skipPrepend && prepend}
@ -602,7 +604,7 @@ class Status extends ImmutablePureComponent {
} }
</div> </div>
</div> </div>
</Hotkeys> </HotKeys>
); );
} }

View File

@ -67,28 +67,21 @@ const messages = defineMessages({
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
filter: { id: 'status.filter', defaultMessage: 'Filter this post' }, filter: { id: 'status.filter', defaultMessage: 'Filter this post' },
openOriginalPage: { id: 'account.open_original_page', defaultMessage: 'Open original page' }, openOriginalPage: { id: 'account.open_original_page', defaultMessage: 'Open original page' },
revokeQuote: { id: 'status.revoke_quote', defaultMessage: 'Remove my post from @{name}s post' },
}); });
const mapStateToProps = (state, { status }) => { const mapStateToProps = (state, { status }) => ({
const quotedStatusId = status.getIn(['quote', 'quoted_status']);
return ({
relationship: state.getIn(['relationships', status.getIn(['account', 'id'])]), relationship: state.getIn(['relationships', status.getIn(['account', 'id'])]),
quotedAccountId: quotedStatusId ? state.getIn(['statuses', quotedStatusId, 'account']) : null,
}); });
};
class StatusActionBar extends ImmutablePureComponent { class StatusActionBar extends ImmutablePureComponent {
static propTypes = { static propTypes = {
identity: identityContextPropShape, identity: identityContextPropShape,
status: ImmutablePropTypes.map.isRequired, status: ImmutablePropTypes.map.isRequired,
relationship: ImmutablePropTypes.record, relationship: ImmutablePropTypes.record,
quotedAccountId: ImmutablePropTypes.string,
onReply: PropTypes.func, onReply: PropTypes.func,
onFavourite: PropTypes.func, onFavourite: PropTypes.func,
onReblog: PropTypes.func, onReblog: PropTypes.func,
onDelete: PropTypes.func, onDelete: PropTypes.func,
onRevokeQuote: PropTypes.func,
onDirect: PropTypes.func, onDirect: PropTypes.func,
onMention: PropTypes.func, onMention: PropTypes.func,
onMute: PropTypes.func, onMute: PropTypes.func,
@ -117,7 +110,6 @@ class StatusActionBar extends ImmutablePureComponent {
updateOnProps = [ updateOnProps = [
'status', 'status',
'relationship', 'relationship',
'quotedAccountId',
'withDismiss', 'withDismiss',
]; ];
@ -198,10 +190,6 @@ class StatusActionBar extends ImmutablePureComponent {
} }
}; };
handleRevokeQuoteClick = () => {
this.props.onRevokeQuote(this.props.status);
}
handleBlockClick = () => { handleBlockClick = () => {
const { status, relationship, onBlock, onUnblock } = this.props; const { status, relationship, onBlock, onUnblock } = this.props;
const account = status.get('account'); const account = status.get('account');
@ -253,7 +241,7 @@ class StatusActionBar extends ImmutablePureComponent {
}; };
render () { render () {
const { status, relationship, quotedAccountId, intl, withDismiss, withCounters, scrollKey } = this.props; const { status, relationship, intl, withDismiss, withCounters, scrollKey } = this.props;
const { signedIn, permissions } = this.props.identity; const { signedIn, permissions } = this.props.identity;
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
@ -303,10 +291,6 @@ class StatusActionBar extends ImmutablePureComponent {
menu.push({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.handleDirectClick }); menu.push({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.handleDirectClick });
menu.push(null); menu.push(null);
if (quotedAccountId === me) {
menu.push({ text: intl.formatMessage(messages.revokeQuote, { name: account.get('username') }), action: this.handleRevokeQuoteClick, dangerous: true });
}
if (relationship && relationship.get('muting')) { if (relationship && relationship.get('muting')) {
menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.handleMuteClick }); menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.handleMuteClick });
} else { } else {

View File

@ -14,8 +14,6 @@ import { Icon } from 'mastodon/components/icon';
import { Poll } from 'mastodon/components/poll'; import { Poll } from 'mastodon/components/poll';
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
import { autoPlayGif, languages as preloadedLanguages } from 'mastodon/initial_state'; import { autoPlayGif, languages as preloadedLanguages } from 'mastodon/initial_state';
import { EmojiHTML } from '../features/emoji/emoji_html';
import { isModernEmojiEnabled } from '../utils/environment';
const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top) const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
@ -25,9 +23,6 @@ const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
* @returns {string} * @returns {string}
*/ */
export function getStatusContent(status) { export function getStatusContent(status) {
if (isModernEmojiEnabled()) {
return status.getIn(['translation', 'content']) || status.get('content');
}
return status.getIn(['translation', 'contentHtml']) || status.get('contentHtml'); return status.getIn(['translation', 'contentHtml']) || status.get('contentHtml');
} }
@ -48,13 +43,13 @@ class TranslateButton extends PureComponent {
return ( return (
<div className='translate-button'> <div className='translate-button'>
<button className='link-button' onClick={onClick}>
<FormattedMessage id='status.show_original' defaultMessage='Show original' />
</button>
<div className='translate-button__meta'> <div className='translate-button__meta'>
<FormattedMessage id='status.translated_from_with' defaultMessage='Translated from {lang} using {provider}' values={{ lang: languageName, provider }} /> <FormattedMessage id='status.translated_from_with' defaultMessage='Translated from {lang} using {provider}' values={{ lang: languageName, provider }} />
</div> </div>
<button className='link-button' onClick={onClick}>
<FormattedMessage id='status.show_original' defaultMessage='Show original' />
</button>
</div> </div>
); );
} }
@ -138,16 +133,6 @@ class StatusContent extends PureComponent {
onCollapsedToggle(collapsed); onCollapsedToggle(collapsed);
} }
// Remove quote fallback link from the DOM so it doesn't
// mess with paragraph margins
if (!!status.get('quote')) {
const inlineQuote = node.querySelector('.quote-inline');
if (inlineQuote) {
inlineQuote.remove();
}
}
} }
handleMouseEnter = ({ currentTarget }) => { handleMouseEnter = ({ currentTarget }) => {
@ -243,7 +228,7 @@ class StatusContent extends PureComponent {
const targetLanguages = this.props.languages?.get(status.get('language') || 'und'); const targetLanguages = this.props.languages?.get(status.get('language') || 'und');
const renderTranslate = this.props.onTranslate && this.props.identity.signedIn && ['public', 'unlisted'].includes(status.get('visibility')) && status.get('search_index').trim().length > 0 && targetLanguages?.includes(contentLocale); const renderTranslate = this.props.onTranslate && this.props.identity.signedIn && ['public', 'unlisted'].includes(status.get('visibility')) && status.get('search_index').trim().length > 0 && targetLanguages?.includes(contentLocale);
const content = statusContent ?? getStatusContent(status); const content = { __html: statusContent ?? getStatusContent(status) };
const language = status.getIn(['translation', 'language']) || status.get('language'); const language = status.getIn(['translation', 'language']) || status.get('language');
const classNames = classnames('status__content', { const classNames = classnames('status__content', {
'status__content--with-action': this.props.onClick && this.props.history, 'status__content--with-action': this.props.onClick && this.props.history,
@ -268,12 +253,7 @@ class StatusContent extends PureComponent {
return ( return (
<> <>
<div className={classNames} ref={this.setRef} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> <div className={classNames} ref={this.setRef} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
<EmojiHTML <div className='status__content__text status__content__text--visible translate' lang={language} dangerouslySetInnerHTML={content} />
className='status__content__text status__content__text--visible translate'
lang={language}
htmlString={content}
extraEmojis={status.get('emojis')}
/>
{poll} {poll}
{translateButton} {translateButton}
@ -285,12 +265,7 @@ class StatusContent extends PureComponent {
} else { } else {
return ( return (
<div className={classNames} ref={this.setRef} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> <div className={classNames} ref={this.setRef} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
<EmojiHTML <div className='status__content__text status__content__text--visible translate' lang={language} dangerouslySetInnerHTML={content} />
className='status__content__text status__content__text--visible translate'
lang={language}
htmlString={content}
extraEmojis={status.get('emojis')}
/>
{poll} {poll}
{translateButton} {translateButton}

View File

@ -40,14 +40,6 @@ export default class StatusList extends ImmutablePureComponent {
trackScroll: true, trackScroll: true,
}; };
componentDidMount() {
this.columnHeaderHeight = this.node?.node
? parseFloat(
getComputedStyle(this.node.node).getPropertyValue('--column-header-height')
) || 0
: 0;
}
getFeaturedStatusCount = () => { getFeaturedStatusCount = () => {
return this.props.featuredStatusIds ? this.props.featuredStatusIds.size : 0; return this.props.featuredStatusIds ? this.props.featuredStatusIds.size : 0;
}; };
@ -61,68 +53,34 @@ export default class StatusList extends ImmutablePureComponent {
}; };
handleMoveUp = (id, featured) => { handleMoveUp = (id, featured) => {
const index = this.getCurrentStatusIndex(id, featured); const elementIndex = this.getCurrentStatusIndex(id, featured) - 1;
this._selectChild(id, index, -1); this._selectChild(elementIndex, true);
}; };
handleMoveDown = (id, featured) => { handleMoveDown = (id, featured) => {
const index = this.getCurrentStatusIndex(id, featured); const elementIndex = this.getCurrentStatusIndex(id, featured) + 1;
this._selectChild(id, index, 1); this._selectChild(elementIndex, false);
}; };
_selectChild = (id, index, direction) => {
const listContainer = this.node?.node;
let listItem = listContainer?.querySelector(
// :nth-child uses 1-based indexing
`.item-list > :nth-child(${index + 1 + direction})`
);
if (!listItem) {
return;
}
// If selected container element is empty, we skip it
if (listItem.matches(':empty')) {
this._selectChild(id, index + direction, direction);
return;
}
// Check if the list item is a post
let targetElement = listItem.querySelector('.focusable');
// Otherwise, check if the item contains follow suggestions or
// is a 'load more' button.
if (
!targetElement && (
listItem.querySelector('.inline-follow-suggestions') ||
listItem.matches('.load-more')
)
) {
targetElement = listItem;
}
if (targetElement) {
const elementRect = targetElement.getBoundingClientRect();
const isFullyVisible =
elementRect.top >= this.columnHeaderHeight &&
elementRect.bottom <= window.innerHeight;
if (!isFullyVisible) {
targetElement.scrollIntoView({
block: direction === 1 ? 'start' : 'center',
});
}
targetElement.focus();
}
}
handleLoadOlder = debounce(() => { handleLoadOlder = debounce(() => {
const { statusIds, lastId, onLoadMore } = this.props; const { statusIds, lastId, onLoadMore } = this.props;
onLoadMore(lastId || (statusIds.size > 0 ? statusIds.last() : undefined)); onLoadMore(lastId || (statusIds.size > 0 ? statusIds.last() : undefined));
}, 300, { leading: true }); }, 300, { leading: true });
_selectChild (index, align_top) {
const container = this.node.node;
const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
if (element) {
if (align_top && container.scrollTop > element.offsetTop) {
element.scrollIntoView(true);
} else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
element.scrollIntoView(false);
}
element.focus();
}
}
setRef = c => { setRef = c => {
this.node = c; this.node = c;
}; };

View File

@ -3,15 +3,19 @@ import { useEffect, useMemo } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
import { Link } from 'react-router-dom';
import type { Map as ImmutableMap } from 'immutable'; import type { Map as ImmutableMap } from 'immutable';
import { LearnMoreLink } from 'mastodon/components/learn_more_link'; import ArticleIcon from '@/material-icons/400-24px/article.svg?react';
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
import { Icon } from 'mastodon/components/icon';
import StatusContainer from 'mastodon/containers/status_container'; import StatusContainer from 'mastodon/containers/status_container';
import type { Status } from 'mastodon/models/status'; import type { Status } from 'mastodon/models/status';
import type { RootState } from 'mastodon/store'; import type { RootState } from 'mastodon/store';
import { useAppDispatch, useAppSelector } from 'mastodon/store'; import { useAppDispatch, useAppSelector } from 'mastodon/store';
import QuoteIcon from '../../images/quote.svg?react';
import { fetchStatus } from '../actions/statuses'; import { fetchStatus } from '../actions/statuses';
import { makeGetStatus } from '../selectors'; import { makeGetStatus } from '../selectors';
@ -27,6 +31,7 @@ const QuoteWrapper: React.FC<{
'status__quote--error': isError, 'status__quote--error': isError,
})} })}
> >
<Icon id='quote' icon={QuoteIcon} className='status__quote-icon' />
{children} {children}
</div> </div>
); );
@ -40,20 +45,27 @@ const NestedQuoteLink: React.FC<{
accountId ? state.accounts.get(accountId) : undefined, accountId ? state.accounts.get(accountId) : undefined,
); );
const quoteAuthorName = account?.acct; const quoteAuthorName = account?.display_name_html;
if (!quoteAuthorName) { if (!quoteAuthorName) {
return null; return null;
} }
const quoteAuthorElement = (
<span dangerouslySetInnerHTML={{ __html: quoteAuthorName }} />
);
const quoteUrl = `/@${account.get('acct')}/${status.get('id') as string}`;
return ( return (
<div className='status__quote-author-button'> <Link to={quoteUrl} className='status__quote-author-button'>
<FormattedMessage <FormattedMessage
id='status.quote_post_author' id='status.quote_post_author'
defaultMessage='Quoted a post by @{name}' defaultMessage='Post by {name}'
values={{ name: quoteAuthorName }} values={{ name: quoteAuthorElement }}
/> />
</div> <Icon id='chevron_right' icon={ChevronRightIcon} />
<Icon id='article' icon={ArticleIcon} />
</Link>
); );
}; };
@ -100,42 +112,39 @@ export const QuotedStatus: React.FC<{
defaultMessage='Hidden due to one of your filters' defaultMessage='Hidden due to one of your filters'
/> />
); );
} else if (quoteState === 'deleted') {
quoteError = (
<FormattedMessage
id='status.quote_error.removed'
defaultMessage='This post was removed by its author.'
/>
);
} else if (quoteState === 'unauthorized') {
quoteError = (
<FormattedMessage
id='status.quote_error.unauthorized'
defaultMessage='This post cannot be displayed as you are not authorized to view it.'
/>
);
} else if (quoteState === 'pending') { } else if (quoteState === 'pending') {
quoteError = ( quoteError = (
<>
<FormattedMessage <FormattedMessage
id='status.quote_error.pending_approval' id='status.quote_error.pending_approval'
defaultMessage='Post pending' defaultMessage='This post is pending approval from the original author.'
/> />
<LearnMoreLink>
<h6>
<FormattedMessage
id='status.quote_error.pending_approval_popout.title'
defaultMessage='Pending quote? Remain calm'
/>
</h6>
<p>
<FormattedMessage
id='status.quote_error.pending_approval_popout.body'
defaultMessage='Quotes shared across the Fediverse may take time to display, as different servers have different protocols.'
/>
</p>
</LearnMoreLink>
</>
); );
} else if ( } else if (quoteState === 'rejected' || quoteState === 'revoked') {
!status ||
!quotedStatusId ||
quoteState === 'deleted' ||
quoteState === 'rejected' ||
quoteState === 'revoked' ||
quoteState === 'unauthorized'
) {
quoteError = ( quoteError = (
<FormattedMessage <FormattedMessage
id='status.quote_error.not_available' id='status.quote_error.rejected'
defaultMessage='Post unavailable' defaultMessage='This post cannot be displayed as the original author does not allow it to be quoted.'
/>
);
} else if (!status || !quotedStatusId) {
quoteError = (
<FormattedMessage
id='status.quote_error.not_found'
defaultMessage='This post cannot be displayed.'
/> />
); );
} }
@ -159,7 +168,7 @@ export const QuotedStatus: React.FC<{
isQuotedPost isQuotedPost
id={quotedStatusId} id={quotedStatusId}
contextType={contextType} contextType={contextType}
avatarSize={32} avatarSize={40}
> >
{canRenderChildQuote && ( {canRenderChildQuote && (
<QuotedStatus <QuotedStatus

View File

@ -111,10 +111,6 @@ const mapDispatchToProps = (dispatch, { contextType }) => ({
} }
}, },
onRevokeQuote (status) {
dispatch(openModal({ modalType: 'CONFIRM_REVOKE_QUOTE', modalProps: { statusId: status.get('id'), quotedStatusId: status.getIn(['quote', 'quoted_status']) }}));
},
onEdit (status) { onEdit (status) {
dispatch((_, getState) => { dispatch((_, getState) => {
let state = getState(); let state = getState();

View File

@ -898,7 +898,8 @@ export const AccountHeader: React.FC<{
)} )}
<AccountBio <AccountBio
accountId={accountId} note={account.note_emojified}
dropdownAccountId={accountId}
className='account__header__content' className='account__header__content'
/> />

View File

@ -92,29 +92,10 @@ class ComposeForm extends ImmutablePureComponent {
this.props.onChange(e.target.value); this.props.onChange(e.target.value);
}; };
blurOnEscape = (e) => { handleKeyDown = (e) => {
if (['esc', 'escape'].includes(e.key.toLowerCase())) { if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
e.target.blur();
}
}
handleKeyDownPost = (e) => {
if (e.key.toLowerCase() === 'enter' && (e.ctrlKey || e.metaKey)) {
this.handleSubmit(); this.handleSubmit();
} }
this.blurOnEscape(e);
};
handleKeyDownSpoiler = (e) => {
if (e.key.toLowerCase() === 'enter') {
if (e.ctrlKey || e.metaKey) {
this.handleSubmit();
} else {
e.preventDefault();
this.textareaRef.current?.focus();
}
}
this.blurOnEscape(e);
}; };
getFulltextForCharacterCounting = () => { getFulltextForCharacterCounting = () => {
@ -267,7 +248,7 @@ class ComposeForm extends ImmutablePureComponent {
value={this.props.spoilerText} value={this.props.spoilerText}
disabled={isSubmitting} disabled={isSubmitting}
onChange={this.handleChangeSpoilerText} onChange={this.handleChangeSpoilerText}
onKeyDown={this.handleKeyDownSpoiler} onKeyDown={this.handleKeyDown}
ref={this.setSpoilerText} ref={this.setSpoilerText}
suggestions={this.props.suggestions} suggestions={this.props.suggestions}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
@ -292,7 +273,7 @@ class ComposeForm extends ImmutablePureComponent {
onChange={this.handleChange} onChange={this.handleChange}
suggestions={this.props.suggestions} suggestions={this.props.suggestions}
onFocus={this.handleFocus} onFocus={this.handleFocus}
onKeyDown={this.handleKeyDownPost} onKeyDown={this.handleKeyDown}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested} onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={this.onSuggestionSelected} onSuggestionSelected={this.onSuggestionSelected}

View File

@ -10,13 +10,15 @@ import { createSelector } from '@reduxjs/toolkit';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { HotKeys } from 'react-hotkeys';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react'; import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
import { replyCompose } from 'mastodon/actions/compose'; import { replyCompose } from 'mastodon/actions/compose';
import { markConversationRead, deleteConversation } from 'mastodon/actions/conversations'; import { markConversationRead, deleteConversation } from 'mastodon/actions/conversations';
import { openModal } from 'mastodon/actions/modal'; import { openModal } from 'mastodon/actions/modal';
import { muteStatus, unmuteStatus, toggleStatusSpoilers } from 'mastodon/actions/statuses'; import { muteStatus, unmuteStatus, toggleStatusSpoilers } from 'mastodon/actions/statuses';
import { Hotkeys } from 'mastodon/components/hotkeys';
import AttachmentList from 'mastodon/components/attachment_list'; import AttachmentList from 'mastodon/components/attachment_list';
import AvatarComposite from 'mastodon/components/avatar_composite'; import AvatarComposite from 'mastodon/components/avatar_composite';
import { IconButton } from 'mastodon/components/icon_button'; import { IconButton } from 'mastodon/components/icon_button';
@ -167,7 +169,7 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
}; };
return ( return (
<Hotkeys handlers={handlers}> <HotKeys handlers={handlers}>
<div className={classNames('conversation focusable muted', { unread })} tabIndex={0}> <div className={classNames('conversation focusable muted', { unread })} tabIndex={0}>
<div className='conversation__avatar' onClick={handleClick} role='presentation'> <div className='conversation__avatar' onClick={handleClick} role='presentation'>
<AvatarComposite accounts={accounts} size={48} /> <AvatarComposite accounts={accounts} size={48} />
@ -217,7 +219,7 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
</div> </div>
</div> </div>
</div> </div>
</Hotkeys> </HotKeys>
); );
}; };

View File

@ -1,120 +0,0 @@
// Utility codes
export const VARIATION_SELECTOR_CODE = 0xfe0f;
export const KEYCAP_CODE = 0x20e3;
// Gender codes
export const GENDER_FEMALE_CODE = 0x2640;
export const GENDER_MALE_CODE = 0x2642;
// Skin tone codes
export const SKIN_TONE_CODES = [
0x1f3fb, // Light skin tone
0x1f3fc, // Medium-light skin tone
0x1f3fd, // Medium skin tone
0x1f3fe, // Medium-dark skin tone
0x1f3ff, // Dark skin tone
] as const;
// Emoji rendering modes. A mode is what we are using to render emojis, a style is what the user has selected.
export const EMOJI_MODE_NATIVE = 'native';
export const EMOJI_MODE_NATIVE_WITH_FLAGS = 'native-flags';
export const EMOJI_MODE_TWEMOJI = 'twemoji';
export const EMOJI_TYPE_UNICODE = 'unicode';
export const EMOJI_TYPE_CUSTOM = 'custom';
export const EMOJI_STATE_MISSING = 'missing';
export const EMOJIS_WITH_DARK_BORDER = [
'🎱', // 1F3B1
'🐜', // 1F41C
'⚫', // 26AB
'🖤', // 1F5A4
'⬛', // 2B1B
'◼️', // 25FC-FE0F
'◾', // 25FE
'◼️', // 25FC-FE0F
'✒️', // 2712-FE0F
'▪️', // 25AA-FE0F
'💣', // 1F4A3
'🎳', // 1F3B3
'📷', // 1F4F7
'📸', // 1F4F8
'♣️', // 2663-FE0F
'🕶️', // 1F576-FE0F
'✴️', // 2734-FE0F
'🔌', // 1F50C
'💂‍♀️', // 1F482-200D-2640-FE0F
'📽️', // 1F4FD-FE0F
'🍳', // 1F373
'🦍', // 1F98D
'💂', // 1F482
'🔪', // 1F52A
'🕳️', // 1F573-FE0F
'🕹️', // 1F579-FE0F
'🕋', // 1F54B
'🖊️', // 1F58A-FE0F
'🖋️', // 1F58B-FE0F
'💂‍♂️', // 1F482-200D-2642-FE0F
'🎤', // 1F3A4
'🎓', // 1F393
'🎥', // 1F3A5
'🎼', // 1F3BC
'♠️', // 2660-FE0F
'🎩', // 1F3A9
'🦃', // 1F983
'📼', // 1F4FC
'📹', // 1F4F9
'🎮', // 1F3AE
'🐃', // 1F403
'🏴', // 1F3F4
'🐞', // 1F41E
'🕺', // 1F57A
'📱', // 1F4F1
'📲', // 1F4F2
'🚲', // 1F6B2
'🪮', // 1FAA6
'🐦‍⬛', // 1F426-200D-2B1B
];
export const EMOJIS_WITH_LIGHT_BORDER = [
'👽', // 1F47D
'⚾', // 26BE
'🐔', // 1F414
'☁️', // 2601-FE0F
'💨', // 1F4A8
'🕊️', // 1F54A-FE0F
'👀', // 1F440
'🍥', // 1F365
'👻', // 1F47B
'🐐', // 1F410
'❕', // 2755
'❔', // 2754
'⛸️', // 26F8-FE0F
'🌩️', // 1F329-FE0F
'🔊', // 1F50A
'🔇', // 1F507
'📃', // 1F4C3
'🌧️', // 1F327-FE0F
'🐏', // 1F40F
'🍚', // 1F35A
'🍙', // 1F359
'🐓', // 1F413
'🐑', // 1F411
'💀', // 1F480
'☠️', // 2620-FE0F
'🌨️', // 1F328-FE0F
'🔉', // 1F509
'🔈', // 1F508
'💬', // 1F4AC
'💭', // 1F4AD
'🏐', // 1F3D0
'🏳️', // 1F3F3-FE0F
'⚪', // 26AA
'⬜', // 2B1C
'◽', // 25FD
'◻️', // 25FB-FE0F
'▫️', // 25AB-FE0F
'🪽', // 1FAE8
'🪿', // 1FABF
];

View File

@ -1,139 +0,0 @@
import { IDBFactory } from 'fake-indexeddb';
import { unicodeEmojiFactory } from '@/testing/factories';
import {
putEmojiData,
loadEmojiByHexcode,
searchEmojisByHexcodes,
searchEmojisByTag,
testClear,
testGet,
} from './database';
describe('emoji database', () => {
afterEach(() => {
testClear();
indexedDB = new IDBFactory();
});
describe('putEmojiData', () => {
test('adds to loaded locales', async () => {
const { loadedLocales } = await testGet();
expect(loadedLocales).toHaveLength(0);
await putEmojiData([], 'en');
expect(loadedLocales).toContain('en');
});
test('loads emoji into indexedDB', async () => {
await putEmojiData([unicodeEmojiFactory()], 'en');
const { db } = await testGet();
await expect(db.get('en', 'test')).resolves.toEqual(
unicodeEmojiFactory(),
);
});
});
describe('loadEmojiByHexcode', () => {
test('throws if the locale is not loaded', async () => {
await expect(loadEmojiByHexcode('en', 'test')).rejects.toThrowError(
'Locale en',
);
});
test('retrieves the emoji', async () => {
await putEmojiData([unicodeEmojiFactory()], 'en');
await expect(loadEmojiByHexcode('test', 'en')).resolves.toEqual(
unicodeEmojiFactory(),
);
});
test('returns undefined if not found', async () => {
await putEmojiData([], 'en');
await expect(loadEmojiByHexcode('test', 'en')).resolves.toBeUndefined();
});
});
describe('searchEmojisByHexcodes', () => {
const data = [
unicodeEmojiFactory({ hexcode: 'not a number' }),
unicodeEmojiFactory({ hexcode: '1' }),
unicodeEmojiFactory({ hexcode: '2' }),
unicodeEmojiFactory({ hexcode: '3' }),
unicodeEmojiFactory({ hexcode: 'another not a number' }),
];
beforeEach(async () => {
await putEmojiData(data, 'en');
});
test('finds emoji in consecutive range', async () => {
const actual = await searchEmojisByHexcodes(['1', '2', '3'], 'en');
expect(actual).toHaveLength(3);
});
test('finds emoji in split range', async () => {
const actual = await searchEmojisByHexcodes(['1', '3'], 'en');
expect(actual).toHaveLength(2);
expect(actual).toContainEqual(data.at(1));
expect(actual).toContainEqual(data.at(3));
});
test('finds emoji with non-numeric range', async () => {
const actual = await searchEmojisByHexcodes(
['3', 'not a number', '1'],
'en',
);
expect(actual).toHaveLength(3);
expect(actual).toContainEqual(data.at(0));
expect(actual).toContainEqual(data.at(1));
expect(actual).toContainEqual(data.at(3));
});
test('not found emoji are not returned', async () => {
const actual = await searchEmojisByHexcodes(['not found'], 'en');
expect(actual).toHaveLength(0);
});
test('only found emojis are returned', async () => {
const actual = await searchEmojisByHexcodes(
['another not a number', 'not found'],
'en',
);
expect(actual).toHaveLength(1);
expect(actual).toContainEqual(data.at(4));
});
});
describe('searchEmojisByTag', () => {
const data = [
unicodeEmojiFactory({ hexcode: 'test1', tags: ['test 1'] }),
unicodeEmojiFactory({
hexcode: 'test2',
tags: ['test 2', 'something else'],
}),
unicodeEmojiFactory({ hexcode: 'test3', tags: ['completely different'] }),
];
beforeEach(async () => {
await putEmojiData(data, 'en');
});
test('finds emojis with tag', async () => {
const actual = await searchEmojisByTag('test 1', 'en');
expect(actual).toHaveLength(1);
expect(actual).toContainEqual(data.at(0));
});
test('finds emojis starting with tag', async () => {
const actual = await searchEmojisByTag('test', 'en');
expect(actual).toHaveLength(2);
expect(actual).not.toContainEqual(data.at(2));
});
test('does not find emojis ending with tag', async () => {
const actual = await searchEmojisByTag('else', 'en');
expect(actual).toHaveLength(0);
});
test('finds nothing with invalid tag', async () => {
const actual = await searchEmojisByTag('not found', 'en');
expect(actual).toHaveLength(0);
});
});
});

View File

@ -1,221 +0,0 @@
import { SUPPORTED_LOCALES } from 'emojibase';
import type { Locale } from 'emojibase';
import type { DBSchema, IDBPDatabase } from 'idb';
import { openDB } from 'idb';
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale';
import type {
CustomEmojiData,
UnicodeEmojiData,
LocaleOrCustom,
} from './types';
import { emojiLogger } from './utils';
interface EmojiDB extends LocaleTables, DBSchema {
custom: {
key: string;
value: CustomEmojiData;
indexes: {
category: string;
};
};
etags: {
key: LocaleOrCustom;
value: string;
};
}
interface LocaleTable {
key: string;
value: UnicodeEmojiData;
indexes: {
group: number;
label: string;
order: number;
tags: string[];
};
}
type LocaleTables = Record<Locale, LocaleTable>;
type Database = IDBPDatabase<EmojiDB>;
const SCHEMA_VERSION = 1;
const loadedLocales = new Set<Locale>();
const log = emojiLogger('database');
// Loads the database in a way that ensures it's only loaded once.
const loadDB = (() => {
let dbPromise: Promise<Database> | null = null;
// Actually load the DB.
async function initDB() {
const db = await openDB<EmojiDB>('mastodon-emoji', SCHEMA_VERSION, {
upgrade(database) {
const customTable = database.createObjectStore('custom', {
keyPath: 'shortcode',
autoIncrement: false,
});
customTable.createIndex('category', 'category');
database.createObjectStore('etags');
for (const locale of SUPPORTED_LOCALES) {
const localeTable = database.createObjectStore(locale, {
keyPath: 'hexcode',
autoIncrement: false,
});
localeTable.createIndex('group', 'group');
localeTable.createIndex('label', 'label');
localeTable.createIndex('order', 'order');
localeTable.createIndex('tags', 'tags', { multiEntry: true });
}
},
});
await syncLocales(db);
return db;
}
// Loads the database, or returns the existing promise if it hasn't resolved yet.
const loadPromise = async (): Promise<Database> => {
if (dbPromise) {
return dbPromise;
}
dbPromise = initDB();
return dbPromise;
};
// Special way to reset the database, used for unit testing.
loadPromise.reset = () => {
dbPromise = null;
};
return loadPromise;
})();
export async function putEmojiData(emojis: UnicodeEmojiData[], locale: Locale) {
loadedLocales.add(locale);
const db = await loadDB();
const trx = db.transaction(locale, 'readwrite');
await Promise.all(emojis.map((emoji) => trx.store.put(emoji)));
await trx.done;
}
export async function putCustomEmojiData(emojis: CustomEmojiData[]) {
const db = await loadDB();
const trx = db.transaction('custom', 'readwrite');
await Promise.all(emojis.map((emoji) => trx.store.put(emoji)));
await trx.done;
}
export async function putLatestEtag(etag: string, localeString: string) {
const locale = toSupportedLocaleOrCustom(localeString);
const db = await loadDB();
await db.put('etags', etag, locale);
}
export async function loadEmojiByHexcode(
hexcode: string,
localeString: string,
) {
const db = await loadDB();
const locale = toLoadedLocale(localeString);
return db.get(locale, hexcode);
}
export async function searchEmojisByHexcodes(
hexcodes: string[],
localeString: string,
) {
const db = await loadDB();
const locale = toLoadedLocale(localeString);
const sortedCodes = hexcodes.toSorted();
const results = await db.getAll(
locale,
IDBKeyRange.bound(sortedCodes.at(0), sortedCodes.at(-1)),
);
return results.filter((emoji) => hexcodes.includes(emoji.hexcode));
}
export async function searchEmojisByTag(tag: string, localeString: string) {
const db = await loadDB();
const locale = toLoadedLocale(localeString);
const range = IDBKeyRange.bound(
tag.toLowerCase(),
`${tag.toLowerCase()}\uffff`,
);
return db.getAllFromIndex(locale, 'tags', range);
}
export async function loadCustomEmojiByShortcode(shortcode: string) {
const db = await loadDB();
return db.get('custom', shortcode);
}
export async function searchCustomEmojisByShortcodes(shortcodes: string[]) {
const db = await loadDB();
const sortedCodes = shortcodes.toSorted();
const results = await db.getAll(
'custom',
IDBKeyRange.bound(sortedCodes.at(0), sortedCodes.at(-1)),
);
return results.filter((emoji) => shortcodes.includes(emoji.shortcode));
}
export async function loadLatestEtag(localeString: string) {
const locale = toSupportedLocaleOrCustom(localeString);
const db = await loadDB();
const rowCount = await db.count(locale);
if (!rowCount) {
return null; // No data for this locale, return null even if there is an etag.
}
const etag = await db.get('etags', locale);
return etag ?? null;
}
// Private functions
async function syncLocales(db: Database) {
const locales = await Promise.all(
SUPPORTED_LOCALES.map(
async (locale) =>
[locale, await hasLocale(locale, db)] satisfies [Locale, boolean],
),
);
for (const [locale, loaded] of locales) {
if (loaded) {
loadedLocales.add(locale);
} else {
loadedLocales.delete(locale);
}
}
log('Loaded %d locales: %o', loadedLocales.size, loadedLocales);
}
function toLoadedLocale(localeString: string) {
const locale = toSupportedLocale(localeString);
if (localeString !== locale) {
log(`Locale ${locale} is different from provided ${localeString}`);
}
if (!loadedLocales.has(locale)) {
throw new Error(`Locale ${locale} is not loaded in emoji database`);
}
return locale;
}
async function hasLocale(locale: Locale, db: Database): Promise<boolean> {
if (loadedLocales.has(locale)) {
return true;
}
const rowCount = await db.count(locale);
return !!rowCount;
}
// Testing helpers
export async function testGet() {
const db = await loadDB();
return { db, loadedLocales };
}
export function testClear() {
loadedLocales.clear();
loadDB.reset();
}

View File

@ -1,48 +0,0 @@
import type { ComponentPropsWithoutRef, ElementType } from 'react';
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
import { useEmojify } from './hooks';
import type { CustomEmojiMapArg } from './types';
type EmojiHTMLProps<Element extends ElementType = 'div'> = Omit<
ComponentPropsWithoutRef<Element>,
'dangerouslySetInnerHTML'
> & {
htmlString: string;
extraEmojis?: CustomEmojiMapArg;
as?: Element;
};
export const ModernEmojiHTML = <Element extends ElementType>({
extraEmojis,
htmlString,
as: asElement, // Rename for syntax highlighting
...props
}: EmojiHTMLProps<Element>) => {
const Wrapper = asElement ?? 'div';
const emojifiedHtml = useEmojify(htmlString, extraEmojis);
if (emojifiedHtml === null) {
return null;
}
return (
<Wrapper {...props} dangerouslySetInnerHTML={{ __html: emojifiedHtml }} />
);
};
export const EmojiHTML = <Element extends ElementType>(
props: EmojiHTMLProps<Element>,
) => {
if (isModernEmojiEnabled()) {
return <ModernEmojiHTML {...props} />;
}
const Wrapper = props.as ?? 'div';
return (
<Wrapper
{...props}
dangerouslySetInnerHTML={{ __html: props.htmlString }}
/>
);
};

View File

@ -1,77 +0,0 @@
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
import { isList } from 'immutable';
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
import { useAppSelector } from '@/mastodon/store';
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
import { toSupportedLocale } from './locale';
import { determineEmojiMode } from './mode';
import type {
CustomEmojiMapArg,
EmojiAppState,
ExtraCustomEmojiMap,
} from './types';
import { stringHasAnyEmoji } from './utils';
export function useEmojify(text: string, extraEmojis?: CustomEmojiMapArg) {
const [emojifiedText, setEmojifiedText] = useState<string | null>(null);
const appState = useEmojiAppState();
const extra: ExtraCustomEmojiMap = useMemo(() => {
if (!extraEmojis) {
return {};
}
if (isList(extraEmojis)) {
return (
extraEmojis.toJS() as ApiCustomEmojiJSON[]
).reduce<ExtraCustomEmojiMap>(
(acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }),
{},
);
}
return extraEmojis;
}, [extraEmojis]);
const emojify = useCallback(
async (input: string) => {
const wrapper = document.createElement('div');
wrapper.innerHTML = input;
const { emojifyElement } = await import('./render');
const result = await emojifyElement(wrapper, appState, extra);
if (result) {
setEmojifiedText(result.innerHTML);
} else {
setEmojifiedText(input);
}
},
[appState, extra],
);
useLayoutEffect(() => {
if (isModernEmojiEnabled() && !!text.trim() && stringHasAnyEmoji(text)) {
void emojify(text);
} else {
// If no emoji or we don't want to render, fall back.
setEmojifiedText(text);
}
}, [emojify, text]);
return emojifiedText;
}
export function useEmojiAppState(): EmojiAppState {
const locale = useAppSelector((state) =>
toSupportedLocale(state.meta.get('locale') as string),
);
const mode = useAppSelector((state) =>
determineEmojiMode(state.meta.get('emoji_style') as string),
);
return {
currentLocale: locale,
locales: [locale],
mode,
darkTheme: document.body.classList.contains('theme-default'),
};
}

View File

@ -1,73 +0,0 @@
import initialState from '@/mastodon/initial_state';
import { loadWorker } from '@/mastodon/utils/workers';
import { toSupportedLocale } from './locale';
import { emojiLogger } from './utils';
const userLocale = toSupportedLocale(initialState?.meta.locale ?? 'en');
let worker: Worker | null = null;
const log = emojiLogger('index');
export function initializeEmoji() {
log('initializing emojis');
if (!worker && 'Worker' in window) {
try {
worker = loadWorker(new URL('./worker', import.meta.url), {
type: 'module',
});
} catch (err) {
console.warn('Error creating web worker:', err);
}
}
if (worker) {
// Assign worker to const to make TS happy inside the event listener.
const thisWorker = worker;
const timeoutId = setTimeout(() => {
log('worker is not ready after timeout');
worker = null;
void fallbackLoad();
}, 500);
thisWorker.addEventListener('message', (event: MessageEvent<string>) => {
const { data: message } = event;
if (message === 'ready') {
log('worker ready, loading data');
clearTimeout(timeoutId);
thisWorker.postMessage('custom');
void loadEmojiLocale(userLocale);
// Load English locale as well, because people are still used to
// using it from before we supported other locales.
if (userLocale !== 'en') {
void loadEmojiLocale('en');
}
} else {
log('got worker message: %s', message);
}
});
} else {
void fallbackLoad();
}
}
async function fallbackLoad() {
log('falling back to main thread for loading');
const { importCustomEmojiData } = await import('./loader');
await importCustomEmojiData();
await loadEmojiLocale(userLocale);
if (userLocale !== 'en') {
await loadEmojiLocale('en');
}
}
export async function loadEmojiLocale(localeString: string) {
const locale = toSupportedLocale(localeString);
if (worker) {
worker.postMessage(locale);
} else {
const { importEmojiData } = await import('./loader');
await importEmojiData(locale);
}
}

View File

@ -1,84 +0,0 @@
import { flattenEmojiData } from 'emojibase';
import type { CompactEmoji, FlatCompactEmoji } from 'emojibase';
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
import {
putEmojiData,
putCustomEmojiData,
loadLatestEtag,
putLatestEtag,
} from './database';
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale';
import type { LocaleOrCustom } from './types';
import { emojiLogger } from './utils';
const log = emojiLogger('loader');
export async function importEmojiData(localeString: string) {
const locale = toSupportedLocale(localeString);
const emojis = await fetchAndCheckEtag<CompactEmoji[]>(locale);
if (!emojis) {
return;
}
const flattenedEmojis: FlatCompactEmoji[] = flattenEmojiData(emojis);
log('loaded %d for %s locale', flattenedEmojis.length, locale);
await putEmojiData(flattenedEmojis, locale);
}
export async function importCustomEmojiData() {
const emojis = await fetchAndCheckEtag<ApiCustomEmojiJSON[]>('custom');
if (!emojis) {
return;
}
log('loaded %d custom emojis', emojis.length);
await putCustomEmojiData(emojis);
}
async function fetchAndCheckEtag<ResultType extends object[]>(
localeOrCustom: LocaleOrCustom,
): Promise<ResultType | null> {
const locale = toSupportedLocaleOrCustom(localeOrCustom);
// Use location.origin as this script may be loaded from a CDN domain.
const url = new URL(location.origin);
if (locale === 'custom') {
url.pathname = '/api/v1/custom_emojis';
} else {
// This doesn't use isDevelopment() as that module loads initial state
// which breaks workers, as they cannot access the DOM.
url.pathname = `/packs${import.meta.env.DEV ? '-dev' : ''}/emoji/${locale}.json`;
}
const oldEtag = await loadLatestEtag(locale);
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
'If-None-Match': oldEtag ?? '', // Send the old ETag to check for modifications
},
});
// If not modified, return null
if (response.status === 304) {
return null;
}
if (!response.ok) {
throw new Error(
`Failed to fetch emoji data for ${localeOrCustom}: ${response.statusText}`,
);
}
const data = (await response.json()) as ResultType;
if (!Array.isArray(data)) {
throw new Error(
`Unexpected data format for ${localeOrCustom}: expected an array`,
);
}
// Store the ETag for future requests
const etag = response.headers.get('ETag');
if (etag) {
await putLatestEtag(etag, localeOrCustom);
}
return data;
}

View File

@ -1,29 +0,0 @@
import { SUPPORTED_LOCALES } from 'emojibase';
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale';
describe('toSupportedLocale', () => {
test('returns the same locale if it is supported', () => {
for (const locale of SUPPORTED_LOCALES) {
expect(toSupportedLocale(locale)).toBe(locale);
}
});
test('returns "en" for unsupported locales', () => {
const unsupportedLocales = ['xx', 'fr-CA'];
for (const locale of unsupportedLocales) {
expect(toSupportedLocale(locale)).toBe('en');
}
});
});
describe('toSupportedLocaleOrCustom', () => {
test('returns custom for "custom" locale', () => {
expect(toSupportedLocaleOrCustom('custom')).toBe('custom');
});
test('returns supported locale for valid locales', () => {
for (const locale of SUPPORTED_LOCALES) {
expect(toSupportedLocaleOrCustom(locale)).toBe(locale);
}
});
});

View File

@ -1,23 +0,0 @@
import type { Locale } from 'emojibase';
import { SUPPORTED_LOCALES } from 'emojibase';
import type { LocaleOrCustom } from './types';
export function toSupportedLocale(localeBase: string): Locale {
const locale = localeBase.toLowerCase();
if (isSupportedLocale(locale)) {
return locale;
}
return 'en'; // Default to English if unsupported
}
export function toSupportedLocaleOrCustom(locale: string): LocaleOrCustom {
if (locale.toLowerCase() === 'custom') {
return 'custom';
}
return toSupportedLocale(locale);
}
function isSupportedLocale(locale: string): locale is Locale {
return SUPPORTED_LOCALES.includes(locale.toLowerCase() as Locale);
}

View File

@ -1,119 +0,0 @@
// Credit to Nolan Lawson for the original implementation.
// See: https://github.com/nolanlawson/emoji-picker-element/blob/master/src/picker/utils/testColorEmojiSupported.js
import { isDevelopment } from '@/mastodon/utils/environment';
import {
EMOJI_MODE_NATIVE,
EMOJI_MODE_NATIVE_WITH_FLAGS,
EMOJI_MODE_TWEMOJI,
} from './constants';
import type { EmojiMode } from './types';
type Feature = Uint8ClampedArray;
// See: https://github.com/nolanlawson/emoji-picker-element/blob/master/src/picker/constants.js
const FONT_FAMILY =
'"Twemoji Mozilla","Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol",' +
'"Noto Color Emoji","EmojiOne Color","Android Emoji",sans-serif';
function getTextFeature(text: string, color: string) {
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 1;
const ctx = canvas.getContext('2d', {
// Improves the performance of `getImageData()`
// https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/getContextAttributes#willreadfrequently
willReadFrequently: true,
});
if (!ctx) {
throw new Error('Canvas context not available');
}
ctx.textBaseline = 'top';
ctx.font = `100px ${FONT_FAMILY}`;
ctx.fillStyle = color;
ctx.scale(0.01, 0.01);
ctx.fillText(text, 0, 0);
return ctx.getImageData(0, 0, 1, 1).data satisfies Feature;
}
function compareFeatures(feature1: Feature, feature2: Feature) {
const feature1Str = [...feature1].join(',');
const feature2Str = [...feature2].join(',');
// This is RGBA, so for 0,0,0, we are checking that the first RGB is not all zeroes.
// Most of the time when unsupported this is 0,0,0,0, but on Chrome on Mac it is
// 0,0,0,61 - there is a transparency here.
return feature1Str === feature2Str && !feature1Str.startsWith('0,0,0,');
}
function testEmojiSupport(text: string) {
// Render white and black and then compare them to each other and ensure they're the same
// color, and neither one is black. This shows that the emoji was rendered in color.
const feature1 = getTextFeature(text, '#000');
const feature2 = getTextFeature(text, '#fff');
return compareFeatures(feature1, feature2);
}
const EMOJI_VERSION_TEST_EMOJI = '🫨'; // shaking head, from v15
const EMOJI_FLAG_TEST_EMOJI = '🇨🇭';
export function determineEmojiMode(style: string): EmojiMode {
if (style === EMOJI_MODE_NATIVE) {
// If flags are not supported, we replace them with Twemoji.
if (shouldReplaceFlags()) {
return EMOJI_MODE_NATIVE_WITH_FLAGS;
}
return EMOJI_MODE_NATIVE;
}
if (style === EMOJI_MODE_TWEMOJI) {
return EMOJI_MODE_TWEMOJI;
}
// Auto style so determine based on browser capabilities.
if (shouldUseTwemoji()) {
return EMOJI_MODE_TWEMOJI;
} else if (shouldReplaceFlags()) {
return EMOJI_MODE_NATIVE_WITH_FLAGS;
}
return EMOJI_MODE_NATIVE;
}
export function shouldUseTwemoji(): boolean {
if (typeof window === 'undefined') {
return false;
}
try {
// Test a known color emoji to see if 15.1 is supported.
return !testEmojiSupport(EMOJI_VERSION_TEST_EMOJI);
} catch (err: unknown) {
// If an error occurs, fall back to Twemoji to be safe.
if (isDevelopment()) {
console.warn(
'Emoji rendering test failed, defaulting to Twemoji. Error:',
err,
);
}
return true;
}
}
// Based on https://github.com/talkjs/country-flag-emoji-polyfill/blob/master/src/index.ts#L19
export function shouldReplaceFlags(): boolean {
if (typeof window === 'undefined') {
return false;
}
try {
// Test a known flag emoji to see if it is rendered in color.
return !testEmojiSupport(EMOJI_FLAG_TEST_EMOJI);
} catch (err: unknown) {
// If an error occurs, assume flags should be replaced.
if (isDevelopment()) {
console.warn(
'Flag emoji rendering test failed, defaulting to replacement. Error:',
err,
);
}
return true;
}
}

View File

@ -1,101 +0,0 @@
import { readdir } from 'fs/promises';
import { basename, resolve } from 'path';
import { flattenEmojiData } from 'emojibase';
import unicodeRawEmojis from 'emojibase-data/en/data.json';
import {
twemojiHasBorder,
twemojiToUnicodeInfo,
unicodeToTwemojiHex,
CODES_WITH_DARK_BORDER,
CODES_WITH_LIGHT_BORDER,
emojiToUnicodeHex,
} from './normalize';
const emojiSVGFiles = await readdir(
// This assumes tests are run from project root
resolve(process.cwd(), 'public/emoji'),
{
withFileTypes: true,
},
);
const svgFileNames = emojiSVGFiles
.filter((file) => file.isFile() && file.name.endsWith('.svg'))
.map((file) => basename(file.name, '.svg'));
const svgFileNamesWithoutBorder = svgFileNames.filter(
(fileName) => !fileName.endsWith('_border'),
);
const unicodeEmojis = flattenEmojiData(unicodeRawEmojis);
describe('emojiToUnicodeHex', () => {
test.concurrent.for([
['🎱', '1F3B1'],
['🐜', '1F41C'],
['⚫', '26AB'],
['🖤', '1F5A4'],
['💀', '1F480'],
['💂‍♂️', '1F482-200D-2642-FE0F'],
] as const)(
'emojiToUnicodeHex converts %s to %s',
([emoji, hexcode], { expect }) => {
expect(emojiToUnicodeHex(emoji)).toBe(hexcode);
},
);
});
describe('unicodeToTwemojiHex', () => {
test.concurrent.for(
unicodeEmojis
// Our version of Twemoji only supports up to version 15.1
.filter((emoji) => emoji.version < 16)
.map((emoji) => [emoji.hexcode, emoji.label] as [string, string]),
)('verifying an emoji exists for %s (%s)', ([hexcode], { expect }) => {
const result = unicodeToTwemojiHex(hexcode);
expect(svgFileNamesWithoutBorder).toContain(result);
});
});
describe('twemojiHasBorder', () => {
test.concurrent.for(
svgFileNames
.filter((file) => file.endsWith('_border'))
.map((file) => {
const hexCode = file.replace('_border', '');
return [
hexCode,
CODES_WITH_LIGHT_BORDER.includes(hexCode.toUpperCase()),
CODES_WITH_DARK_BORDER.includes(hexCode.toUpperCase()),
] as const;
}),
)('twemojiHasBorder for %s', ([hexCode, isLight, isDark], { expect }) => {
const result = twemojiHasBorder(hexCode);
expect(result).toHaveProperty('hexCode', hexCode);
expect(result).toHaveProperty('hasLightBorder', isLight);
expect(result).toHaveProperty('hasDarkBorder', isDark);
});
});
describe('twemojiToUnicodeInfo', () => {
const unicodeCodeSet = new Set(unicodeEmojis.map((emoji) => emoji.hexcode));
test.concurrent.for(svgFileNamesWithoutBorder)(
'verifying SVG file %s maps to Unicode emoji',
(svgFileName, { expect }) => {
assert(!!svgFileName);
const result = twemojiToUnicodeInfo(svgFileName);
const hexcode = typeof result === 'string' ? result : result.unqualified;
if (!hexcode) {
// No hexcode means this is a special case like the Shibuya 109 emoji
expect(result).toHaveProperty('label');
return;
}
assert(!!hexcode);
expect(
unicodeCodeSet.has(hexcode),
`${hexcode} (${svgFileName}) not found`,
).toBeTruthy();
},
);
});

View File

@ -1,168 +0,0 @@
import {
VARIATION_SELECTOR_CODE,
KEYCAP_CODE,
GENDER_FEMALE_CODE,
GENDER_MALE_CODE,
SKIN_TONE_CODES,
EMOJIS_WITH_DARK_BORDER,
EMOJIS_WITH_LIGHT_BORDER,
} from './constants';
import type { TwemojiBorderInfo } from './types';
// Misc codes that have special handling
const SKIER_CODE = 0x26f7;
const CHRISTMAS_TREE_CODE = 0x1f384;
const MR_CLAUS_CODE = 0x1f385;
const EYE_CODE = 0x1f441;
const LEVITATING_PERSON_CODE = 0x1f574;
const SPEECH_BUBBLE_CODE = 0x1f5e8;
const MS_CLAUS_CODE = 0x1f936;
export function emojiToUnicodeHex(emoji: string): string {
const codes: number[] = [];
for (const char of emoji) {
const code = char.codePointAt(0);
if (code !== undefined) {
codes.push(code);
}
}
return hexNumbersToString(codes);
}
export function unicodeToTwemojiHex(unicodeHex: string): string {
const codes = hexStringToNumbers(unicodeHex);
const normalizedCodes: number[] = [];
for (let i = 0; i < codes.length; i++) {
const code = codes[i];
if (!code) {
continue;
}
// Some emoji have their variation selector removed
if (code === VARIATION_SELECTOR_CODE) {
// Key emoji
if (i === 1 && codes.at(-1) === KEYCAP_CODE) {
continue;
}
// Eye in speech bubble
if (codes.at(0) === EYE_CODE && codes.at(-2) === SPEECH_BUBBLE_CODE) {
continue;
}
}
// This removes zero padding to correctly match the SVG filenames
normalizedCodes.push(code);
}
return hexNumbersToString(normalizedCodes, 0).toLowerCase();
}
export const CODES_WITH_DARK_BORDER =
EMOJIS_WITH_DARK_BORDER.map(emojiToUnicodeHex);
export const CODES_WITH_LIGHT_BORDER =
EMOJIS_WITH_LIGHT_BORDER.map(emojiToUnicodeHex);
export function twemojiHasBorder(twemojiHex: string): TwemojiBorderInfo {
const normalizedHex = twemojiHex.toUpperCase();
let hasLightBorder = false;
let hasDarkBorder = false;
if (CODES_WITH_LIGHT_BORDER.includes(normalizedHex)) {
hasLightBorder = true;
}
if (CODES_WITH_DARK_BORDER.includes(normalizedHex)) {
hasDarkBorder = true;
}
return {
hexCode: twemojiHex,
hasLightBorder,
hasDarkBorder,
};
}
interface TwemojiSpecificEmoji {
unqualified?: string;
gender?: number;
skin?: number;
label?: string;
}
// Normalize man/woman to male/female
const GENDER_CODES_MAP: Record<number, number> = {
[GENDER_FEMALE_CODE]: GENDER_FEMALE_CODE,
[GENDER_MALE_CODE]: GENDER_MALE_CODE,
// These are man/woman markers, but are used for gender sometimes.
[0x1f468]: GENDER_MALE_CODE,
[0x1f469]: GENDER_FEMALE_CODE,
};
const TWEMOJI_SPECIAL_CASES: Record<string, string | TwemojiSpecificEmoji> = {
'1F441-200D-1F5E8': '1F441-FE0F-200D-1F5E8-FE0F', // Eye in speech bubble
// An emoji that was never ported to the Unicode standard.
// See: https://emojipedia.org/shibuya
E50A: { label: 'Shibuya 109' },
};
export function twemojiToUnicodeInfo(
twemojiHex: string,
): TwemojiSpecificEmoji | string {
const specialCase = TWEMOJI_SPECIAL_CASES[twemojiHex.toUpperCase()];
if (specialCase) {
return specialCase;
}
const codes = hexStringToNumbers(twemojiHex);
let gender: undefined | number;
let skin: undefined | number;
for (const code of codes) {
if (!gender && code in GENDER_CODES_MAP) {
gender = GENDER_CODES_MAP[code];
} else if (!skin && code in SKIN_TONE_CODES) {
skin = code;
}
// Exit if we have both skin and gender
if (skin && gender) {
break;
}
}
let mappedCodes: unknown[] = codes;
if (codes.at(-1) === CHRISTMAS_TREE_CODE && codes.length >= 3 && gender) {
// Twemoji uses the christmas tree with a ZWJ for Mr. and Mrs. Claus,
// but in Unicode that only works for Mx. Claus.
const START_CODE =
gender === GENDER_FEMALE_CODE ? MS_CLAUS_CODE : MR_CLAUS_CODE;
mappedCodes = [START_CODE, skin];
} else if (codes.at(-1) === KEYCAP_CODE && codes.length === 2) {
// For key emoji, insert the variation selector
mappedCodes = [codes[0], VARIATION_SELECTOR_CODE, KEYCAP_CODE];
} else if (
(codes.at(0) === SKIER_CODE || codes.at(0) === LEVITATING_PERSON_CODE) &&
codes.length > 1
) {
// Twemoji offers more gender and skin options for the skier and levitating person emoji.
return {
unqualified: hexNumbersToString([codes.at(0)]),
skin,
gender,
};
}
return hexNumbersToString(mappedCodes);
}
function hexStringToNumbers(hexString: string): number[] {
return hexString
.split('-')
.map((code) => Number.parseInt(code, 16))
.filter((code) => !Number.isNaN(code));
}
function hexNumbersToString(codes: unknown[], padding = 4): string {
return codes
.filter(
(code): code is number =>
typeof code === 'number' && code > 0 && !Number.isNaN(code),
)
.map((code) => code.toString(16).padStart(padding, '0').toUpperCase())
.join('-');
}

View File

@ -1,253 +0,0 @@
import { customEmojiFactory, unicodeEmojiFactory } from '@/testing/factories';
import {
EMOJI_MODE_NATIVE,
EMOJI_MODE_NATIVE_WITH_FLAGS,
EMOJI_MODE_TWEMOJI,
} from './constants';
import * as db from './database';
import {
emojifyElement,
emojifyText,
testCacheClear,
tokenizeText,
} from './render';
import type { EmojiAppState, ExtraCustomEmojiMap } from './types';
function mockDatabase() {
return {
searchCustomEmojisByShortcodes: vi
.spyOn(db, 'searchCustomEmojisByShortcodes')
.mockResolvedValue([customEmojiFactory()]),
searchEmojisByHexcodes: vi
.spyOn(db, 'searchEmojisByHexcodes')
.mockResolvedValue([
unicodeEmojiFactory({
hexcode: '1F60A',
label: 'smiling face with smiling eyes',
unicode: '😊',
}),
unicodeEmojiFactory({
hexcode: '1F1EA-1F1FA',
label: 'flag-eu',
unicode: '🇪🇺',
}),
]),
};
}
const expectedSmileImage =
'<img draggable="false" class="emojione" alt="😊" title="smiling face with smiling eyes" src="/emoji/1f60a.svg">';
const expectedFlagImage =
'<img draggable="false" class="emojione" alt="🇪🇺" title="flag-eu" src="/emoji/1f1ea-1f1fa.svg">';
const expectedCustomEmojiImage =
'<img draggable="false" class="emojione custom-emoji" alt=":custom:" title=":custom:" src="emoji/custom/static" data-original="emoji/custom" data-static="emoji/custom/static">';
const expectedRemoteCustomEmojiImage =
'<img draggable="false" class="emojione custom-emoji" alt=":remote:" title=":remote:" src="remote.social/static" data-original="remote.social/custom" data-static="remote.social/static">';
const mockExtraCustom: ExtraCustomEmojiMap = {
remote: {
shortcode: 'remote',
static_url: 'remote.social/static',
url: 'remote.social/custom',
},
};
function testAppState(state: Partial<EmojiAppState> = {}) {
return {
locales: ['en'],
mode: EMOJI_MODE_TWEMOJI,
currentLocale: 'en',
darkTheme: false,
...state,
} satisfies EmojiAppState;
}
describe('emojifyElement', () => {
function testElement(text = '<p>Hello 😊🇪🇺!</p><p>:custom:</p>') {
const testElement = document.createElement('div');
testElement.innerHTML = text;
return testElement;
}
afterEach(() => {
testCacheClear();
vi.restoreAllMocks();
});
test('caches element rendering results', async () => {
const { searchCustomEmojisByShortcodes, searchEmojisByHexcodes } =
mockDatabase();
await emojifyElement(testElement(), testAppState());
await emojifyElement(testElement(), testAppState());
await emojifyElement(testElement(), testAppState());
expect(searchEmojisByHexcodes).toHaveBeenCalledExactlyOnceWith(
['1F1EA-1F1FA', '1F60A'],
'en',
);
expect(searchCustomEmojisByShortcodes).toHaveBeenCalledExactlyOnceWith([
'custom',
]);
});
test('emojifies custom emoji in native mode', async () => {
const { searchEmojisByHexcodes } = mockDatabase();
const actual = await emojifyElement(
testElement(),
testAppState({ mode: EMOJI_MODE_NATIVE }),
);
assert(actual);
expect(actual.innerHTML).toBe(
`<p>Hello 😊🇪🇺!</p><p>${expectedCustomEmojiImage}</p>`,
);
expect(searchEmojisByHexcodes).not.toHaveBeenCalled();
});
test('emojifies flag emoji in native-with-flags mode', async () => {
const { searchEmojisByHexcodes } = mockDatabase();
const actual = await emojifyElement(
testElement(),
testAppState({ mode: EMOJI_MODE_NATIVE_WITH_FLAGS }),
);
assert(actual);
expect(actual.innerHTML).toBe(
`<p>Hello 😊${expectedFlagImage}!</p><p>${expectedCustomEmojiImage}</p>`,
);
expect(searchEmojisByHexcodes).toHaveBeenCalledOnce();
});
test('emojifies everything in twemoji mode', async () => {
const { searchCustomEmojisByShortcodes, searchEmojisByHexcodes } =
mockDatabase();
const actual = await emojifyElement(testElement(), testAppState());
assert(actual);
expect(actual.innerHTML).toBe(
`<p>Hello ${expectedSmileImage}${expectedFlagImage}!</p><p>${expectedCustomEmojiImage}</p>`,
);
expect(searchEmojisByHexcodes).toHaveBeenCalledOnce();
expect(searchCustomEmojisByShortcodes).toHaveBeenCalledOnce();
});
test('emojifies with provided custom emoji', async () => {
const { searchCustomEmojisByShortcodes, searchEmojisByHexcodes } =
mockDatabase();
const actual = await emojifyElement(
testElement('<p>hi :remote:</p>'),
testAppState(),
mockExtraCustom,
);
assert(actual);
expect(actual.innerHTML).toBe(
`<p>hi ${expectedRemoteCustomEmojiImage}</p>`,
);
expect(searchEmojisByHexcodes).not.toHaveBeenCalled();
expect(searchCustomEmojisByShortcodes).not.toHaveBeenCalled();
});
test('returns null when no emoji are found', async () => {
mockDatabase();
const actual = await emojifyElement(
testElement('<p>here is just text :)</p>'),
testAppState(),
);
expect(actual).toBeNull();
});
});
describe('emojifyText', () => {
test('returns original input when no emoji are in string', async () => {
const actual = await emojifyText('nothing here', testAppState());
expect(actual).toBe('nothing here');
});
test('renders Unicode emojis to twemojis', async () => {
mockDatabase();
const actual = await emojifyText('Hello 😊🇪🇺!', testAppState());
expect(actual).toBe(`Hello ${expectedSmileImage}${expectedFlagImage}!`);
});
test('renders custom emojis', async () => {
mockDatabase();
const actual = await emojifyText('Hello :custom:!', testAppState());
expect(actual).toBe(`Hello ${expectedCustomEmojiImage}!`);
});
test('renders provided extra emojis', async () => {
const actual = await emojifyText(
'remote emoji :remote:',
testAppState(),
mockExtraCustom,
);
expect(actual).toBe(`remote emoji ${expectedRemoteCustomEmojiImage}`);
});
});
describe('tokenizeText', () => {
test('returns empty array for string with only whitespace', () => {
expect(tokenizeText(' \n')).toEqual([]);
});
test('returns an array of text to be a single token', () => {
expect(tokenizeText('Hello')).toEqual(['Hello']);
});
test('returns tokens for text with emoji', () => {
expect(tokenizeText('Hello 😊 🇿🇼!!')).toEqual([
'Hello ',
{
type: 'unicode',
code: '😊',
},
' ',
{
type: 'unicode',
code: '🇿🇼',
},
'!!',
]);
});
test('returns tokens for text with custom emoji', () => {
expect(tokenizeText('Hello :smile:!!')).toEqual([
'Hello ',
{
type: 'custom',
code: 'smile',
},
'!!',
]);
});
test('handles custom emoji with underscores and numbers', () => {
expect(tokenizeText('Hello :smile_123:!!')).toEqual([
'Hello ',
{
type: 'custom',
code: 'smile_123',
},
'!!',
]);
});
test('returns tokens for text with mixed emoji', () => {
expect(tokenizeText('Hello 😊 :smile:!!')).toEqual([
'Hello ',
{
type: 'unicode',
code: '😊',
},
' ',
{
type: 'custom',
code: 'smile',
},
'!!',
]);
});
test('does not capture custom emoji with invalid characters', () => {
expect(tokenizeText('Hello :smile-123:!!')).toEqual([
'Hello :smile-123:!!',
]);
});
});

View File

@ -1,407 +0,0 @@
import { autoPlayGif } from '@/mastodon/initial_state';
import { createLimitedCache } from '@/mastodon/utils/cache';
import { assetHost } from '@/mastodon/utils/config';
import * as perf from '@/mastodon/utils/performance';
import {
EMOJI_MODE_NATIVE,
EMOJI_MODE_NATIVE_WITH_FLAGS,
EMOJI_TYPE_UNICODE,
EMOJI_TYPE_CUSTOM,
EMOJI_STATE_MISSING,
} from './constants';
import {
searchCustomEmojisByShortcodes,
searchEmojisByHexcodes,
} from './database';
import {
emojiToUnicodeHex,
twemojiHasBorder,
unicodeToTwemojiHex,
} from './normalize';
import type {
CustomEmojiToken,
EmojiAppState,
EmojiLoadedState,
EmojiMode,
EmojiState,
EmojiStateMap,
EmojiToken,
ExtraCustomEmojiMap,
LocaleOrCustom,
UnicodeEmojiToken,
} from './types';
import {
anyEmojiRegex,
emojiLogger,
stringHasAnyEmoji,
stringHasUnicodeFlags,
} from './utils';
const log = emojiLogger('render');
/**
* Emojifies an element. This modifies the element in place, replacing text nodes with emojified versions.
*/
export async function emojifyElement<Element extends HTMLElement>(
element: Element,
appState: EmojiAppState,
extraEmojis: ExtraCustomEmojiMap = {},
): Promise<Element | null> {
const cacheKey = createCacheKey(element, appState, extraEmojis);
const cached = getCached(cacheKey);
if (cached !== undefined) {
log('Cache hit on %s', element.outerHTML);
if (cached === null) {
return null;
}
element.innerHTML = cached;
return element;
}
if (!stringHasAnyEmoji(element.innerHTML)) {
updateCache(cacheKey, null);
return null;
}
perf.start('emojifyElement()');
const queue: (HTMLElement | Text)[] = [element];
while (queue.length > 0) {
const current = queue.shift();
if (
!current ||
current instanceof HTMLScriptElement ||
current instanceof HTMLStyleElement
) {
continue;
}
if (
current.textContent &&
(current instanceof Text || !current.hasChildNodes())
) {
const renderedContent = await textToElementArray(
current.textContent,
appState,
extraEmojis,
);
if (renderedContent) {
if (!(current instanceof Text)) {
current.textContent = null; // Clear the text content if it's not a Text node.
}
current.replaceWith(renderedToHTML(renderedContent));
}
continue;
}
for (const child of current.childNodes) {
if (child instanceof HTMLElement || child instanceof Text) {
queue.push(child);
}
}
}
updateCache(cacheKey, element.innerHTML);
perf.stop('emojifyElement()');
return element;
}
export async function emojifyText(
text: string,
appState: EmojiAppState,
extraEmojis: ExtraCustomEmojiMap = {},
): Promise<string | null> {
const cacheKey = createCacheKey(text, appState, extraEmojis);
const cached = getCached(cacheKey);
if (cached !== undefined) {
log('Cache hit on %s', text);
return cached ?? text;
}
if (!stringHasAnyEmoji(text)) {
updateCache(cacheKey, null);
return text;
}
const eleArray = await textToElementArray(text, appState, extraEmojis);
if (!eleArray) {
updateCache(cacheKey, null);
return text;
}
const rendered = renderedToHTML(eleArray, document.createElement('div'));
updateCache(cacheKey, rendered.innerHTML);
return rendered.innerHTML;
}
// Private functions
const {
set: updateCache,
get: getCached,
clear: cacheClear,
} = createLimitedCache<string | null>({ log: log.extend('cache') });
function createCacheKey(
input: HTMLElement | string,
appState: EmojiAppState,
extraEmojis: ExtraCustomEmojiMap,
) {
return JSON.stringify([
input instanceof HTMLElement ? input.outerHTML : input,
appState,
extraEmojis,
]);
}
type EmojifiedTextArray = (string | HTMLImageElement)[];
async function textToElementArray(
text: string,
appState: EmojiAppState,
extraEmojis: ExtraCustomEmojiMap = {},
): Promise<EmojifiedTextArray | null> {
// Exit if no text to convert.
if (!text.trim()) {
return null;
}
const tokens = tokenizeText(text);
// If only one token and it's a string, exit early.
if (tokens.length === 1 && typeof tokens[0] === 'string') {
return null;
}
// Get all emoji from the state map, loading any missing ones.
await loadMissingEmojiIntoCache(tokens, appState, extraEmojis);
const renderedFragments: EmojifiedTextArray = [];
for (const token of tokens) {
if (typeof token !== 'string' && shouldRenderImage(token, appState.mode)) {
let state: EmojiState | undefined;
if (token.type === EMOJI_TYPE_CUSTOM) {
const extraEmojiData = extraEmojis[token.code];
if (extraEmojiData) {
state = { type: EMOJI_TYPE_CUSTOM, data: extraEmojiData };
} else {
state = emojiForLocale(token.code, EMOJI_TYPE_CUSTOM);
}
} else {
state = emojiForLocale(
emojiToUnicodeHex(token.code),
appState.currentLocale,
);
}
// If the state is valid, create an image element. Otherwise, just append as text.
if (state && typeof state !== 'string') {
const image = stateToImage(state, appState);
renderedFragments.push(image);
continue;
}
}
const text = typeof token === 'string' ? token : token.code;
renderedFragments.push(text);
}
return renderedFragments;
}
type TokenizedText = (string | EmojiToken)[];
export function tokenizeText(text: string): TokenizedText {
if (!text.trim()) {
return [];
}
const tokens = [];
let lastIndex = 0;
for (const match of text.matchAll(anyEmojiRegex())) {
if (match.index > lastIndex) {
tokens.push(text.slice(lastIndex, match.index));
}
const code = match[0];
if (code.startsWith(':') && code.endsWith(':')) {
// Custom emoji
tokens.push({
type: EMOJI_TYPE_CUSTOM,
code: code.slice(1, -1), // Remove the colons
} satisfies CustomEmojiToken);
} else {
// Unicode emoji
tokens.push({
type: EMOJI_TYPE_UNICODE,
code: code,
} satisfies UnicodeEmojiToken);
}
lastIndex = match.index + code.length;
}
if (lastIndex < text.length) {
tokens.push(text.slice(lastIndex));
}
return tokens;
}
const localeCacheMap = new Map<LocaleOrCustom, EmojiStateMap>([
[
EMOJI_TYPE_CUSTOM,
createLimitedCache<EmojiState>({ log: log.extend('custom') }),
],
]);
function cacheForLocale(locale: LocaleOrCustom): EmojiStateMap {
return (
localeCacheMap.get(locale) ??
createLimitedCache<EmojiState>({ log: log.extend(locale) })
);
}
function emojiForLocale(
code: string,
locale: LocaleOrCustom,
): EmojiState | undefined {
const cache = cacheForLocale(locale);
return cache.get(code);
}
async function loadMissingEmojiIntoCache(
tokens: TokenizedText,
{ mode, currentLocale }: EmojiAppState,
extraEmojis: ExtraCustomEmojiMap,
) {
const missingUnicodeEmoji = new Set<string>();
const missingCustomEmoji = new Set<string>();
// Iterate over tokens and check if they are in the cache already.
for (const token of tokens) {
if (typeof token === 'string') {
continue; // Skip plain strings.
}
// If this is a custom emoji, check it separately.
if (token.type === EMOJI_TYPE_CUSTOM) {
const code = token.code;
if (code in extraEmojis) {
continue; // We don't care about extra emoji.
}
const emojiState = emojiForLocale(code, EMOJI_TYPE_CUSTOM);
if (!emojiState) {
missingCustomEmoji.add(code);
}
// Otherwise this is a unicode emoji, so check it against all locales.
} else if (shouldRenderImage(token, mode)) {
const code = emojiToUnicodeHex(token.code);
if (missingUnicodeEmoji.has(code)) {
continue; // Already marked as missing.
}
const emojiState = emojiForLocale(code, currentLocale);
if (!emojiState) {
// If it's missing in one locale, we consider it missing for all.
missingUnicodeEmoji.add(code);
}
}
}
if (missingUnicodeEmoji.size > 0) {
const missingEmojis = Array.from(missingUnicodeEmoji).toSorted();
const emojis = await searchEmojisByHexcodes(missingEmojis, currentLocale);
const cache = cacheForLocale(currentLocale);
for (const emoji of emojis) {
cache.set(emoji.hexcode, { type: EMOJI_TYPE_UNICODE, data: emoji });
}
const notFoundEmojis = missingEmojis.filter((code) =>
emojis.every((emoji) => emoji.hexcode !== code),
);
for (const code of notFoundEmojis) {
cache.set(code, EMOJI_STATE_MISSING); // Mark as missing if not found, as it's probably not a valid emoji.
}
localeCacheMap.set(currentLocale, cache);
}
if (missingCustomEmoji.size > 0) {
const missingEmojis = Array.from(missingCustomEmoji).toSorted();
const emojis = await searchCustomEmojisByShortcodes(missingEmojis);
const cache = cacheForLocale(EMOJI_TYPE_CUSTOM);
for (const emoji of emojis) {
cache.set(emoji.shortcode, { type: EMOJI_TYPE_CUSTOM, data: emoji });
}
const notFoundEmojis = missingEmojis.filter((code) =>
emojis.every((emoji) => emoji.shortcode !== code),
);
for (const code of notFoundEmojis) {
cache.set(code, EMOJI_STATE_MISSING); // Mark as missing if not found, as it's probably not a valid emoji.
}
localeCacheMap.set(EMOJI_TYPE_CUSTOM, cache);
}
}
function shouldRenderImage(token: EmojiToken, mode: EmojiMode): boolean {
if (token.type === EMOJI_TYPE_UNICODE) {
// If the mode is native or native with flags for non-flag emoji
// we can just append the text node directly.
if (
mode === EMOJI_MODE_NATIVE ||
(mode === EMOJI_MODE_NATIVE_WITH_FLAGS &&
!stringHasUnicodeFlags(token.code))
) {
return false;
}
}
return true;
}
function stateToImage(state: EmojiLoadedState, appState: EmojiAppState) {
const image = document.createElement('img');
image.draggable = false;
image.classList.add('emojione');
if (state.type === EMOJI_TYPE_UNICODE) {
const emojiInfo = twemojiHasBorder(unicodeToTwemojiHex(state.data.hexcode));
let fileName = emojiInfo.hexCode;
if (
(appState.darkTheme && emojiInfo.hasDarkBorder) ||
(!appState.darkTheme && emojiInfo.hasLightBorder)
) {
fileName = `${emojiInfo.hexCode}_border`;
}
image.alt = state.data.unicode;
image.title = state.data.label;
image.src = `${assetHost}/emoji/${fileName}.svg`;
} else {
// Custom emoji
const shortCode = `:${state.data.shortcode}:`;
image.classList.add('custom-emoji');
image.alt = shortCode;
image.title = shortCode;
image.src = autoPlayGif ? state.data.url : state.data.static_url;
image.dataset.original = state.data.url;
image.dataset.static = state.data.static_url;
}
return image;
}
function renderedToHTML(renderedArray: EmojifiedTextArray): DocumentFragment;
function renderedToHTML<ParentType extends ParentNode>(
renderedArray: EmojifiedTextArray,
parent: ParentType,
): ParentType;
function renderedToHTML(
renderedArray: EmojifiedTextArray,
parent: ParentNode | null = null,
) {
const fragment = parent ?? document.createDocumentFragment();
for (const fragmentItem of renderedArray) {
if (typeof fragmentItem === 'string') {
fragment.appendChild(document.createTextNode(fragmentItem));
} else if (fragmentItem instanceof HTMLImageElement) {
fragment.appendChild(fragmentItem);
}
}
return fragment;
}
// Testing helpers
export const testCacheClear = () => {
cacheClear();
localeCacheMap.clear();
};

View File

@ -1,76 +0,0 @@
import type { List as ImmutableList } from 'immutable';
import type { FlatCompactEmoji, Locale } from 'emojibase';
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
import type { CustomEmoji } from '@/mastodon/models/custom_emoji';
import type { LimitedCache } from '@/mastodon/utils/cache';
import type {
EMOJI_MODE_NATIVE,
EMOJI_MODE_NATIVE_WITH_FLAGS,
EMOJI_MODE_TWEMOJI,
EMOJI_STATE_MISSING,
EMOJI_TYPE_CUSTOM,
EMOJI_TYPE_UNICODE,
} from './constants';
export type EmojiMode =
| typeof EMOJI_MODE_NATIVE
| typeof EMOJI_MODE_NATIVE_WITH_FLAGS
| typeof EMOJI_MODE_TWEMOJI;
export type LocaleOrCustom = Locale | typeof EMOJI_TYPE_CUSTOM;
export interface EmojiAppState {
locales: Locale[];
currentLocale: Locale;
mode: EmojiMode;
darkTheme: boolean;
}
export interface UnicodeEmojiToken {
type: typeof EMOJI_TYPE_UNICODE;
code: string;
}
export interface CustomEmojiToken {
type: typeof EMOJI_TYPE_CUSTOM;
code: string;
}
export type EmojiToken = UnicodeEmojiToken | CustomEmojiToken;
export type CustomEmojiData = ApiCustomEmojiJSON;
export type UnicodeEmojiData = FlatCompactEmoji;
export type AnyEmojiData = CustomEmojiData | UnicodeEmojiData;
export type EmojiStateMissing = typeof EMOJI_STATE_MISSING;
export interface EmojiStateUnicode {
type: typeof EMOJI_TYPE_UNICODE;
data: UnicodeEmojiData;
}
export interface EmojiStateCustom {
type: typeof EMOJI_TYPE_CUSTOM;
data: CustomEmojiRenderFields;
}
export type EmojiState =
| EmojiStateMissing
| EmojiStateUnicode
| EmojiStateCustom;
export type EmojiLoadedState = EmojiStateUnicode | EmojiStateCustom;
export type EmojiStateMap = LimitedCache<string, EmojiState>;
export type CustomEmojiMapArg =
| ExtraCustomEmojiMap
| ImmutableList<CustomEmoji>;
export type CustomEmojiRenderFields = Pick<
CustomEmojiData,
'shortcode' | 'static_url' | 'url'
>;
export type ExtraCustomEmojiMap = Record<string, CustomEmojiRenderFields>;
export interface TwemojiBorderInfo {
hexCode: string;
hasLightBorder: boolean;
hasDarkBorder: boolean;
}

View File

@ -1,77 +0,0 @@
import {
stringHasAnyEmoji,
stringHasCustomEmoji,
stringHasUnicodeEmoji,
stringHasUnicodeFlags,
} from './utils';
describe('stringHasUnicodeEmoji', () => {
test.concurrent.for([
['only text', false],
['text with non-emoji symbols ™©', false],
['text with emoji 😀', true],
['multiple emojis 😀😃😄', true],
['emoji with skin tone 👍🏽', true],
['emoji with ZWJ 👩‍❤️‍👨', true],
['emoji with variation selector ✊️', true],
['emoji with keycap 1⃣', true],
['emoji with flags 🇺🇸', true],
['emoji with regional indicators 🇦🇺', true],
['emoji with gender 👩‍⚕️', true],
['emoji with family 👨‍👩‍👧‍👦', true],
['emoji with zero width joiner 👩‍🔬', true],
['emoji with non-BMP codepoint 🧑‍🚀', true],
['emoji with combining marks 👨‍👩‍👧‍👦', true],
['emoji with enclosing keycap #️⃣', true],
['emoji with no visible glyph \u200D', false],
] as const)(
'stringHasUnicodeEmoji has emojis in "%s": %o',
([text, expected], { expect }) => {
expect(stringHasUnicodeEmoji(text)).toBe(expected);
},
);
});
describe('stringHasUnicodeFlags', () => {
test.concurrent.for([
['EU 🇪🇺', true],
['Germany 🇩🇪', true],
['Canada 🇨🇦', true],
['São Tomé & Príncipe 🇸🇹', true],
['Scotland 🏴󠁧󠁢󠁳󠁣󠁴󠁿', true],
['black flag 🏴', false],
['arrr 🏴‍☠️', false],
['rainbow flag 🏳️‍🌈', false],
['non-flag 🔥', false],
['only text', false],
] as const)(
'stringHasFlags has flag in "%s": %o',
([text, expected], { expect }) => {
expect(stringHasUnicodeFlags(text)).toBe(expected);
},
);
});
describe('stringHasCustomEmoji', () => {
test('string with custom emoji returns true', () => {
expect(stringHasCustomEmoji(':custom: :test:')).toBeTruthy();
});
test('string without custom emoji returns false', () => {
expect(stringHasCustomEmoji('🏳️‍🌈 :🏳️‍🌈: text ™')).toBeFalsy();
});
});
describe('stringHasAnyEmoji', () => {
test('string without any emoji or characters', () => {
expect(stringHasAnyEmoji('normal text. 12356?!')).toBeFalsy();
});
test('string with non-emoji characters', () => {
expect(stringHasAnyEmoji('™©')).toBeFalsy();
});
test('has unicode emoji', () => {
expect(stringHasAnyEmoji('🏳️‍🌈🔥🇸🇹 👩‍🔬')).toBeTruthy();
});
test('has custom emoji', () => {
expect(stringHasAnyEmoji(':test: :custom:')).toBeTruthy();
});
});

View File

@ -1,56 +0,0 @@
import debug from 'debug';
import { emojiRegexPolyfill } from '@/mastodon/polyfills';
export function emojiLogger(segment: string) {
return debug(`emojis:${segment}`);
}
export function stringHasUnicodeEmoji(input: string): boolean {
return new RegExp(EMOJI_REGEX, supportedFlags()).test(input);
}
export function stringHasUnicodeFlags(input: string): boolean {
if (supportsRegExpSets()) {
return new RegExp(
'\\p{RGI_Emoji_Flag_Sequence}|\\p{RGI_Emoji_Tag_Sequence}',
'v',
).test(input);
}
return new RegExp(
// First range is regional indicator symbols,
// Second is a black flag + 0-9|a-z tag chars + cancel tag.
// See: https://en.wikipedia.org/wiki/Regional_indicator_symbol
'(?:\uD83C[\uDDE6-\uDDFF]){2}|\uD83C\uDFF4(?:\uDB40[\uDC30-\uDC7A])+\uDB40\uDC7F',
).test(input);
}
// Constant as this is supported by all browsers.
const CUSTOM_EMOJI_REGEX = /:([a-z0-9_]+):/i;
export function stringHasCustomEmoji(input: string) {
return CUSTOM_EMOJI_REGEX.test(input);
}
export function stringHasAnyEmoji(input: string) {
return stringHasUnicodeEmoji(input) || stringHasCustomEmoji(input);
}
export function anyEmojiRegex() {
return new RegExp(
`${EMOJI_REGEX}|${CUSTOM_EMOJI_REGEX.source}`,
supportedFlags('gi'),
);
}
function supportsRegExpSets() {
return 'unicodeSets' in RegExp.prototype;
}
function supportedFlags(flags = '') {
if (supportsRegExpSets()) {
return `${flags}v`;
}
return flags;
}
const EMOJI_REGEX = emojiRegexPolyfill?.source ?? '\\p{RGI_Emoji}';

View File

@ -1,18 +0,0 @@
import { importEmojiData, importCustomEmojiData } from './loader';
addEventListener('message', handleMessage);
self.postMessage('ready'); // After the worker is ready, notify the main thread
function handleMessage(event: MessageEvent<string>) {
const { data: locale } = event;
void loadData(locale);
}
async function loadData(locale: string) {
if (locale !== 'custom') {
await importEmojiData(locale);
} else {
await importCustomEmojiData();
}
self.postMessage(`loaded ${locale}`);
}

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