mirror of
https://github.com/mastodon/mastodon.git
synced 2025-08-09 05:08:20 +00:00
Compare commits
67 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
5478ef9b32 | ||
![]() |
e2592419d9 | ||
![]() |
e330447b0e | ||
![]() |
b15861528c | ||
![]() |
83dc7dc16e | ||
![]() |
7d3cc51148 | ||
![]() |
cabb33bc49 | ||
![]() |
208cb8276a | ||
![]() |
4ae47f4263 | ||
![]() |
08b2f255fc | ||
![]() |
8242f06eca | ||
![]() |
5429351889 | ||
![]() |
6ff4e83937 | ||
![]() |
77d2cdb302 | ||
![]() |
c727197760 | ||
![]() |
d6859c9658 | ||
![]() |
7a9e98f4d6 | ||
![]() |
7924a27ae7 | ||
![]() |
d664b9d8ff | ||
![]() |
4558cfadd8 | ||
![]() |
713965467d | ||
![]() |
aec6d0f807 | ||
![]() |
e103815d2d | ||
![]() |
d73b9fba90 | ||
![]() |
a89d11bc08 | ||
![]() |
a250928934 | ||
![]() |
1d1b17b04b | ||
![]() |
2aff51013c | ||
![]() |
8c3c1faaec | ||
![]() |
a2888f1bb2 | ||
![]() |
77fe044f03 | ||
![]() |
da0cc0f5b9 | ||
![]() |
ee83f3a8b9 | ||
![]() |
7ae78b1032 | ||
![]() |
c4b7c3bdda | ||
![]() |
a79dbf8334 | ||
![]() |
ef6f5f9357 | ||
![]() |
f65f6ad6f1 | ||
![]() |
c0e242cb73 | ||
![]() |
609a40181e | ||
![]() |
93ce44d21d | ||
![]() |
fb3ff194b5 | ||
![]() |
81b363b338 | ||
![]() |
1151b05c2d | ||
![]() |
f96743fcfb | ||
![]() |
69e14246b8 | ||
![]() |
c1794fb948 | ||
![]() |
333a17a478 | ||
![]() |
388e09e1a3 | ||
![]() |
2dcededcf0 | ||
![]() |
2db8a328cd | ||
![]() |
b4a950c2fc | ||
![]() |
194645aada | ||
![]() |
0c5ce23ae4 | ||
![]() |
cb937a920e | ||
![]() |
7051458467 | ||
![]() |
025abf7325 | ||
![]() |
28373a9c88 | ||
![]() |
42884d8727 | ||
![]() |
000ff9c05f | ||
![]() |
921af5d27d | ||
![]() |
878e1e65eb | ||
![]() |
06f5f270cc | ||
![]() |
961c22a6fd | ||
![]() |
07b4fa55c8 | ||
![]() |
041bce9ed6 | ||
![]() |
d7a08d81b6 |
|
@ -5,7 +5,6 @@
|
|||
.gitattributes
|
||||
.gitignore
|
||||
.github
|
||||
.vscode
|
||||
public/system
|
||||
public/assets
|
||||
public/packs
|
||||
|
@ -21,7 +20,6 @@ postgres14
|
|||
redis
|
||||
elasticsearch
|
||||
chart
|
||||
storybook-static
|
||||
.yarn/
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
|
|
2
.github/ISSUE_TEMPLATE/1.web_bug_report.yml
vendored
2
.github/ISSUE_TEMPLATE/1.web_bug_report.yml
vendored
|
@ -1,6 +1,6 @@
|
|||
name: Bug Report (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
|
||||
body:
|
||||
- type: markdown
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
name: Bug Report (server / API)
|
||||
description: |
|
||||
There is a problem with the HTTP server, REST API, ActivityPub interaction, etc.
|
||||
labels: ['status/to triage']
|
||||
type: 'Bug'
|
||||
body:
|
||||
- type: markdown
|
||||
|
|
1
.github/renovate.json5
vendored
1
.github/renovate.json5
vendored
|
@ -23,6 +23,7 @@
|
|||
matchManagers: ['npm'],
|
||||
matchPackageNames: [
|
||||
'tesseract.js', // Requires code changes
|
||||
'react-hotkeys', // Requires code changes
|
||||
|
||||
// react-router: Requires manual upgrade
|
||||
'history',
|
||||
|
|
4
.github/workflows/build-releases.yml
vendored
4
.github/workflows/build-releases.yml
vendored
|
@ -21,7 +21,7 @@ jobs:
|
|||
# Only tag with latest when ran against the latest stable branch
|
||||
# This needs to be updated after each minor version release
|
||||
flavor: |
|
||||
latest=${{ startsWith(github.ref, 'refs/tags/v4.3.') }}
|
||||
latest=${{ startsWith(github.ref, 'refs/tags/v4.4.') }}
|
||||
tags: |
|
||||
type=pep440,pattern={{raw}}
|
||||
type=pep440,pattern=v{{major}}.{{minor}}
|
||||
|
@ -39,7 +39,7 @@ jobs:
|
|||
# Only tag with latest when ran against the latest stable branch
|
||||
# This needs to be updated after each minor version release
|
||||
flavor: |
|
||||
latest=${{ startsWith(github.ref, 'refs/tags/v4.3.') }}
|
||||
latest=${{ startsWith(github.ref, 'refs/tags/v4.4.') }}
|
||||
tags: |
|
||||
type=pep440,pattern={{raw}}
|
||||
type=pep440,pattern=v{{major}}.{{minor}}
|
||||
|
|
|
@ -50,7 +50,7 @@ jobs:
|
|||
|
||||
# Create or update the 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:
|
||||
commit-message: 'New Crowdin translations'
|
||||
title: 'New Crowdin Translations for ${{ github.base_ref || github.ref_name }} (automated)'
|
||||
|
|
|
@ -1,11 +1,15 @@
|
|||
# This configuration was generated by
|
||||
# `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
|
||||
# one by one as the offenses are removed from the code base.
|
||||
# Note that changes in the inspected code, or installation of new
|
||||
# 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.
|
||||
Metrics/AbcSize:
|
||||
Max: 82
|
||||
|
|
|
@ -1 +1 @@
|
|||
3.4.5
|
||||
3.4.4
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
import { resolve } from 'node:path';
|
||||
|
||||
import type { StorybookConfig } from '@storybook/react-vite';
|
||||
|
||||
const config: StorybookConfig = {
|
||||
|
@ -28,12 +26,6 @@ const config: StorybookConfig = {
|
|||
'oops.png',
|
||||
].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;
|
||||
|
|
|
@ -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"]
|
||||
# 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"]
|
||||
# renovate: datasource=node-version depName=node
|
||||
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"]
|
||||
# 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"]
|
||||
ARG VIPS_URL=https://github.com/libvips/libvips/releases/download
|
||||
|
||||
|
|
4
Gemfile
4
Gemfile
|
@ -62,7 +62,7 @@ gem 'inline_svg'
|
|||
gem 'irb', '~> 1.8'
|
||||
gem 'kaminari', '~> 1.2'
|
||||
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 'mime-types', '~> 3.7.0', require: 'mime/types/columnar'
|
||||
gem 'mutex_m'
|
||||
|
@ -84,7 +84,7 @@ gem 'sanitize', '~> 7.0'
|
|||
gem 'scenic', '~> 1.7'
|
||||
gem 'sidekiq', '< 8'
|
||||
gem 'sidekiq-bulk', '~> 0.2.0'
|
||||
gem 'sidekiq-scheduler', '~> 6.0'
|
||||
gem 'sidekiq-scheduler', '~> 5.0'
|
||||
gem 'sidekiq-unique-jobs', '> 8'
|
||||
gem 'simple_form', '~> 5.2'
|
||||
gem 'simple-navigation', '~> 4.4'
|
||||
|
|
135
Gemfile.lock
135
Gemfile.lock
|
@ -90,13 +90,13 @@ GEM
|
|||
public_suffix (>= 2.0.2, < 7.0)
|
||||
aes_key_wrap (1.1.0)
|
||||
android_key_attestation (0.3.0)
|
||||
annotaterb (4.18.0)
|
||||
annotaterb (4.16.0)
|
||||
activerecord (>= 6.0.0)
|
||||
activesupport (>= 6.0.0)
|
||||
ast (2.4.3)
|
||||
attr_required (1.0.2)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1135.0)
|
||||
aws-eventstream (1.3.2)
|
||||
aws-partitions (1.1103.0)
|
||||
aws-sdk-core (3.215.1)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
|
@ -109,9 +109,9 @@ GEM
|
|||
aws-sdk-core (~> 3, >= 3.210.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.12.1)
|
||||
aws-sigv4 (1.11.0)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
azure-blob (0.5.9.1)
|
||||
azure-blob (0.5.8)
|
||||
rexml
|
||||
base64 (0.3.0)
|
||||
bcp47_spec (0.2.1)
|
||||
|
@ -144,7 +144,7 @@ GEM
|
|||
rack-test (>= 0.6.3)
|
||||
regexp_parser (>= 1.5, < 3.0)
|
||||
xpath (~> 3.2)
|
||||
capybara-playwright-driver (0.5.7)
|
||||
capybara-playwright-driver (0.5.6)
|
||||
addressable
|
||||
capybara
|
||||
playwright-ruby-client (>= 1.16.0)
|
||||
|
@ -175,9 +175,9 @@ GEM
|
|||
css_parser (1.21.1)
|
||||
addressable
|
||||
csv (3.3.5)
|
||||
database_cleaner-active_record (2.2.2)
|
||||
database_cleaner-active_record (2.2.1)
|
||||
activerecord (>= 5.a)
|
||||
database_cleaner-core (~> 2.0)
|
||||
database_cleaner-core (~> 2.0.0)
|
||||
database_cleaner-core (2.0.1)
|
||||
date (3.4.1)
|
||||
debug (1.11.0)
|
||||
|
@ -224,16 +224,16 @@ GEM
|
|||
mail (~> 2.7)
|
||||
email_validator (2.2.4)
|
||||
activemodel
|
||||
erb (5.0.2)
|
||||
erb (5.0.1)
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.2.11)
|
||||
tzinfo
|
||||
excon (1.2.8)
|
||||
excon (1.2.5)
|
||||
logger
|
||||
fabrication (3.0.0)
|
||||
faker (3.5.2)
|
||||
faker (3.5.1)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.13.4)
|
||||
faraday (2.13.1)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
|
@ -241,7 +241,7 @@ GEM
|
|||
faraday (>= 1, < 3)
|
||||
faraday-httpclient (2.0.2)
|
||||
httpclient (>= 2.2)
|
||||
faraday-net_http (3.4.1)
|
||||
faraday-net_http (3.4.0)
|
||||
net-http (>= 0.5.0)
|
||||
fast_blank (1.0.1)
|
||||
fastimage (2.4.0)
|
||||
|
@ -266,14 +266,14 @@ GEM
|
|||
fog-openstack (1.1.5)
|
||||
fog-core (~> 2.1)
|
||||
fog-json (>= 1.0)
|
||||
formatador (1.1.1)
|
||||
formatador (1.1.0)
|
||||
forwardable (1.3.3)
|
||||
fugit (1.11.1)
|
||||
et-orbi (~> 1, >= 1.2.11)
|
||||
raabro (~> 1.4)
|
||||
globalid (1.2.1)
|
||||
activesupport (>= 6.1)
|
||||
google-protobuf (4.31.1)
|
||||
google-protobuf (4.31.0)
|
||||
bigdecimal
|
||||
rake (>= 13)
|
||||
googleapis-common-protos-types (1.20.0)
|
||||
|
@ -287,21 +287,21 @@ GEM
|
|||
activesupport (>= 5.1)
|
||||
haml (>= 4.0.6)
|
||||
railties (>= 5.1)
|
||||
haml_lint (0.66.0)
|
||||
haml_lint (0.64.0)
|
||||
haml (>= 5.0)
|
||||
parallel (~> 1.10)
|
||||
rainbow
|
||||
rubocop (>= 1.0)
|
||||
sysexits (~> 1.1)
|
||||
hashdiff (1.2.0)
|
||||
hashdiff (1.1.2)
|
||||
hashie (5.0.0)
|
||||
hcaptcha (7.1.0)
|
||||
json
|
||||
highline (3.1.2)
|
||||
reline
|
||||
hiredis (0.6.3)
|
||||
hiredis-client (0.25.1)
|
||||
redis-client (= 0.25.1)
|
||||
hiredis-client (0.24.0)
|
||||
redis-client (= 0.24.0)
|
||||
hkdf (0.3.0)
|
||||
htmlentities (4.3.4)
|
||||
http (5.3.1)
|
||||
|
@ -315,7 +315,7 @@ GEM
|
|||
http_accept_language (2.1.1)
|
||||
httpclient (2.9.0)
|
||||
mutex_m
|
||||
httplog (1.7.3)
|
||||
httplog (1.7.0)
|
||||
rack (>= 2.0)
|
||||
rainbow (>= 2.0.0)
|
||||
i18n (1.14.7)
|
||||
|
@ -335,7 +335,7 @@ GEM
|
|||
inline_svg (1.10.0)
|
||||
activesupport (>= 3.0)
|
||||
nokogiri (>= 1.6)
|
||||
io-console (0.8.1)
|
||||
io-console (0.8.0)
|
||||
irb (1.15.2)
|
||||
pp (>= 0.6.0)
|
||||
rdoc (>= 4.0.0)
|
||||
|
@ -345,7 +345,7 @@ GEM
|
|||
azure-blob (~> 0.5.2)
|
||||
hashie (~> 5.0)
|
||||
jmespath (1.6.2)
|
||||
json (2.13.2)
|
||||
json (2.12.2)
|
||||
json-canonicalization (1.0.0)
|
||||
json-jwt (1.16.7)
|
||||
activesupport (>= 4.2)
|
||||
|
@ -362,14 +362,14 @@ GEM
|
|||
rack (>= 2.2, < 4)
|
||||
rdf (~> 3.3)
|
||||
rexml (~> 3.2)
|
||||
json-ld-preloaded (3.3.2)
|
||||
json-ld-preloaded (3.3.1)
|
||||
json-ld (~> 3.3)
|
||||
rdf (~> 3.3)
|
||||
json-schema (5.2.1)
|
||||
json-schema (5.1.1)
|
||||
addressable (~> 2.8)
|
||||
bigdecimal (~> 3.1)
|
||||
jsonapi-renderer (0.2.2)
|
||||
jwt (2.10.2)
|
||||
jwt (2.10.1)
|
||||
base64
|
||||
kaminari (1.2.2)
|
||||
activesupport (>= 4.1.0)
|
||||
|
@ -403,7 +403,7 @@ GEM
|
|||
rexml
|
||||
link_header (0.0.8)
|
||||
lint_roller (1.1.0)
|
||||
linzer (0.7.7)
|
||||
linzer (0.7.3)
|
||||
cgi (~> 0.4.2)
|
||||
forwardable (~> 1.3, >= 1.3.3)
|
||||
logger (~> 1.7, >= 1.7.0)
|
||||
|
@ -433,21 +433,21 @@ GEM
|
|||
marcel (1.0.4)
|
||||
mario-redis-lock (1.2.1)
|
||||
redis (>= 3.0.5)
|
||||
matrix (0.4.3)
|
||||
matrix (0.4.2)
|
||||
memory_profiler (1.1.0)
|
||||
mime-types (3.7.0)
|
||||
logger
|
||||
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_portile2 (2.8.9)
|
||||
minitest (5.25.5)
|
||||
msgpack (1.8.0)
|
||||
multi_json (1.17.0)
|
||||
multi_json (1.15.0)
|
||||
mutex_m (0.3.0)
|
||||
net-http (0.6.0)
|
||||
uri
|
||||
net-imap (0.5.9)
|
||||
net-imap (0.5.8)
|
||||
date
|
||||
net-protocol
|
||||
net-ldap (0.19.0)
|
||||
|
@ -468,7 +468,7 @@ GEM
|
|||
hashie (>= 3.4.6)
|
||||
rack (>= 2.2.3)
|
||||
rack-protection
|
||||
omniauth-cas (3.0.2)
|
||||
omniauth-cas (3.0.1)
|
||||
addressable (~> 2.8)
|
||||
nokogiri (~> 1.12)
|
||||
omniauth (~> 2.1)
|
||||
|
@ -515,7 +515,7 @@ GEM
|
|||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-active_support (~> 0.7)
|
||||
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-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-instrumentation-rack (~> 0.21)
|
||||
|
@ -553,7 +553,7 @@ GEM
|
|||
opentelemetry-instrumentation-faraday (0.27.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-instrumentation-http (0.25.1)
|
||||
opentelemetry-instrumentation-http (0.25.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-instrumentation-http_client (0.23.0)
|
||||
|
@ -597,20 +597,20 @@ GEM
|
|||
opentelemetry-semantic_conventions (1.11.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
orm_adapter (0.5.0)
|
||||
ostruct (0.6.3)
|
||||
ostruct (0.6.1)
|
||||
ox (2.14.23)
|
||||
bigdecimal (>= 3.0)
|
||||
parallel (1.27.0)
|
||||
parser (3.3.9.0)
|
||||
parser (3.3.8.0)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
parslet (2.0.0)
|
||||
pastel (0.8.0)
|
||||
tty-color (~> 0.5)
|
||||
pg (1.6.1)
|
||||
pg (1.5.9)
|
||||
pghero (3.7.0)
|
||||
activerecord (>= 7.1)
|
||||
playwright-ruby-client (1.54.1)
|
||||
playwright-ruby-client (1.52.0)
|
||||
concurrent-ruby (>= 1.1.6)
|
||||
mime-types (>= 3.0)
|
||||
pp (0.6.2)
|
||||
|
@ -627,15 +627,16 @@ GEM
|
|||
prism (1.4.0)
|
||||
prometheus_exporter (2.2.0)
|
||||
webrick
|
||||
propshaft (1.2.1)
|
||||
propshaft (1.1.0)
|
||||
actionpack (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
rack
|
||||
railties (>= 7.0.0)
|
||||
psych (5.2.6)
|
||||
date
|
||||
stringio
|
||||
public_suffix (6.0.2)
|
||||
puma (6.6.1)
|
||||
puma (6.6.0)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.5.0)
|
||||
activesupport (>= 3.0.0)
|
||||
|
@ -681,7 +682,7 @@ GEM
|
|||
activesupport (= 8.0.2)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 8.0.2)
|
||||
rails-dom-testing (2.3.0)
|
||||
rails-dom-testing (2.2.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
nokogiri (>= 1.6)
|
||||
|
@ -701,28 +702,23 @@ GEM
|
|||
zeitwerk (~> 2.6)
|
||||
rainbow (3.1.1)
|
||||
rake (13.3.0)
|
||||
rdf (3.3.4)
|
||||
rdf (3.3.2)
|
||||
bcp47_spec (~> 0.2)
|
||||
bigdecimal (~> 3.1, >= 3.1.5)
|
||||
link_header (~> 0.0, >= 0.0.8)
|
||||
logger (~> 1.5)
|
||||
ostruct (~> 0.6)
|
||||
readline (~> 0.0)
|
||||
rdf-normalize (0.7.0)
|
||||
rdf (~> 3.3)
|
||||
rdoc (6.14.2)
|
||||
rdoc (6.14.1)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
readline (0.0.4)
|
||||
reline
|
||||
redcarpet (3.6.1)
|
||||
redis (4.8.1)
|
||||
redis-client (0.25.1)
|
||||
redis-client (0.24.0)
|
||||
connection_pool
|
||||
redlock (1.3.2)
|
||||
redis (>= 3.0.0, < 6.0)
|
||||
regexp_parser (2.11.0)
|
||||
reline (0.6.2)
|
||||
regexp_parser (2.10.0)
|
||||
reline (0.6.1)
|
||||
io-console (~> 0.5)
|
||||
request_store (1.7.0)
|
||||
rack (>= 1.4)
|
||||
|
@ -731,17 +727,17 @@ GEM
|
|||
railties (>= 5.2)
|
||||
rexml (3.4.1)
|
||||
rotp (6.3.0)
|
||||
rouge (4.6.0)
|
||||
rouge (4.5.2)
|
||||
rpam2 (4.0.2)
|
||||
rqrcode (3.1.0)
|
||||
chunky_png (~> 1.0)
|
||||
rqrcode_core (~> 2.0)
|
||||
rqrcode_core (2.0.0)
|
||||
rspec (3.13.1)
|
||||
rspec (3.13.0)
|
||||
rspec-core (~> 3.13.0)
|
||||
rspec-expectations (~> 3.13.0)
|
||||
rspec-mocks (~> 3.13.0)
|
||||
rspec-core (3.13.5)
|
||||
rspec-core (3.13.4)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-expectations (3.13.5)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
|
@ -759,13 +755,13 @@ GEM
|
|||
rspec-expectations (~> 3.13)
|
||||
rspec-mocks (~> 3.13)
|
||||
rspec-support (~> 3.13)
|
||||
rspec-sidekiq (5.2.0)
|
||||
rspec-sidekiq (5.1.0)
|
||||
rspec-core (~> 3.0)
|
||||
rspec-expectations (~> 3.0)
|
||||
rspec-mocks (~> 3.0)
|
||||
sidekiq (>= 5, < 9)
|
||||
rspec-support (3.13.4)
|
||||
rubocop (1.79.2)
|
||||
rubocop (1.77.0)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
|
@ -773,10 +769,10 @@ GEM
|
|||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.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)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.46.0)
|
||||
rubocop-ast (1.45.1)
|
||||
parser (>= 3.3.7.2)
|
||||
prism (~> 1.4)
|
||||
rubocop-capybara (2.22.1)
|
||||
|
@ -819,7 +815,7 @@ GEM
|
|||
sanitize (7.0.0)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.16.8)
|
||||
scenic (1.9.0)
|
||||
scenic (1.8.0)
|
||||
activerecord (>= 4.0.0)
|
||||
railties (>= 4.0.0)
|
||||
securerandom (0.4.1)
|
||||
|
@ -833,9 +829,10 @@ GEM
|
|||
redis-client (>= 0.22.2)
|
||||
sidekiq-bulk (0.2.0)
|
||||
sidekiq
|
||||
sidekiq-scheduler (6.0.1)
|
||||
sidekiq-scheduler (5.0.6)
|
||||
rufus-scheduler (~> 3.2)
|
||||
sidekiq (>= 7.3, < 9)
|
||||
sidekiq (>= 6, < 8)
|
||||
tilt (>= 1.4.0, < 3)
|
||||
sidekiq-unique-jobs (8.0.11)
|
||||
concurrent-ruby (~> 1.0, >= 1.0.5)
|
||||
sidekiq (>= 7.0.0, < 9.0.0)
|
||||
|
@ -849,7 +846,7 @@ GEM
|
|||
docile (~> 1.1)
|
||||
simplecov-html (~> 0.11)
|
||||
simplecov_json_formatter (~> 0.1)
|
||||
simplecov-html (0.13.2)
|
||||
simplecov-html (0.13.1)
|
||||
simplecov-lcov (0.8.0)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
stackprof (0.2.27)
|
||||
|
@ -858,7 +855,7 @@ GEM
|
|||
stoplight (4.1.1)
|
||||
redlock (~> 1.0)
|
||||
stringio (3.1.7)
|
||||
strong_migrations (2.5.0)
|
||||
strong_migrations (2.4.0)
|
||||
activerecord (>= 7.1)
|
||||
swd (2.0.3)
|
||||
activesupport (>= 3)
|
||||
|
@ -866,14 +863,14 @@ GEM
|
|||
faraday (~> 2.0)
|
||||
faraday-follow_redirects
|
||||
sysexits (1.2.0)
|
||||
temple (0.10.4)
|
||||
temple (0.10.3)
|
||||
terminal-table (4.0.0)
|
||||
unicode-display_width (>= 1.1.1, < 4)
|
||||
terrapin (1.1.1)
|
||||
terrapin (1.1.0)
|
||||
climate_control
|
||||
test-prof (1.4.4)
|
||||
thor (1.4.0)
|
||||
tilt (2.6.1)
|
||||
tilt (2.6.0)
|
||||
timeout (0.4.3)
|
||||
tpm-key_attestation (0.14.1)
|
||||
bindata (~> 2.4)
|
||||
|
@ -935,7 +932,7 @@ GEM
|
|||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
webrick (1.9.1)
|
||||
websocket-driver (0.8.0)
|
||||
websocket-driver (0.7.7)
|
||||
base64
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
|
@ -1011,7 +1008,7 @@ DEPENDENCIES
|
|||
letter_opener (~> 1.8)
|
||||
letter_opener_web (~> 3.0)
|
||||
link_header (~> 0.0)
|
||||
linzer (~> 0.7.7)
|
||||
linzer (~> 0.7.2)
|
||||
lograge (~> 0.12)
|
||||
mail (~> 2.8)
|
||||
mario-redis-lock (~> 1.2)
|
||||
|
@ -1081,7 +1078,7 @@ DEPENDENCIES
|
|||
shoulda-matchers
|
||||
sidekiq (< 8)
|
||||
sidekiq-bulk (~> 0.2.0)
|
||||
sidekiq-scheduler (~> 6.0)
|
||||
sidekiq-scheduler (~> 5.0)
|
||||
sidekiq-unique-jobs (> 8)
|
||||
simple-navigation (~> 4.4)
|
||||
simple_form (~> 5.2)
|
||||
|
@ -1105,4 +1102,4 @@ RUBY VERSION
|
|||
ruby 3.4.1p0
|
||||
|
||||
BUNDLED WITH
|
||||
2.7.1
|
||||
2.6.9
|
||||
|
|
62
README.md
62
README.md
|
@ -17,71 +17,71 @@
|
|||
<img src="https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg" alt="Crowdin" /></a>
|
||||
</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
|
||||
|
||||
- [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)
|
||||
- [Blog 📰](https://blog.joinmastodon.org)
|
||||
- [Documentation 📚](https://docs.joinmastodon.org)
|
||||
- [Official container image 🚢](https://github.com/mastodon/mastodon/pkgs/container/mastodon)
|
||||
- [Blog](https://blog.joinmastodon.org)
|
||||
- [Documentation](https://docs.joinmastodon.org)
|
||||
- [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
|
||||
|
||||
<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
|
||||
|
||||
### Tech stack
|
||||
|
||||
- [Ruby on Rails](https://github.com/rails/rails) powers the REST API and other web pages.
|
||||
- [PostgreSQL](https://www.postgresql.org/) is the main database.
|
||||
- [Redis](https://redis.io/) and [Sidekiq](https://sidekiq.org/) are used for caching and queueing.
|
||||
- [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)
|
||||
- **Ruby on Rails** powers the REST API and other web pages
|
||||
- **React.js** and **Redux** are used for the dynamic parts of the interface
|
||||
- **Node.js** powers the streaming API
|
||||
|
||||
### Requirements
|
||||
|
||||
- **Ruby** 3.2+
|
||||
- **PostgreSQL** 13+
|
||||
- **Redis** 6.2+
|
||||
- **Ruby** 3.2+
|
||||
- **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
|
||||
|
||||
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.
|
||||
|
||||
If you would like to help with translations 🌐 you can do so on [Crowdin](https://crowdin.com/project/mastodon).
|
||||
|
||||
## LICENSE
|
||||
## License
|
||||
|
||||
Copyright (c) 2016-2025 Eugen Rochko (+ [`mastodon authors`](AUTHORS.md))
|
||||
|
||||
Licensed under GNU Affero General Public License as stated in the [LICENSE](LICENSE):
|
||||
|
||||
```text
|
||||
```
|
||||
Copyright (c) 2016-2025 Eugen Rochko & other Mastodon contributors
|
||||
|
||||
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
|
||||
with this program. If not, see https://www.gnu.org/licenses/
|
||||
```
|
||||
|
||||
[CONTRIBUTING]: CONTRIBUTING.md
|
||||
[DEVELOPMENT]: docs/DEVELOPMENT.md
|
||||
[OpenCollective]: https://opencollective.com/mastodon
|
||||
|
|
|
@ -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
|
|
@ -16,14 +16,11 @@ module Admin
|
|||
def batch
|
||||
authorize :account, :index?
|
||||
|
||||
@form = Form::AccountBatch.new(
|
||||
form_account_batch_params.merge(
|
||||
action: action_from_button,
|
||||
current_account:,
|
||||
query: filtered_accounts,
|
||||
select_all_matching: params[:select_all_matching]
|
||||
)
|
||||
)
|
||||
@form = Form::AccountBatch.new(form_account_batch_params)
|
||||
@form.current_account = current_account
|
||||
@form.action = action_from_button
|
||||
@form.select_all_matching = params[:select_all_matching]
|
||||
@form.query = filtered_accounts
|
||||
@form.save
|
||||
rescue ActionController::ParameterMissing
|
||||
flash[:alert] = I18n.t('admin.accounts.no_account_selected')
|
||||
|
|
|
@ -6,7 +6,7 @@ module Admin
|
|||
|
||||
def index
|
||||
authorize :audit_log, :index?
|
||||
@auditable_accounts = Account.auditable.select(:id, :username).order(username: :asc)
|
||||
@auditable_accounts = Account.auditable.select(:id, :username)
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
@ -19,13 +19,15 @@ module Admin
|
|||
|
||||
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
|
||||
|
||||
private
|
||||
|
||||
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
|
||||
|
||||
def user_confirmed?
|
||||
|
|
|
@ -18,7 +18,7 @@ class Admin::Disputes::AppealsController < Admin::BaseController
|
|||
end
|
||||
|
||||
def reject
|
||||
authorize @appeal, :reject?
|
||||
authorize @appeal, :approve?
|
||||
log_action :reject, @appeal
|
||||
@appeal.reject!(current_account)
|
||||
UserMailer.appeal_rejected(@appeal.account.user, @appeal).deliver_later
|
||||
|
|
|
@ -36,7 +36,7 @@ module Admin
|
|||
end
|
||||
|
||||
def edit
|
||||
authorize :domain_block, :update?
|
||||
authorize :domain_block, :create?
|
||||
end
|
||||
|
||||
def create
|
||||
|
@ -129,7 +129,7 @@ module Admin
|
|||
end
|
||||
|
||||
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
|
||||
|
|
|
@ -13,9 +13,27 @@ class Admin::Reports::ActionsController < Admin::BaseController
|
|||
|
||||
case action_from_button
|
||||
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'
|
||||
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
|
||||
return redirect_to admin_report_path(@report), alert: I18n.t('admin.reports.unknown_action_msg', action: action_from_button)
|
||||
end
|
||||
|
@ -25,26 +43,6 @@ class Admin::Reports::ActionsController < Admin::BaseController
|
|||
|
||||
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
|
||||
@report = Report.find(params[:report_id])
|
||||
end
|
||||
|
|
|
@ -14,7 +14,8 @@ module Admin
|
|||
@admin_settings = Form::AdminSettings.new(settings_params)
|
||||
|
||||
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
|
||||
render :show
|
||||
end
|
||||
|
|
|
@ -5,7 +5,6 @@ module Admin
|
|||
before_action :set_tag, except: [:index]
|
||||
|
||||
PER_PAGE = 20
|
||||
PERIOD_DAYS = 6.days
|
||||
|
||||
def index
|
||||
authorize :tag, :index?
|
||||
|
@ -16,7 +15,7 @@ module Admin
|
|||
def show
|
||||
authorize @tag, :show?
|
||||
|
||||
@time_period = report_range
|
||||
@time_period = (6.days.ago.to_date...Time.now.utc.to_date)
|
||||
end
|
||||
|
||||
def update
|
||||
|
@ -25,7 +24,7 @@ module Admin
|
|||
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')
|
||||
else
|
||||
@time_period = report_range
|
||||
@time_period = (6.days.ago.to_date...Time.now.utc.to_date)
|
||||
|
||||
render :show
|
||||
end
|
||||
|
@ -37,10 +36,6 @@ module Admin
|
|||
@tag = Tag.find(params[:id])
|
||||
end
|
||||
|
||||
def report_range
|
||||
(PERIOD_DAYS.ago.to_date...Time.now.utc.to_date)
|
||||
end
|
||||
|
||||
def tag_params
|
||||
params
|
||||
.expect(tag: [:name, :display_name, :trendable, :usable, :listable])
|
||||
|
|
|
@ -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
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
class Api::V1::Admin::TagsController < Api::BaseController
|
||||
include Authorization
|
||||
|
||||
before_action -> { authorize_if_got_token! :'admin:read' }, only: [:index, :show]
|
||||
before_action -> { authorize_if_got_token! :'admin:write' }, only: :update
|
||||
|
||||
|
|
|
@ -7,7 +7,6 @@ class Api::V1::InvitesController < Api::BaseController
|
|||
skip_around_action :set_locale
|
||||
|
||||
before_action :set_invite
|
||||
before_action :check_valid_usage!
|
||||
before_action :check_enabled_registrations!
|
||||
|
||||
# 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])
|
||||
end
|
||||
|
||||
def check_valid_usage!
|
||||
render json: { error: I18n.t('invites.invalid') }, status: 401 unless @invite.valid_for_use?
|
||||
end
|
||||
|
||||
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)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -16,7 +16,16 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController
|
|||
def create
|
||||
with_redis_lock("push_subscription:#{current_user.id}") do
|
||||
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
|
||||
|
||||
render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer
|
||||
|
@ -46,18 +55,6 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController
|
|||
not_found if @push_subscription.nil?
|
||||
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
|
||||
params.expect(subscription: [:endpoint, :standard, keys: [:auth, :p256dh]])
|
||||
end
|
||||
|
|
|
@ -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
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
class Api::V1::StatusesController < Api::BaseController
|
||||
include Authorization
|
||||
include AsyncRefreshesConcern
|
||||
|
||||
before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [: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_status, only: [:show, :context]
|
||||
before_action :set_thread, only: [:create]
|
||||
before_action :set_quoted_status, only: [:create]
|
||||
before_action :check_statuses_limit, only: [:index]
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
ActivityPub::FetchAllRepliesWorker.perform_async(@status.id) if !current_account.nil? && @status.should_fetch_replies?
|
||||
end
|
||||
|
||||
def create
|
||||
|
@ -81,8 +67,6 @@ class Api::V1::StatusesController < Api::BaseController
|
|||
current_user.account,
|
||||
text: status_params[:status],
|
||||
thread: @thread,
|
||||
quoted_status: @quoted_status,
|
||||
quote_approval_policy: quote_approval_policy,
|
||||
media_ids: status_params[:media_ids],
|
||||
sensitive: status_params[:sensitive],
|
||||
spoiler_text: status_params[:spoiler_text],
|
||||
|
@ -114,8 +98,7 @@ class Api::V1::StatusesController < Api::BaseController
|
|||
sensitive: status_params[:sensitive],
|
||||
language: status_params[:language],
|
||||
spoiler_text: status_params[:spoiler_text],
|
||||
poll: status_params[:poll],
|
||||
quote_approval_policy: quote_approval_policy
|
||||
poll: status_params[:poll]
|
||||
)
|
||||
|
||||
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
|
||||
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
|
||||
raise(Mastodon::ValidationError) if status_ids.size > DEFAULT_STATUSES_LIMIT
|
||||
end
|
||||
|
@ -181,8 +154,6 @@ class Api::V1::StatusesController < Api::BaseController
|
|||
params.permit(
|
||||
:status,
|
||||
:in_reply_to_id,
|
||||
:quoted_status_id,
|
||||
:quote_approval_policy,
|
||||
:sensitive,
|
||||
:spoiler_text,
|
||||
:visibility,
|
||||
|
@ -205,23 +176,6 @@ class Api::V1::StatusesController < Api::BaseController
|
|||
)
|
||||
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
|
||||
@status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer
|
||||
end
|
||||
|
|
|
@ -20,7 +20,7 @@ class Api::V2::SearchController < Api::BaseController
|
|||
@search = Search.new(search_results)
|
||||
render json: @search, serializer: REST::SearchSerializer
|
||||
rescue Mastodon::SyntaxError
|
||||
unprocessable_content
|
||||
unprocessable_entity
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
not_found
|
||||
end
|
||||
|
|
|
@ -49,7 +49,7 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
|
|||
{
|
||||
policy: 'all',
|
||||
alerts: Notification::TYPES.index_with { alerts_enabled },
|
||||
}.deep_stringify_keys
|
||||
}
|
||||
end
|
||||
|
||||
def alerts_enabled
|
||||
|
|
|
@ -28,7 +28,7 @@ class ApplicationController < ActionController::Base
|
|||
rescue_from Mastodon::NotPermittedError, with: :forbidden
|
||||
rescue_from ActionController::RoutingError, ActiveRecord::RecordNotFound, with: :not_found
|
||||
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::HTTP_CONNECTION_ERRORS, with: :internal_server_error)
|
||||
|
@ -123,7 +123,7 @@ class ApplicationController < ActionController::Base
|
|||
respond_with_error(410)
|
||||
end
|
||||
|
||||
def unprocessable_content
|
||||
def unprocessable_entity
|
||||
respond_with_error(422)
|
||||
end
|
||||
|
||||
|
|
|
@ -38,7 +38,8 @@ class Auth::OmniauthCallbacksController < Devise::OmniauthCallbacksController
|
|||
private
|
||||
|
||||
def record_login_activity
|
||||
@user.login_activities.create(
|
||||
LoginActivity.create(
|
||||
user: @user,
|
||||
success: true,
|
||||
authentication_method: :omniauth,
|
||||
provider: @provider,
|
||||
|
|
|
@ -19,7 +19,8 @@ class Auth::PasswordsController < Devise::PasswordsController
|
|||
private
|
||||
|
||||
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
|
||||
|
||||
def reset_password_token_is_valid?
|
||||
|
|
|
@ -12,8 +12,6 @@ class Auth::SessionsController < Devise::SessionsController
|
|||
skip_before_action :require_functional!
|
||||
skip_before_action :update_user_sign_in
|
||||
|
||||
around_action :preserve_stored_location, only: :destroy, if: :continue_after?
|
||||
|
||||
prepend_before_action :check_suspicious!, only: [:create]
|
||||
|
||||
include Auth::TwoFactorAuthenticationConcern
|
||||
|
@ -33,9 +31,11 @@ class Auth::SessionsController < Devise::SessionsController
|
|||
end
|
||||
|
||||
def destroy
|
||||
tmp_stored_location = stored_location_for(:user)
|
||||
super
|
||||
session.delete(:challenge_passed_at)
|
||||
flash.delete(:notice)
|
||||
store_location_for(:user, tmp_stored_location) if continue_after?
|
||||
end
|
||||
|
||||
def webauthn_options
|
||||
|
@ -96,12 +96,6 @@ class Auth::SessionsController < Devise::SessionsController
|
|||
|
||||
private
|
||||
|
||||
def preserve_stored_location
|
||||
original_stored_location = stored_location_for(:user)
|
||||
yield
|
||||
store_location_for(:user, original_stored_location)
|
||||
end
|
||||
|
||||
def check_suspicious!
|
||||
user = find_user
|
||||
@login_is_suspicious = suspicious_sign_in?(user) unless user.nil?
|
||||
|
@ -157,11 +151,12 @@ class Auth::SessionsController < Devise::SessionsController
|
|||
sign_in(user)
|
||||
flash.delete(:notice)
|
||||
|
||||
user.login_activities.create(
|
||||
request_details.merge(
|
||||
LoginActivity.create(
|
||||
user: user,
|
||||
success: true,
|
||||
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
|
||||
|
@ -172,12 +167,13 @@ class Auth::SessionsController < Devise::SessionsController
|
|||
end
|
||||
|
||||
def on_authentication_failure(user, security_measure, failure_reason)
|
||||
user.login_activities.create(
|
||||
request_details.merge(
|
||||
LoginActivity.create(
|
||||
user: user,
|
||||
success: false,
|
||||
authentication_method: security_measure,
|
||||
failure_reason: failure_reason,
|
||||
success: false
|
||||
)
|
||||
ip: request.remote_ip,
|
||||
user_agent: request.user_agent
|
||||
)
|
||||
|
||||
# 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!
|
||||
end
|
||||
|
||||
def request_details
|
||||
{
|
||||
ip: request.remote_ip,
|
||||
user_agent: request.user_agent,
|
||||
}
|
||||
end
|
||||
|
||||
def second_factor_attempts_key(user)
|
||||
"2fa_auth_attempts:#{user.id}:#{Time.now.utc.hour}"
|
||||
end
|
||||
|
|
|
@ -5,18 +5,6 @@ module Auth::CaptchaConcern
|
|||
|
||||
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
|
||||
helper_method :render_captcha
|
||||
end
|
||||
|
@ -54,9 +42,20 @@ module Auth::CaptchaConcern
|
|||
end
|
||||
|
||||
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
|
||||
|
||||
def render_captcha
|
||||
|
@ -64,24 +63,4 @@ module Auth::CaptchaConcern
|
|||
|
||||
hcaptcha_tags
|
||||
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
|
||||
|
|
|
@ -5,6 +5,6 @@ class Settings::LoginActivitiesController < Settings::BaseController
|
|||
skip_before_action :require_functional!
|
||||
|
||||
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
|
||||
|
|
|
@ -22,7 +22,7 @@ class Settings::Migration::RedirectsController < Settings::BaseController
|
|||
end
|
||||
|
||||
def destroy
|
||||
if current_account.moved?
|
||||
if current_account.moved_to_account_id.present?
|
||||
current_account.update!(moved_to_account: nil)
|
||||
ActivityPub::UpdateDistributionWorker.perform_async(current_account.id)
|
||||
end
|
||||
|
|
|
@ -8,7 +8,8 @@ class Settings::SessionsController < Settings::BaseController
|
|||
|
||||
def 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
|
||||
|
||||
private
|
||||
|
|
|
@ -52,7 +52,7 @@ module Settings
|
|||
end
|
||||
else
|
||||
flash[:error] = I18n.t('webauthn_credentials.create.error')
|
||||
status = :unprocessable_content
|
||||
status = :unprocessable_entity
|
||||
end
|
||||
else
|
||||
flash[:error] = t('webauthn_credentials.create.error')
|
||||
|
@ -86,11 +86,13 @@ module Settings
|
|||
private
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
|
|
@ -11,7 +11,6 @@ class StatusesController < ApplicationController
|
|||
before_action :require_account_signature!, only: [:show, :activity], if: -> { request.format == :json && authorized_fetch_mode? }
|
||||
before_action :set_status
|
||||
before_action :redirect_to_original, only: :show
|
||||
before_action :verify_embed_allowed, only: :embed
|
||||
|
||||
after_action :set_link_headers
|
||||
|
||||
|
@ -41,6 +40,8 @@ class StatusesController < ApplicationController
|
|||
end
|
||||
|
||||
def embed
|
||||
return not_found if @status.hidden? || @status.reblog?
|
||||
|
||||
expires_in 180, public: true
|
||||
response.headers.delete('X-Frame-Options')
|
||||
|
||||
|
@ -49,10 +50,6 @@ class StatusesController < ApplicationController
|
|||
|
||||
private
|
||||
|
||||
def verify_embed_allowed
|
||||
not_found if @status.hidden? || @status.reblog?
|
||||
end
|
||||
|
||||
def set_link_headers
|
||||
response.headers['Link'] = LinkHeader.new(
|
||||
[[ActivityPub::TagManager.instance.uri_for(@status), [%w(rel alternate), %w(type application/activity+json)]]]
|
||||
|
|
|
@ -13,8 +13,6 @@ module Admin::ActionLogsHelper
|
|||
end
|
||||
when 'UserRole'
|
||||
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'
|
||||
link_to "##{log.human_identifier.presence || log.target_id}", admin_report_path(log.target_id)
|
||||
when 'Instance', 'DomainBlock', 'DomainAllow', 'UnavailableDomain'
|
||||
|
|
|
@ -39,12 +39,6 @@ module ContextHelper
|
|||
'automaticApproval' => { '@id' => 'gts:automaticApproval', '@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
|
||||
|
||||
def full_context
|
||||
|
|
18
app/helpers/email_helper.rb
Normal file
18
app/helpers/email_helper.rb
Normal 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
|
|
@ -65,13 +65,13 @@ module FormattingHelper
|
|||
end
|
||||
|
||||
def rss_content_preroll(status)
|
||||
return unless status.spoiler_text?
|
||||
|
||||
if status.spoiler_text?
|
||||
safe_join [
|
||||
tag.p { spoiler_with_warning(status) },
|
||||
tag.hr,
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
def spoiler_with_warning(status)
|
||||
safe_join [
|
||||
|
@ -81,12 +81,12 @@ module FormattingHelper
|
|||
end
|
||||
|
||||
def rss_content_postroll(status)
|
||||
return unless status.preloadable_poll
|
||||
|
||||
if status.preloadable_poll
|
||||
tag.p do
|
||||
poll_option_tags(status)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def poll_option_tags(status)
|
||||
safe_join(
|
||||
|
|
|
@ -39,8 +39,18 @@ module HomeHelper
|
|||
end
|
||||
end
|
||||
|
||||
def field_verified_class(verified)
|
||||
if verified
|
||||
def obscured_counter(count)
|
||||
if count <= 0
|
||||
'0'
|
||||
elsif count == 1
|
||||
'1'
|
||||
else
|
||||
'1+'
|
||||
end
|
||||
end
|
||||
|
||||
def custom_field_classes(field)
|
||||
if field.verified?
|
||||
'verified'
|
||||
else
|
||||
'emojify'
|
||||
|
|
|
@ -134,7 +134,7 @@ module JsonLdHelper
|
|||
patch_for_forwarding!(value, compacted_value)
|
||||
elsif 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|
|
||||
if v.is_a?(Hash) && vc.is_a?(Hash)
|
||||
|
|
|
@ -24,8 +24,7 @@ module ThemeHelper
|
|||
end
|
||||
|
||||
def custom_stylesheet
|
||||
return if active_custom_stylesheet.blank?
|
||||
|
||||
if active_custom_stylesheet.present?
|
||||
stylesheet_link_tag(
|
||||
custom_css_path(active_custom_stylesheet),
|
||||
host: root_url,
|
||||
|
@ -33,16 +32,17 @@ module ThemeHelper
|
|||
skip_pipeline: true
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
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)]
|
||||
.compact_blank
|
||||
.join('-')
|
||||
end
|
||||
end
|
||||
|
||||
def cached_custom_css_digest
|
||||
Rails.cache.fetch(:setting_digest_custom_css) do
|
||||
|
|
|
@ -1,3 +1 @@
|
|||
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 |
|
@ -228,8 +228,6 @@ export function submitCompose() {
|
|||
visibility: getState().getIn(['compose', 'privacy']),
|
||||
poll: getState().getIn(['compose', 'poll'], null),
|
||||
language: getState().getIn(['compose', 'language']),
|
||||
quoted_status_id: getState().getIn(['compose', 'quoted_status_id']),
|
||||
quote_approval_policy: getState().getIn(['compose', 'quote_policy']),
|
||||
},
|
||||
headers: {
|
||||
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
|
||||
|
|
|
@ -1,18 +1,9 @@
|
|||
import { createAction } from '@reduxjs/toolkit';
|
||||
import type { List as ImmutableList, Map as ImmutableMap } from 'immutable';
|
||||
|
||||
import { apiUpdateMedia } from 'mastodon/api/compose';
|
||||
import type { ApiMediaAttachmentJSON } from 'mastodon/api_types/media_attachments';
|
||||
import type { MediaAttachment } from 'mastodon/models/media_attachment';
|
||||
import {
|
||||
createDataLoadingThunk,
|
||||
createAppThunk,
|
||||
} from 'mastodon/store/typed_functions';
|
||||
|
||||
import type { ApiQuotePolicy } from '../api_types/quotes';
|
||||
import type { Status } from '../models/status';
|
||||
|
||||
import { ensureComposeIsVisible } from './compose';
|
||||
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
||||
|
||||
type SimulatedMediaAttachmentJSON = ApiMediaAttachmentJSON & {
|
||||
unattached?: boolean;
|
||||
|
@ -77,26 +68,3 @@ export const changeUploadCompose = createDataLoadingThunk(
|
|||
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',
|
||||
);
|
||||
|
|
|
@ -1,8 +1,4 @@
|
|||
import {
|
||||
apiReblog,
|
||||
apiUnreblog,
|
||||
apiRevokeQuote,
|
||||
} from 'mastodon/api/interactions';
|
||||
import { apiReblog, apiUnreblog } from 'mastodon/api/interactions';
|
||||
import type { StatusVisibility } from 'mastodon/models/status';
|
||||
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
||||
|
||||
|
@ -37,19 +33,3 @@ export const unreblog = createDataLoadingThunk(
|
|||
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;
|
||||
},
|
||||
);
|
||||
|
|
|
@ -31,9 +31,7 @@ import { NOTIFICATIONS_FILTER_SET } from './notifications';
|
|||
import { saveSettings } from './settings';
|
||||
|
||||
function excludeAllTypesExcept(filter: string) {
|
||||
return allNotificationTypes.filter(
|
||||
(item) => item !== filter && !(item === 'quote' && filter === 'mention'),
|
||||
);
|
||||
return allNotificationTypes.filter((item) => item !== filter);
|
||||
}
|
||||
|
||||
function getExcludedTypes(state: RootState) {
|
||||
|
@ -158,15 +156,12 @@ export const processNewNotificationForGroups = createAppAsyncThunk(
|
|||
const showInColumn =
|
||||
activeFilter === 'all'
|
||||
? notificationShows[notification.type] !== false
|
||||
: activeFilter === notification.type ||
|
||||
(activeFilter === 'mention' && notification.type === 'quote');
|
||||
: activeFilter === notification.type;
|
||||
|
||||
if (!showInColumn) return;
|
||||
|
||||
if (
|
||||
(notification.type === 'mention' ||
|
||||
notification.type === 'update' ||
|
||||
notification.type === 'quote') &&
|
||||
(notification.type === 'mention' || notification.type === 'update') &&
|
||||
notification.status?.filtered
|
||||
) {
|
||||
const filters = notification.status.filtered.filter((result) =>
|
||||
|
|
|
@ -31,7 +31,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
|
|||
|
||||
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'));
|
||||
|
||||
if (filters.some(result => result.filter.filter_action === 'hide')) {
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
import { createAction } from '@reduxjs/toolkit';
|
||||
|
||||
import { apiGetContext } from 'mastodon/api/statuses';
|
||||
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
||||
|
||||
|
@ -8,18 +6,13 @@ import { importFetchedStatuses } from './importer';
|
|||
export const fetchContext = createDataLoadingThunk(
|
||||
'status/context',
|
||||
({ statusId }: { statusId: string }) => apiGetContext(statusId),
|
||||
({ context, refresh }, { dispatch }) => {
|
||||
(context, { dispatch }) => {
|
||||
const statuses = context.ancestors.concat(context.descendants);
|
||||
|
||||
dispatch(importFetchedStatuses(statuses));
|
||||
|
||||
return {
|
||||
context,
|
||||
refresh,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
export const completeContextRefresh = createAction<{ statusId: string }>(
|
||||
'status/context/complete',
|
||||
);
|
||||
|
|
|
@ -20,50 +20,6 @@ export const getLinks = (response: AxiosResponse) => {
|
|||
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 setCSRFHeader = () => {
|
||||
|
@ -127,7 +83,7 @@ export default function api(withAuthorization = true) {
|
|||
return instance;
|
||||
}
|
||||
|
||||
type ApiUrl = `v${1 | '1_alpha' | 2}/${string}`;
|
||||
type ApiUrl = `v${1 | 2}/${string}`;
|
||||
type RequestParamsOrData = Record<string, unknown>;
|
||||
|
||||
export async function apiRequest<ApiResponse = unknown>(
|
||||
|
|
|
@ -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}`);
|
|
@ -8,8 +8,3 @@ export const apiReblog = (statusId: string, visibility: StatusVisibility) =>
|
|||
|
||||
export const apiUnreblog = (statusId: string) =>
|
||||
apiRequestPost<Status>(`v1/statuses/${statusId}/unreblog`);
|
||||
|
||||
export const apiRevokeQuote = (quotedStatusId: string, statusId: string) =>
|
||||
apiRequestPost<Status>(
|
||||
`v1/statuses/${quotedStatusId}/quotes/${statusId}/revoke`,
|
||||
);
|
||||
|
|
|
@ -1,14 +1,5 @@
|
|||
import api, { getAsyncRefreshHeader } from 'mastodon/api';
|
||||
import { apiRequestGet } from 'mastodon/api';
|
||||
import type { ApiContextJSON } from 'mastodon/api_types/statuses';
|
||||
|
||||
export const apiGetContext = async (statusId: string) => {
|
||||
const response = await api().request<ApiContextJSON>({
|
||||
method: 'GET',
|
||||
url: `/api/v1/statuses/${statusId}/context`,
|
||||
});
|
||||
|
||||
return {
|
||||
context: response.data,
|
||||
refresh: getAsyncRefreshHeader(response),
|
||||
};
|
||||
};
|
||||
export const apiGetContext = (statusId: string) =>
|
||||
apiRequestGet<ApiContextJSON>(`v1/statuses/${statusId}/context`);
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
export interface ApiAsyncRefreshJSON {
|
||||
async_refresh: {
|
||||
id: string;
|
||||
status: 'running' | 'finished';
|
||||
result_count: number;
|
||||
};
|
||||
}
|
|
@ -13,7 +13,6 @@ export const allNotificationTypes = [
|
|||
'favourite',
|
||||
'reblog',
|
||||
'mention',
|
||||
'quote',
|
||||
'poll',
|
||||
'status',
|
||||
'update',
|
||||
|
@ -29,7 +28,6 @@ export type NotificationWithStatusType =
|
|||
| 'reblog'
|
||||
| 'status'
|
||||
| 'mention'
|
||||
| 'quote'
|
||||
| 'poll'
|
||||
| 'update';
|
||||
|
||||
|
|
|
@ -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;
|
|
@ -4,7 +4,6 @@ import type { ApiAccountJSON } from './accounts';
|
|||
import type { ApiCustomEmojiJSON } from './custom_emoji';
|
||||
import type { ApiMediaAttachmentJSON } from './media_attachments';
|
||||
import type { ApiPollJSON } from './polls';
|
||||
import type { ApiQuoteJSON } from './quotes';
|
||||
|
||||
// See app/modals/status.rb
|
||||
export type StatusVisibility =
|
||||
|
@ -119,7 +118,6 @@ export interface ApiStatusJSON {
|
|||
|
||||
card?: ApiPreviewCardJSON;
|
||||
poll?: ApiPollJSON;
|
||||
quote?: ApiQuoteJSON;
|
||||
}
|
||||
|
||||
export interface ApiContextJSON {
|
||||
|
|
|
@ -2,42 +2,27 @@ import { useCallback } from 'react';
|
|||
|
||||
import { useLinks } from 'mastodon/hooks/useLinks';
|
||||
|
||||
import { EmojiHTML } from '../features/emoji/emoji_html';
|
||||
import { useAppSelector } from '../store';
|
||||
import { isModernEmojiEnabled } from '../utils/environment';
|
||||
|
||||
interface AccountBioProps {
|
||||
note: string;
|
||||
className: string;
|
||||
accountId: string;
|
||||
showDropdown?: boolean;
|
||||
dropdownAccountId?: string;
|
||||
}
|
||||
|
||||
export const AccountBio: React.FC<AccountBioProps> = ({
|
||||
note,
|
||||
className,
|
||||
accountId,
|
||||
showDropdown = false,
|
||||
dropdownAccountId,
|
||||
}) => {
|
||||
const handleClick = useLinks(showDropdown);
|
||||
const handleClick = useLinks(!!dropdownAccountId);
|
||||
const handleNodeChange = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
if (!showDropdown || !node || node.childNodes.length === 0) {
|
||||
if (!dropdownAccountId || !node || node.childNodes.length === 0) {
|
||||
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) {
|
||||
return null;
|
||||
|
@ -46,11 +31,10 @@ export const AccountBio: React.FC<AccountBioProps> = ({
|
|||
return (
|
||||
<div
|
||||
className={`${className} translate`}
|
||||
dangerouslySetInnerHTML={{ __html: note }}
|
||||
onClickCapture={handleClick}
|
||||
ref={handleNodeChange}
|
||||
>
|
||||
<EmojiHTML htmlString={note} extraEmojis={extraEmojis} />
|
||||
</div>
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -37,6 +37,7 @@ export const GIFV = forwardRef<HTMLVideoElement, Props>(
|
|||
role='button'
|
||||
tabIndex={0}
|
||||
aria-label={alt}
|
||||
title={alt}
|
||||
lang={lang}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
|
@ -48,6 +49,7 @@ export const GIFV = forwardRef<HTMLVideoElement, Props>(
|
|||
role='button'
|
||||
tabIndex={0}
|
||||
aria-label={alt}
|
||||
title={alt}
|
||||
lang={lang}
|
||||
width={width}
|
||||
height={height}
|
||||
|
|
|
@ -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 "<kbd>n</kbd>
|
||||
" or "<kbd>/</kbd>" key. Press "
|
||||
<kbd>Backspace</kbd>" to clear the displayed hotkey.
|
||||
</p>
|
||||
<p>
|
||||
Try typing a sequence, like "<kbd>g</kbd>" shortly
|
||||
followed by "<kbd>h</kbd>", "<kbd>n</kbd>", or
|
||||
"<kbd>f</kbd>"
|
||||
</p>
|
||||
<p>
|
||||
Note that this playground doesn't support all hotkeys we use in
|
||||
the app.
|
||||
</p>
|
||||
<p>
|
||||
When a <button>Button</button> is focused, "
|
||||
<kbd>Enter</kbd>
|
||||
" should not trigger "open", but "<kbd>o</kbd>
|
||||
" should.
|
||||
</p>
|
||||
<p>
|
||||
When an input element is focused, hotkeys should not interfere with
|
||||
regular typing:
|
||||
</p>
|
||||
<input type='text' />
|
||||
</div>
|
||||
</Hotkeys>
|
||||
);
|
||||
},
|
||||
play: hotkeyTest,
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -102,7 +102,7 @@ export const HoverCardAccount = forwardRef<
|
|||
<>
|
||||
<div className='hover-card__text-row'>
|
||||
<AccountBio
|
||||
accountId={account.id}
|
||||
note={account.note_emojified}
|
||||
className='hover-card__bio'
|
||||
/>
|
||||
<AccountFields fields={account.fields} limit={2} />
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -8,9 +8,10 @@ import { Link } from 'react-router-dom';
|
|||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
import { HotKeys } from 'react-hotkeys';
|
||||
|
||||
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.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 { FilterWarning } from 'mastodon/components/filter_warning';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
|
@ -34,6 +35,7 @@ import StatusActionBar from './status_action_bar';
|
|||
import StatusContent from './status_content';
|
||||
import { StatusThreadLabel } from './status_thread_label';
|
||||
import { VisibilityIcon } from './visibility_icon';
|
||||
|
||||
const domParser = new DOMParser();
|
||||
|
||||
export const textForScreenReader = (intl, status, rebloggedByText = false) => {
|
||||
|
@ -323,11 +325,11 @@ class Status extends ImmutablePureComponent {
|
|||
};
|
||||
|
||||
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 => {
|
||||
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 = () => {
|
||||
|
@ -435,13 +437,13 @@ class Status extends ImmutablePureComponent {
|
|||
|
||||
if (hidden) {
|
||||
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}>
|
||||
<span>{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}</span>
|
||||
{status.get('spoiler_text').length > 0 && (<span>{status.get('spoiler_text')}</span>)}
|
||||
{expanded && <span>{status.get('content')}</span>}
|
||||
</div>
|
||||
</Hotkeys>
|
||||
</HotKeys>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -541,7 +543,7 @@ class Status extends ImmutablePureComponent {
|
|||
const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status);
|
||||
|
||||
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}>
|
||||
{!skipPrepend && prepend}
|
||||
|
||||
|
@ -602,7 +604,7 @@ class Status extends ImmutablePureComponent {
|
|||
}
|
||||
</div>
|
||||
</div>
|
||||
</Hotkeys>
|
||||
</HotKeys>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -67,28 +67,21 @@ const messages = defineMessages({
|
|||
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
|
||||
filter: { id: 'status.filter', defaultMessage: 'Filter this post' },
|
||||
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 quotedStatusId = status.getIn(['quote', 'quoted_status']);
|
||||
return ({
|
||||
const mapStateToProps = (state, { status }) => ({
|
||||
relationship: state.getIn(['relationships', status.getIn(['account', 'id'])]),
|
||||
quotedAccountId: quotedStatusId ? state.getIn(['statuses', quotedStatusId, 'account']) : null,
|
||||
});
|
||||
};
|
||||
|
||||
class StatusActionBar extends ImmutablePureComponent {
|
||||
static propTypes = {
|
||||
identity: identityContextPropShape,
|
||||
status: ImmutablePropTypes.map.isRequired,
|
||||
relationship: ImmutablePropTypes.record,
|
||||
quotedAccountId: ImmutablePropTypes.string,
|
||||
onReply: PropTypes.func,
|
||||
onFavourite: PropTypes.func,
|
||||
onReblog: PropTypes.func,
|
||||
onDelete: PropTypes.func,
|
||||
onRevokeQuote: PropTypes.func,
|
||||
onDirect: PropTypes.func,
|
||||
onMention: PropTypes.func,
|
||||
onMute: PropTypes.func,
|
||||
|
@ -117,7 +110,6 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
updateOnProps = [
|
||||
'status',
|
||||
'relationship',
|
||||
'quotedAccountId',
|
||||
'withDismiss',
|
||||
];
|
||||
|
||||
|
@ -198,10 +190,6 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
}
|
||||
};
|
||||
|
||||
handleRevokeQuoteClick = () => {
|
||||
this.props.onRevokeQuote(this.props.status);
|
||||
}
|
||||
|
||||
handleBlockClick = () => {
|
||||
const { status, relationship, onBlock, onUnblock } = this.props;
|
||||
const account = status.get('account');
|
||||
|
@ -253,7 +241,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
};
|
||||
|
||||
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 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(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')) {
|
||||
menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.handleMuteClick });
|
||||
} else {
|
||||
|
|
|
@ -14,8 +14,6 @@ import { Icon } from 'mastodon/components/icon';
|
|||
import { Poll } from 'mastodon/components/poll';
|
||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
||||
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)
|
||||
|
||||
|
@ -25,9 +23,6 @@ const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
|
|||
* @returns {string}
|
||||
*/
|
||||
export function getStatusContent(status) {
|
||||
if (isModernEmojiEnabled()) {
|
||||
return status.getIn(['translation', 'content']) || status.get('content');
|
||||
}
|
||||
return status.getIn(['translation', 'contentHtml']) || status.get('contentHtml');
|
||||
}
|
||||
|
||||
|
@ -48,13 +43,13 @@ class TranslateButton extends PureComponent {
|
|||
|
||||
return (
|
||||
<div className='translate-button'>
|
||||
<button className='link-button' onClick={onClick}>
|
||||
<FormattedMessage id='status.show_original' defaultMessage='Show original' />
|
||||
</button>
|
||||
|
||||
<div className='translate-button__meta'>
|
||||
<FormattedMessage id='status.translated_from_with' defaultMessage='Translated from {lang} using {provider}' values={{ lang: languageName, provider }} />
|
||||
</div>
|
||||
|
||||
<button className='link-button' onClick={onClick}>
|
||||
<FormattedMessage id='status.show_original' defaultMessage='Show original' />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -138,16 +133,6 @@ class StatusContent extends PureComponent {
|
|||
|
||||
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 }) => {
|
||||
|
@ -243,7 +228,7 @@ class StatusContent extends PureComponent {
|
|||
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 content = statusContent ?? getStatusContent(status);
|
||||
const content = { __html: statusContent ?? getStatusContent(status) };
|
||||
const language = status.getIn(['translation', 'language']) || status.get('language');
|
||||
const classNames = classnames('status__content', {
|
||||
'status__content--with-action': this.props.onClick && this.props.history,
|
||||
|
@ -268,12 +253,7 @@ class StatusContent extends PureComponent {
|
|||
return (
|
||||
<>
|
||||
<div className={classNames} ref={this.setRef} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
||||
<EmojiHTML
|
||||
className='status__content__text status__content__text--visible translate'
|
||||
lang={language}
|
||||
htmlString={content}
|
||||
extraEmojis={status.get('emojis')}
|
||||
/>
|
||||
<div className='status__content__text status__content__text--visible translate' lang={language} dangerouslySetInnerHTML={content} />
|
||||
|
||||
{poll}
|
||||
{translateButton}
|
||||
|
@ -285,12 +265,7 @@ class StatusContent extends PureComponent {
|
|||
} else {
|
||||
return (
|
||||
<div className={classNames} ref={this.setRef} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
||||
<EmojiHTML
|
||||
className='status__content__text status__content__text--visible translate'
|
||||
lang={language}
|
||||
htmlString={content}
|
||||
extraEmojis={status.get('emojis')}
|
||||
/>
|
||||
<div className='status__content__text status__content__text--visible translate' lang={language} dangerouslySetInnerHTML={content} />
|
||||
|
||||
{poll}
|
||||
{translateButton}
|
||||
|
|
|
@ -40,14 +40,6 @@ export default class StatusList extends ImmutablePureComponent {
|
|||
trackScroll: true,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.columnHeaderHeight = this.node?.node
|
||||
? parseFloat(
|
||||
getComputedStyle(this.node.node).getPropertyValue('--column-header-height')
|
||||
) || 0
|
||||
: 0;
|
||||
}
|
||||
|
||||
getFeaturedStatusCount = () => {
|
||||
return this.props.featuredStatusIds ? this.props.featuredStatusIds.size : 0;
|
||||
};
|
||||
|
@ -61,68 +53,34 @@ export default class StatusList extends ImmutablePureComponent {
|
|||
};
|
||||
|
||||
handleMoveUp = (id, featured) => {
|
||||
const index = this.getCurrentStatusIndex(id, featured);
|
||||
this._selectChild(id, index, -1);
|
||||
const elementIndex = this.getCurrentStatusIndex(id, featured) - 1;
|
||||
this._selectChild(elementIndex, true);
|
||||
};
|
||||
|
||||
handleMoveDown = (id, featured) => {
|
||||
const index = this.getCurrentStatusIndex(id, featured);
|
||||
this._selectChild(id, index, 1);
|
||||
const elementIndex = this.getCurrentStatusIndex(id, featured) + 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(() => {
|
||||
const { statusIds, lastId, onLoadMore } = this.props;
|
||||
onLoadMore(lastId || (statusIds.size > 0 ? statusIds.last() : undefined));
|
||||
}, 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 => {
|
||||
this.node = c;
|
||||
};
|
||||
|
|
|
@ -3,15 +3,19 @@ import { useEffect, useMemo } from 'react';
|
|||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
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 type { Status } from 'mastodon/models/status';
|
||||
import type { RootState } from 'mastodon/store';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
import QuoteIcon from '../../images/quote.svg?react';
|
||||
import { fetchStatus } from '../actions/statuses';
|
||||
import { makeGetStatus } from '../selectors';
|
||||
|
||||
|
@ -27,6 +31,7 @@ const QuoteWrapper: React.FC<{
|
|||
'status__quote--error': isError,
|
||||
})}
|
||||
>
|
||||
<Icon id='quote' icon={QuoteIcon} className='status__quote-icon' />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
@ -40,20 +45,27 @@ const NestedQuoteLink: React.FC<{
|
|||
accountId ? state.accounts.get(accountId) : undefined,
|
||||
);
|
||||
|
||||
const quoteAuthorName = account?.acct;
|
||||
const quoteAuthorName = account?.display_name_html;
|
||||
|
||||
if (!quoteAuthorName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const quoteAuthorElement = (
|
||||
<span dangerouslySetInnerHTML={{ __html: quoteAuthorName }} />
|
||||
);
|
||||
const quoteUrl = `/@${account.get('acct')}/${status.get('id') as string}`;
|
||||
|
||||
return (
|
||||
<div className='status__quote-author-button'>
|
||||
<Link to={quoteUrl} className='status__quote-author-button'>
|
||||
<FormattedMessage
|
||||
id='status.quote_post_author'
|
||||
defaultMessage='Quoted a post by @{name}'
|
||||
values={{ name: quoteAuthorName }}
|
||||
defaultMessage='Post by {name}'
|
||||
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'
|
||||
/>
|
||||
);
|
||||
} 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') {
|
||||
quoteError = (
|
||||
<>
|
||||
<FormattedMessage
|
||||
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 (
|
||||
!status ||
|
||||
!quotedStatusId ||
|
||||
quoteState === 'deleted' ||
|
||||
quoteState === 'rejected' ||
|
||||
quoteState === 'revoked' ||
|
||||
quoteState === 'unauthorized'
|
||||
) {
|
||||
} else if (quoteState === 'rejected' || quoteState === 'revoked') {
|
||||
quoteError = (
|
||||
<FormattedMessage
|
||||
id='status.quote_error.not_available'
|
||||
defaultMessage='Post unavailable'
|
||||
id='status.quote_error.rejected'
|
||||
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
|
||||
id={quotedStatusId}
|
||||
contextType={contextType}
|
||||
avatarSize={32}
|
||||
avatarSize={40}
|
||||
>
|
||||
{canRenderChildQuote && (
|
||||
<QuotedStatus
|
||||
|
|
|
@ -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) {
|
||||
dispatch((_, getState) => {
|
||||
let state = getState();
|
||||
|
|
|
@ -898,7 +898,8 @@ export const AccountHeader: React.FC<{
|
|||
)}
|
||||
|
||||
<AccountBio
|
||||
accountId={accountId}
|
||||
note={account.note_emojified}
|
||||
dropdownAccountId={accountId}
|
||||
className='account__header__content'
|
||||
/>
|
||||
|
||||
|
|
|
@ -92,29 +92,10 @@ class ComposeForm extends ImmutablePureComponent {
|
|||
this.props.onChange(e.target.value);
|
||||
};
|
||||
|
||||
blurOnEscape = (e) => {
|
||||
if (['esc', 'escape'].includes(e.key.toLowerCase())) {
|
||||
e.target.blur();
|
||||
}
|
||||
}
|
||||
|
||||
handleKeyDownPost = (e) => {
|
||||
if (e.key.toLowerCase() === 'enter' && (e.ctrlKey || e.metaKey)) {
|
||||
handleKeyDown = (e) => {
|
||||
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
|
||||
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 = () => {
|
||||
|
@ -267,7 +248,7 @@ class ComposeForm extends ImmutablePureComponent {
|
|||
value={this.props.spoilerText}
|
||||
disabled={isSubmitting}
|
||||
onChange={this.handleChangeSpoilerText}
|
||||
onKeyDown={this.handleKeyDownSpoiler}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
ref={this.setSpoilerText}
|
||||
suggestions={this.props.suggestions}
|
||||
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
||||
|
@ -292,7 +273,7 @@ class ComposeForm extends ImmutablePureComponent {
|
|||
onChange={this.handleChange}
|
||||
suggestions={this.props.suggestions}
|
||||
onFocus={this.handleFocus}
|
||||
onKeyDown={this.handleKeyDownPost}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
||||
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
|
||||
onSuggestionSelected={this.onSuggestionSelected}
|
||||
|
|
|
@ -10,13 +10,15 @@ import { createSelector } from '@reduxjs/toolkit';
|
|||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
|
||||
import { HotKeys } from 'react-hotkeys';
|
||||
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
|
||||
import { replyCompose } from 'mastodon/actions/compose';
|
||||
import { markConversationRead, deleteConversation } from 'mastodon/actions/conversations';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { muteStatus, unmuteStatus, toggleStatusSpoilers } from 'mastodon/actions/statuses';
|
||||
import { Hotkeys } from 'mastodon/components/hotkeys';
|
||||
import AttachmentList from 'mastodon/components/attachment_list';
|
||||
import AvatarComposite from 'mastodon/components/avatar_composite';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
|
@ -167,7 +169,7 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
|
|||
};
|
||||
|
||||
return (
|
||||
<Hotkeys handlers={handlers}>
|
||||
<HotKeys handlers={handlers}>
|
||||
<div className={classNames('conversation focusable muted', { unread })} tabIndex={0}>
|
||||
<div className='conversation__avatar' onClick={handleClick} role='presentation'>
|
||||
<AvatarComposite accounts={accounts} size={48} />
|
||||
|
@ -217,7 +219,7 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Hotkeys>
|
||||
</HotKeys>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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
|
||||
];
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
}
|
|
@ -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 }}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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'),
|
||||
};
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
},
|
||||
);
|
||||
});
|
|
@ -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('-');
|
||||
}
|
|
@ -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:!!',
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
};
|
|
@ -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;
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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}';
|
|
@ -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}`);
|
||||
}
|
|
@ -143,17 +143,6 @@ class ColumnSettings extends PureComponent {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section role='group' aria-labelledby='notifications-quote'>
|
||||
<h3 id='notifications-quote'><FormattedMessage id='notifications.column_settings.quote' defaultMessage='Quotes:' /></h3>
|
||||
|
||||
<div className='column-settings__row'>
|
||||
<SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'quote']} onChange={onChange} label={alertStr} />
|
||||
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'quote']} onChange={this.onPushChange} label={pushStr} />}
|
||||
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'quote']} onChange={onChange} label={showStr} />
|
||||
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'quote']} onChange={onChange} label={soundStr} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section role='group' aria-labelledby='notifications-poll'>
|
||||
<h3 id='notifications-poll'><FormattedMessage id='notifications.column_settings.poll' defaultMessage='Poll results:' /></h3>
|
||||
|
||||
|
|
|
@ -8,9 +8,10 @@ import { Link, withRouter } from 'react-router-dom';
|
|||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
import { HotKeys } from 'react-hotkeys';
|
||||
|
||||
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
|
||||
import FlagIcon from '@/material-icons/400-24px/flag-fill.svg?react';
|
||||
import FormatQuoteIcon from '@/material-icons/400-24px/format_quote.svg?react';
|
||||
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
|
||||
import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react';
|
||||
import PersonIcon from '@/material-icons/400-24px/person-fill.svg?react';
|
||||
|
@ -19,7 +20,6 @@ import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
|
|||
import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
|
||||
import { Account } from 'mastodon/components/account';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { Hotkeys } from 'mastodon/components/hotkeys';
|
||||
import { StatusQuoteManager } from 'mastodon/components/status_quoted';
|
||||
import { me } from 'mastodon/initial_state';
|
||||
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
|
||||
|
@ -42,7 +42,6 @@ const messages = defineMessages({
|
|||
adminReport: { id: 'notification.admin.report', defaultMessage: '{name} reported {target}' },
|
||||
relationshipsSevered: { id: 'notification.relationships_severance_event', defaultMessage: 'Lost connections with {name}' },
|
||||
moderationWarning: { id: 'notification.moderation_warning', defaultMessage: 'You have received a moderation warning' },
|
||||
quote: { id: 'notification.label.quote', defaultMessage: '{name} quoted your post'}
|
||||
});
|
||||
|
||||
const notificationForScreenReader = (intl, message, timestamp) => {
|
||||
|
@ -138,7 +137,7 @@ class Notification extends ImmutablePureComponent {
|
|||
const { intl, unread } = this.props;
|
||||
|
||||
return (
|
||||
<Hotkeys handlers={this.getHandlers()}>
|
||||
<HotKeys handlers={this.getHandlers()}>
|
||||
<div className={classNames('notification notification-follow focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.follow, { name: account.get('acct') }), notification.get('created_at'))}>
|
||||
<div className='notification__message'>
|
||||
<Icon id='user-plus' icon={PersonAddIcon} />
|
||||
|
@ -150,7 +149,7 @@ class Notification extends ImmutablePureComponent {
|
|||
|
||||
<Account id={account.get('id')} hidden={this.props.hidden} />
|
||||
</div>
|
||||
</Hotkeys>
|
||||
</HotKeys>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -158,7 +157,7 @@ class Notification extends ImmutablePureComponent {
|
|||
const { intl, unread } = this.props;
|
||||
|
||||
return (
|
||||
<Hotkeys handlers={this.getHandlers()}>
|
||||
<HotKeys handlers={this.getHandlers()}>
|
||||
<div className={classNames('notification notification-follow-request focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage({ id: 'notification.follow_request', defaultMessage: '{name} has requested to follow you' }, { name: account.get('acct') }), notification.get('created_at'))}>
|
||||
<div className='notification__message'>
|
||||
<Icon id='user' icon={PersonIcon} />
|
||||
|
@ -170,7 +169,7 @@ class Notification extends ImmutablePureComponent {
|
|||
|
||||
<FollowRequestContainer id={account.get('id')} hidden={this.props.hidden} />
|
||||
</div>
|
||||
</Hotkeys>
|
||||
</HotKeys>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -196,7 +195,7 @@ class Notification extends ImmutablePureComponent {
|
|||
const { intl, unread } = this.props;
|
||||
|
||||
return (
|
||||
<Hotkeys handlers={this.getHandlers()}>
|
||||
<HotKeys handlers={this.getHandlers()}>
|
||||
<div className={classNames('notification notification-favourite focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.favourite, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
|
||||
<div className='notification__message'>
|
||||
<Icon id='star' icon={StarIcon} className='star-icon' />
|
||||
|
@ -218,7 +217,7 @@ class Notification extends ImmutablePureComponent {
|
|||
cacheMediaWidth={this.props.cacheMediaWidth}
|
||||
/>
|
||||
</div>
|
||||
</Hotkeys>
|
||||
</HotKeys>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -226,7 +225,7 @@ class Notification extends ImmutablePureComponent {
|
|||
const { intl, unread } = this.props;
|
||||
|
||||
return (
|
||||
<Hotkeys handlers={this.getHandlers()}>
|
||||
<HotKeys handlers={this.getHandlers()}>
|
||||
<div className={classNames('notification notification-reblog focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.reblog, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
|
||||
<div className='notification__message'>
|
||||
<Icon id='retweet' icon={RepeatIcon} />
|
||||
|
@ -248,37 +247,7 @@ class Notification extends ImmutablePureComponent {
|
|||
cacheMediaWidth={this.props.cacheMediaWidth}
|
||||
/>
|
||||
</div>
|
||||
</Hotkeys>
|
||||
);
|
||||
}
|
||||
|
||||
renderQuote (notification, link) {
|
||||
const { intl, unread } = this.props;
|
||||
|
||||
return (
|
||||
<Hotkeys handlers={this.getHandlers()}>
|
||||
<div className={classNames('notification notification-quote focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.quote, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
|
||||
<div className='notification__message'>
|
||||
<Icon id='quote' icon={FormatQuoteIcon} />
|
||||
|
||||
<span title={notification.get('created_at')}>
|
||||
<FormattedMessage id='notification.label.quote' defaultMessage='{name} quoted your post' values={{ name: link }} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<StatusQuoteManager
|
||||
id={notification.get('status')}
|
||||
account={notification.get('account')}
|
||||
muted
|
||||
withDismiss
|
||||
hidden={this.props.hidden}
|
||||
getScrollPosition={this.props.getScrollPosition}
|
||||
updateScrollBottom={this.props.updateScrollBottom}
|
||||
cachedMediaWidth={this.props.cachedMediaWidth}
|
||||
cacheMediaWidth={this.props.cacheMediaWidth}
|
||||
/>
|
||||
</div>
|
||||
</Hotkeys>
|
||||
</HotKeys>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -290,7 +259,7 @@ class Notification extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
return (
|
||||
<Hotkeys handlers={this.getHandlers()}>
|
||||
<HotKeys handlers={this.getHandlers()}>
|
||||
<div className={classNames('notification notification-status focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.status, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
|
||||
<div className='notification__message'>
|
||||
<Icon id='home' icon={HomeIcon} />
|
||||
|
@ -313,7 +282,7 @@ class Notification extends ImmutablePureComponent {
|
|||
cacheMediaWidth={this.props.cacheMediaWidth}
|
||||
/>
|
||||
</div>
|
||||
</Hotkeys>
|
||||
</HotKeys>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -325,7 +294,7 @@ class Notification extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
return (
|
||||
<Hotkeys handlers={this.getHandlers()}>
|
||||
<HotKeys handlers={this.getHandlers()}>
|
||||
<div className={classNames('notification notification-update focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.update, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
|
||||
<div className='notification__message'>
|
||||
<Icon id='pencil' icon={EditIcon} />
|
||||
|
@ -348,7 +317,7 @@ class Notification extends ImmutablePureComponent {
|
|||
cacheMediaWidth={this.props.cacheMediaWidth}
|
||||
/>
|
||||
</div>
|
||||
</Hotkeys>
|
||||
</HotKeys>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -362,7 +331,7 @@ class Notification extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
return (
|
||||
<Hotkeys handlers={this.getHandlers()}>
|
||||
<HotKeys handlers={this.getHandlers()}>
|
||||
<div className={classNames('notification notification-poll focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, message, notification.get('created_at'))}>
|
||||
<div className='notification__message'>
|
||||
<Icon id='tasks' icon={InsertChartIcon} />
|
||||
|
@ -389,7 +358,7 @@ class Notification extends ImmutablePureComponent {
|
|||
cacheMediaWidth={this.props.cacheMediaWidth}
|
||||
/>
|
||||
</div>
|
||||
</Hotkeys>
|
||||
</HotKeys>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -402,7 +371,7 @@ class Notification extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
return (
|
||||
<Hotkeys handlers={this.getHandlers()}>
|
||||
<HotKeys handlers={this.getHandlers()}>
|
||||
<div className={classNames('notification notification-severed-relationships focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.relationshipsSevered, { name: notification.getIn(['event', 'target_name']) }), notification.get('created_at'))}>
|
||||
<RelationshipsSeveranceEvent
|
||||
type={event.get('type')}
|
||||
|
@ -412,7 +381,7 @@ class Notification extends ImmutablePureComponent {
|
|||
hidden={hidden}
|
||||
/>
|
||||
</div>
|
||||
</Hotkeys>
|
||||
</HotKeys>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -425,7 +394,7 @@ class Notification extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
return (
|
||||
<Hotkeys handlers={this.getHandlers()}>
|
||||
<HotKeys handlers={this.getHandlers()}>
|
||||
<div className={classNames('notification notification-moderation-warning focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.moderationWarning), notification.get('created_at'))}>
|
||||
<ModerationWarning
|
||||
action={warning.get('action')}
|
||||
|
@ -433,7 +402,7 @@ class Notification extends ImmutablePureComponent {
|
|||
hidden={hidden}
|
||||
/>
|
||||
</div>
|
||||
</Hotkeys>
|
||||
</HotKeys>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -441,7 +410,7 @@ class Notification extends ImmutablePureComponent {
|
|||
const { intl, unread } = this.props;
|
||||
|
||||
return (
|
||||
<Hotkeys handlers={this.getHandlers()}>
|
||||
<HotKeys handlers={this.getHandlers()}>
|
||||
<div className={classNames('notification notification-admin-sign-up focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.adminSignUp, { name: account.get('acct') }), notification.get('created_at'))}>
|
||||
<div className='notification__message'>
|
||||
<Icon id='user-plus' icon={PersonAddIcon} />
|
||||
|
@ -453,7 +422,7 @@ class Notification extends ImmutablePureComponent {
|
|||
|
||||
<Account id={account.get('id')} hidden={this.props.hidden} />
|
||||
</div>
|
||||
</Hotkeys>
|
||||
</HotKeys>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -469,7 +438,7 @@ class Notification extends ImmutablePureComponent {
|
|||
const targetLink = <bdi><Link className='notification__display-name' title={targetAccount.get('acct')} data-hover-card-account={targetAccount.get('id')} to={`/@${targetAccount.get('acct')}`} dangerouslySetInnerHTML={targetDisplayNameHtml} /></bdi>;
|
||||
|
||||
return (
|
||||
<Hotkeys handlers={this.getHandlers()}>
|
||||
<HotKeys handlers={this.getHandlers()}>
|
||||
<div className={classNames('notification notification-admin-report focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.adminReport, { name: account.get('acct'), target: notification.getIn(['report', 'target_account', 'acct']) }), notification.get('created_at'))}>
|
||||
<div className='notification__message'>
|
||||
<Icon id='flag' icon={FlagIcon} />
|
||||
|
@ -481,7 +450,7 @@ class Notification extends ImmutablePureComponent {
|
|||
|
||||
<Report account={account} report={notification.get('report')} hidden={this.props.hidden} />
|
||||
</div>
|
||||
</Hotkeys>
|
||||
</HotKeys>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -498,8 +467,6 @@ class Notification extends ImmutablePureComponent {
|
|||
return this.renderFollowRequest(notification, account, link);
|
||||
case 'mention':
|
||||
return this.renderMention(notification);
|
||||
case 'quote':
|
||||
return this.renderQuote(notification);
|
||||
case 'favourite':
|
||||
return this.renderFavourite(notification, link);
|
||||
case 'reblog':
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user