mirror of
https://github.com/mastodon/mastodon.git
synced 2025-08-09 21:22:22 +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
|
.gitattributes
|
||||||
.gitignore
|
.gitignore
|
||||||
.github
|
.github
|
||||||
.vscode
|
|
||||||
public/system
|
public/system
|
||||||
public/assets
|
public/assets
|
||||||
public/packs
|
public/packs
|
||||||
|
@ -21,7 +20,6 @@ postgres14
|
||||||
redis
|
redis
|
||||||
elasticsearch
|
elasticsearch
|
||||||
chart
|
chart
|
||||||
storybook-static
|
|
||||||
.yarn/
|
.yarn/
|
||||||
!.yarn/patches
|
!.yarn/patches
|
||||||
!.yarn/plugins
|
!.yarn/plugins
|
||||||
|
|
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)
|
name: Bug Report (Web Interface)
|
||||||
description: There is a problem using Mastodon's web interface.
|
description: There is a problem using Mastodon's web interface.
|
||||||
labels: ['area/web interface']
|
labels: ['status/to triage', 'area/web interface']
|
||||||
type: Bug
|
type: Bug
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
name: Bug Report (server / API)
|
name: Bug Report (server / API)
|
||||||
description: |
|
description: |
|
||||||
There is a problem with the HTTP server, REST API, ActivityPub interaction, etc.
|
There is a problem with the HTTP server, REST API, ActivityPub interaction, etc.
|
||||||
|
labels: ['status/to triage']
|
||||||
type: 'Bug'
|
type: 'Bug'
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
|
|
1
.github/renovate.json5
vendored
1
.github/renovate.json5
vendored
|
@ -23,6 +23,7 @@
|
||||||
matchManagers: ['npm'],
|
matchManagers: ['npm'],
|
||||||
matchPackageNames: [
|
matchPackageNames: [
|
||||||
'tesseract.js', // Requires code changes
|
'tesseract.js', // Requires code changes
|
||||||
|
'react-hotkeys', // Requires code changes
|
||||||
|
|
||||||
// react-router: Requires manual upgrade
|
// react-router: Requires manual upgrade
|
||||||
'history',
|
'history',
|
||||||
|
|
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
|
# Only tag with latest when ran against the latest stable branch
|
||||||
# This needs to be updated after each minor version release
|
# This needs to be updated after each minor version release
|
||||||
flavor: |
|
flavor: |
|
||||||
latest=${{ startsWith(github.ref, 'refs/tags/v4.3.') }}
|
latest=${{ startsWith(github.ref, 'refs/tags/v4.4.') }}
|
||||||
tags: |
|
tags: |
|
||||||
type=pep440,pattern={{raw}}
|
type=pep440,pattern={{raw}}
|
||||||
type=pep440,pattern=v{{major}}.{{minor}}
|
type=pep440,pattern=v{{major}}.{{minor}}
|
||||||
|
@ -39,7 +39,7 @@ jobs:
|
||||||
# Only tag with latest when ran against the latest stable branch
|
# Only tag with latest when ran against the latest stable branch
|
||||||
# This needs to be updated after each minor version release
|
# This needs to be updated after each minor version release
|
||||||
flavor: |
|
flavor: |
|
||||||
latest=${{ startsWith(github.ref, 'refs/tags/v4.3.') }}
|
latest=${{ startsWith(github.ref, 'refs/tags/v4.4.') }}
|
||||||
tags: |
|
tags: |
|
||||||
type=pep440,pattern={{raw}}
|
type=pep440,pattern={{raw}}
|
||||||
type=pep440,pattern=v{{major}}.{{minor}}
|
type=pep440,pattern=v{{major}}.{{minor}}
|
||||||
|
|
|
@ -50,7 +50,7 @@ jobs:
|
||||||
|
|
||||||
# Create or update the pull request
|
# Create or update the pull request
|
||||||
- name: Create Pull Request
|
- name: Create Pull Request
|
||||||
uses: peter-evans/create-pull-request@v7.0.8
|
uses: peter-evans/create-pull-request@v7.0.6
|
||||||
with:
|
with:
|
||||||
commit-message: 'New Crowdin translations'
|
commit-message: 'New Crowdin translations'
|
||||||
title: 'New Crowdin Translations for ${{ github.base_ref || github.ref_name }} (automated)'
|
title: 'New Crowdin Translations for ${{ github.base_ref || github.ref_name }} (automated)'
|
||||||
|
|
|
@ -1,11 +1,15 @@
|
||||||
# This configuration was generated by
|
# This configuration was generated by
|
||||||
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp`
|
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp`
|
||||||
# using RuboCop version 1.79.2.
|
# using RuboCop version 1.77.0.
|
||||||
# The point is for the user to remove these configuration records
|
# The point is for the user to remove these configuration records
|
||||||
# one by one as the offenses are removed from the code base.
|
# one by one as the offenses are removed from the code base.
|
||||||
# Note that changes in the inspected code, or installation of new
|
# Note that changes in the inspected code, or installation of new
|
||||||
# versions of RuboCop, may require this file to be generated again.
|
# versions of RuboCop, may require this file to be generated again.
|
||||||
|
|
||||||
|
Lint/NonLocalExitFromIterator:
|
||||||
|
Exclude:
|
||||||
|
- 'app/helpers/json_ld_helper.rb'
|
||||||
|
|
||||||
# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes.
|
# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes.
|
||||||
Metrics/AbcSize:
|
Metrics/AbcSize:
|
||||||
Max: 82
|
Max: 82
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
3.4.5
|
3.4.4
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
import { resolve } from 'node:path';
|
|
||||||
|
|
||||||
import type { StorybookConfig } from '@storybook/react-vite';
|
import type { StorybookConfig } from '@storybook/react-vite';
|
||||||
|
|
||||||
const config: StorybookConfig = {
|
const config: StorybookConfig = {
|
||||||
|
@ -28,12 +26,6 @@ const config: StorybookConfig = {
|
||||||
'oops.png',
|
'oops.png',
|
||||||
].map((path) => ({ from: `../public/${path}`, to: `/${path}` })),
|
].map((path) => ({ from: `../public/${path}`, to: `/${path}` })),
|
||||||
],
|
],
|
||||||
viteFinal(config) {
|
|
||||||
// For an unknown reason, Storybook does not use the root
|
|
||||||
// from the Vite config so we need to set it manually.
|
|
||||||
config.root = resolve(__dirname, '../app/javascript');
|
|
||||||
return config;
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|
|
@ -13,7 +13,7 @@ ARG BASE_REGISTRY="docker.io"
|
||||||
|
|
||||||
# Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.4.x"]
|
# Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.4.x"]
|
||||||
# renovate: datasource=docker depName=docker.io/ruby
|
# renovate: datasource=docker depName=docker.io/ruby
|
||||||
ARG RUBY_VERSION="3.4.5"
|
ARG RUBY_VERSION="3.4.4"
|
||||||
# # Node.js version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"]
|
# # Node.js version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"]
|
||||||
# renovate: datasource=node-version depName=node
|
# renovate: datasource=node-version depName=node
|
||||||
ARG NODE_MAJOR_VERSION="22"
|
ARG NODE_MAJOR_VERSION="22"
|
||||||
|
@ -186,7 +186,7 @@ FROM build AS libvips
|
||||||
|
|
||||||
# libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"]
|
# libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"]
|
||||||
# renovate: datasource=github-releases depName=libvips packageName=libvips/libvips
|
# renovate: datasource=github-releases depName=libvips packageName=libvips/libvips
|
||||||
ARG VIPS_VERSION=8.17.1
|
ARG VIPS_VERSION=8.17.0
|
||||||
# libvips download URL, change with [--build-arg VIPS_URL="https://github.com/libvips/libvips/releases/download"]
|
# libvips download URL, change with [--build-arg VIPS_URL="https://github.com/libvips/libvips/releases/download"]
|
||||||
ARG VIPS_URL=https://github.com/libvips/libvips/releases/download
|
ARG VIPS_URL=https://github.com/libvips/libvips/releases/download
|
||||||
|
|
||||||
|
|
4
Gemfile
4
Gemfile
|
@ -62,7 +62,7 @@ gem 'inline_svg'
|
||||||
gem 'irb', '~> 1.8'
|
gem 'irb', '~> 1.8'
|
||||||
gem 'kaminari', '~> 1.2'
|
gem 'kaminari', '~> 1.2'
|
||||||
gem 'link_header', '~> 0.0'
|
gem 'link_header', '~> 0.0'
|
||||||
gem 'linzer', '~> 0.7.7'
|
gem 'linzer', '~> 0.7.2'
|
||||||
gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock'
|
gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock'
|
||||||
gem 'mime-types', '~> 3.7.0', require: 'mime/types/columnar'
|
gem 'mime-types', '~> 3.7.0', require: 'mime/types/columnar'
|
||||||
gem 'mutex_m'
|
gem 'mutex_m'
|
||||||
|
@ -84,7 +84,7 @@ gem 'sanitize', '~> 7.0'
|
||||||
gem 'scenic', '~> 1.7'
|
gem 'scenic', '~> 1.7'
|
||||||
gem 'sidekiq', '< 8'
|
gem 'sidekiq', '< 8'
|
||||||
gem 'sidekiq-bulk', '~> 0.2.0'
|
gem 'sidekiq-bulk', '~> 0.2.0'
|
||||||
gem 'sidekiq-scheduler', '~> 6.0'
|
gem 'sidekiq-scheduler', '~> 5.0'
|
||||||
gem 'sidekiq-unique-jobs', '> 8'
|
gem 'sidekiq-unique-jobs', '> 8'
|
||||||
gem 'simple_form', '~> 5.2'
|
gem 'simple_form', '~> 5.2'
|
||||||
gem 'simple-navigation', '~> 4.4'
|
gem 'simple-navigation', '~> 4.4'
|
||||||
|
|
135
Gemfile.lock
135
Gemfile.lock
|
@ -90,13 +90,13 @@ GEM
|
||||||
public_suffix (>= 2.0.2, < 7.0)
|
public_suffix (>= 2.0.2, < 7.0)
|
||||||
aes_key_wrap (1.1.0)
|
aes_key_wrap (1.1.0)
|
||||||
android_key_attestation (0.3.0)
|
android_key_attestation (0.3.0)
|
||||||
annotaterb (4.18.0)
|
annotaterb (4.16.0)
|
||||||
activerecord (>= 6.0.0)
|
activerecord (>= 6.0.0)
|
||||||
activesupport (>= 6.0.0)
|
activesupport (>= 6.0.0)
|
||||||
ast (2.4.3)
|
ast (2.4.3)
|
||||||
attr_required (1.0.2)
|
attr_required (1.0.2)
|
||||||
aws-eventstream (1.4.0)
|
aws-eventstream (1.3.2)
|
||||||
aws-partitions (1.1135.0)
|
aws-partitions (1.1103.0)
|
||||||
aws-sdk-core (3.215.1)
|
aws-sdk-core (3.215.1)
|
||||||
aws-eventstream (~> 1, >= 1.3.0)
|
aws-eventstream (~> 1, >= 1.3.0)
|
||||||
aws-partitions (~> 1, >= 1.992.0)
|
aws-partitions (~> 1, >= 1.992.0)
|
||||||
|
@ -109,9 +109,9 @@ GEM
|
||||||
aws-sdk-core (~> 3, >= 3.210.0)
|
aws-sdk-core (~> 3, >= 3.210.0)
|
||||||
aws-sdk-kms (~> 1)
|
aws-sdk-kms (~> 1)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sigv4 (1.12.1)
|
aws-sigv4 (1.11.0)
|
||||||
aws-eventstream (~> 1, >= 1.0.2)
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
azure-blob (0.5.9.1)
|
azure-blob (0.5.8)
|
||||||
rexml
|
rexml
|
||||||
base64 (0.3.0)
|
base64 (0.3.0)
|
||||||
bcp47_spec (0.2.1)
|
bcp47_spec (0.2.1)
|
||||||
|
@ -144,7 +144,7 @@ GEM
|
||||||
rack-test (>= 0.6.3)
|
rack-test (>= 0.6.3)
|
||||||
regexp_parser (>= 1.5, < 3.0)
|
regexp_parser (>= 1.5, < 3.0)
|
||||||
xpath (~> 3.2)
|
xpath (~> 3.2)
|
||||||
capybara-playwright-driver (0.5.7)
|
capybara-playwright-driver (0.5.6)
|
||||||
addressable
|
addressable
|
||||||
capybara
|
capybara
|
||||||
playwright-ruby-client (>= 1.16.0)
|
playwright-ruby-client (>= 1.16.0)
|
||||||
|
@ -175,9 +175,9 @@ GEM
|
||||||
css_parser (1.21.1)
|
css_parser (1.21.1)
|
||||||
addressable
|
addressable
|
||||||
csv (3.3.5)
|
csv (3.3.5)
|
||||||
database_cleaner-active_record (2.2.2)
|
database_cleaner-active_record (2.2.1)
|
||||||
activerecord (>= 5.a)
|
activerecord (>= 5.a)
|
||||||
database_cleaner-core (~> 2.0)
|
database_cleaner-core (~> 2.0.0)
|
||||||
database_cleaner-core (2.0.1)
|
database_cleaner-core (2.0.1)
|
||||||
date (3.4.1)
|
date (3.4.1)
|
||||||
debug (1.11.0)
|
debug (1.11.0)
|
||||||
|
@ -224,16 +224,16 @@ GEM
|
||||||
mail (~> 2.7)
|
mail (~> 2.7)
|
||||||
email_validator (2.2.4)
|
email_validator (2.2.4)
|
||||||
activemodel
|
activemodel
|
||||||
erb (5.0.2)
|
erb (5.0.1)
|
||||||
erubi (1.13.1)
|
erubi (1.13.1)
|
||||||
et-orbi (1.2.11)
|
et-orbi (1.2.11)
|
||||||
tzinfo
|
tzinfo
|
||||||
excon (1.2.8)
|
excon (1.2.5)
|
||||||
logger
|
logger
|
||||||
fabrication (3.0.0)
|
fabrication (3.0.0)
|
||||||
faker (3.5.2)
|
faker (3.5.1)
|
||||||
i18n (>= 1.8.11, < 2)
|
i18n (>= 1.8.11, < 2)
|
||||||
faraday (2.13.4)
|
faraday (2.13.1)
|
||||||
faraday-net_http (>= 2.0, < 3.5)
|
faraday-net_http (>= 2.0, < 3.5)
|
||||||
json
|
json
|
||||||
logger
|
logger
|
||||||
|
@ -241,7 +241,7 @@ GEM
|
||||||
faraday (>= 1, < 3)
|
faraday (>= 1, < 3)
|
||||||
faraday-httpclient (2.0.2)
|
faraday-httpclient (2.0.2)
|
||||||
httpclient (>= 2.2)
|
httpclient (>= 2.2)
|
||||||
faraday-net_http (3.4.1)
|
faraday-net_http (3.4.0)
|
||||||
net-http (>= 0.5.0)
|
net-http (>= 0.5.0)
|
||||||
fast_blank (1.0.1)
|
fast_blank (1.0.1)
|
||||||
fastimage (2.4.0)
|
fastimage (2.4.0)
|
||||||
|
@ -266,14 +266,14 @@ GEM
|
||||||
fog-openstack (1.1.5)
|
fog-openstack (1.1.5)
|
||||||
fog-core (~> 2.1)
|
fog-core (~> 2.1)
|
||||||
fog-json (>= 1.0)
|
fog-json (>= 1.0)
|
||||||
formatador (1.1.1)
|
formatador (1.1.0)
|
||||||
forwardable (1.3.3)
|
forwardable (1.3.3)
|
||||||
fugit (1.11.1)
|
fugit (1.11.1)
|
||||||
et-orbi (~> 1, >= 1.2.11)
|
et-orbi (~> 1, >= 1.2.11)
|
||||||
raabro (~> 1.4)
|
raabro (~> 1.4)
|
||||||
globalid (1.2.1)
|
globalid (1.2.1)
|
||||||
activesupport (>= 6.1)
|
activesupport (>= 6.1)
|
||||||
google-protobuf (4.31.1)
|
google-protobuf (4.31.0)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
rake (>= 13)
|
rake (>= 13)
|
||||||
googleapis-common-protos-types (1.20.0)
|
googleapis-common-protos-types (1.20.0)
|
||||||
|
@ -287,21 +287,21 @@ GEM
|
||||||
activesupport (>= 5.1)
|
activesupport (>= 5.1)
|
||||||
haml (>= 4.0.6)
|
haml (>= 4.0.6)
|
||||||
railties (>= 5.1)
|
railties (>= 5.1)
|
||||||
haml_lint (0.66.0)
|
haml_lint (0.64.0)
|
||||||
haml (>= 5.0)
|
haml (>= 5.0)
|
||||||
parallel (~> 1.10)
|
parallel (~> 1.10)
|
||||||
rainbow
|
rainbow
|
||||||
rubocop (>= 1.0)
|
rubocop (>= 1.0)
|
||||||
sysexits (~> 1.1)
|
sysexits (~> 1.1)
|
||||||
hashdiff (1.2.0)
|
hashdiff (1.1.2)
|
||||||
hashie (5.0.0)
|
hashie (5.0.0)
|
||||||
hcaptcha (7.1.0)
|
hcaptcha (7.1.0)
|
||||||
json
|
json
|
||||||
highline (3.1.2)
|
highline (3.1.2)
|
||||||
reline
|
reline
|
||||||
hiredis (0.6.3)
|
hiredis (0.6.3)
|
||||||
hiredis-client (0.25.1)
|
hiredis-client (0.24.0)
|
||||||
redis-client (= 0.25.1)
|
redis-client (= 0.24.0)
|
||||||
hkdf (0.3.0)
|
hkdf (0.3.0)
|
||||||
htmlentities (4.3.4)
|
htmlentities (4.3.4)
|
||||||
http (5.3.1)
|
http (5.3.1)
|
||||||
|
@ -315,7 +315,7 @@ GEM
|
||||||
http_accept_language (2.1.1)
|
http_accept_language (2.1.1)
|
||||||
httpclient (2.9.0)
|
httpclient (2.9.0)
|
||||||
mutex_m
|
mutex_m
|
||||||
httplog (1.7.3)
|
httplog (1.7.0)
|
||||||
rack (>= 2.0)
|
rack (>= 2.0)
|
||||||
rainbow (>= 2.0.0)
|
rainbow (>= 2.0.0)
|
||||||
i18n (1.14.7)
|
i18n (1.14.7)
|
||||||
|
@ -335,7 +335,7 @@ GEM
|
||||||
inline_svg (1.10.0)
|
inline_svg (1.10.0)
|
||||||
activesupport (>= 3.0)
|
activesupport (>= 3.0)
|
||||||
nokogiri (>= 1.6)
|
nokogiri (>= 1.6)
|
||||||
io-console (0.8.1)
|
io-console (0.8.0)
|
||||||
irb (1.15.2)
|
irb (1.15.2)
|
||||||
pp (>= 0.6.0)
|
pp (>= 0.6.0)
|
||||||
rdoc (>= 4.0.0)
|
rdoc (>= 4.0.0)
|
||||||
|
@ -345,7 +345,7 @@ GEM
|
||||||
azure-blob (~> 0.5.2)
|
azure-blob (~> 0.5.2)
|
||||||
hashie (~> 5.0)
|
hashie (~> 5.0)
|
||||||
jmespath (1.6.2)
|
jmespath (1.6.2)
|
||||||
json (2.13.2)
|
json (2.12.2)
|
||||||
json-canonicalization (1.0.0)
|
json-canonicalization (1.0.0)
|
||||||
json-jwt (1.16.7)
|
json-jwt (1.16.7)
|
||||||
activesupport (>= 4.2)
|
activesupport (>= 4.2)
|
||||||
|
@ -362,14 +362,14 @@ GEM
|
||||||
rack (>= 2.2, < 4)
|
rack (>= 2.2, < 4)
|
||||||
rdf (~> 3.3)
|
rdf (~> 3.3)
|
||||||
rexml (~> 3.2)
|
rexml (~> 3.2)
|
||||||
json-ld-preloaded (3.3.2)
|
json-ld-preloaded (3.3.1)
|
||||||
json-ld (~> 3.3)
|
json-ld (~> 3.3)
|
||||||
rdf (~> 3.3)
|
rdf (~> 3.3)
|
||||||
json-schema (5.2.1)
|
json-schema (5.1.1)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
bigdecimal (~> 3.1)
|
bigdecimal (~> 3.1)
|
||||||
jsonapi-renderer (0.2.2)
|
jsonapi-renderer (0.2.2)
|
||||||
jwt (2.10.2)
|
jwt (2.10.1)
|
||||||
base64
|
base64
|
||||||
kaminari (1.2.2)
|
kaminari (1.2.2)
|
||||||
activesupport (>= 4.1.0)
|
activesupport (>= 4.1.0)
|
||||||
|
@ -403,7 +403,7 @@ GEM
|
||||||
rexml
|
rexml
|
||||||
link_header (0.0.8)
|
link_header (0.0.8)
|
||||||
lint_roller (1.1.0)
|
lint_roller (1.1.0)
|
||||||
linzer (0.7.7)
|
linzer (0.7.3)
|
||||||
cgi (~> 0.4.2)
|
cgi (~> 0.4.2)
|
||||||
forwardable (~> 1.3, >= 1.3.3)
|
forwardable (~> 1.3, >= 1.3.3)
|
||||||
logger (~> 1.7, >= 1.7.0)
|
logger (~> 1.7, >= 1.7.0)
|
||||||
|
@ -433,21 +433,21 @@ GEM
|
||||||
marcel (1.0.4)
|
marcel (1.0.4)
|
||||||
mario-redis-lock (1.2.1)
|
mario-redis-lock (1.2.1)
|
||||||
redis (>= 3.0.5)
|
redis (>= 3.0.5)
|
||||||
matrix (0.4.3)
|
matrix (0.4.2)
|
||||||
memory_profiler (1.1.0)
|
memory_profiler (1.1.0)
|
||||||
mime-types (3.7.0)
|
mime-types (3.7.0)
|
||||||
logger
|
logger
|
||||||
mime-types-data (~> 3.2025, >= 3.2025.0507)
|
mime-types-data (~> 3.2025, >= 3.2025.0507)
|
||||||
mime-types-data (3.2025.0729)
|
mime-types-data (3.2025.0514)
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
mini_portile2 (2.8.9)
|
mini_portile2 (2.8.9)
|
||||||
minitest (5.25.5)
|
minitest (5.25.5)
|
||||||
msgpack (1.8.0)
|
msgpack (1.8.0)
|
||||||
multi_json (1.17.0)
|
multi_json (1.15.0)
|
||||||
mutex_m (0.3.0)
|
mutex_m (0.3.0)
|
||||||
net-http (0.6.0)
|
net-http (0.6.0)
|
||||||
uri
|
uri
|
||||||
net-imap (0.5.9)
|
net-imap (0.5.8)
|
||||||
date
|
date
|
||||||
net-protocol
|
net-protocol
|
||||||
net-ldap (0.19.0)
|
net-ldap (0.19.0)
|
||||||
|
@ -468,7 +468,7 @@ GEM
|
||||||
hashie (>= 3.4.6)
|
hashie (>= 3.4.6)
|
||||||
rack (>= 2.2.3)
|
rack (>= 2.2.3)
|
||||||
rack-protection
|
rack-protection
|
||||||
omniauth-cas (3.0.2)
|
omniauth-cas (3.0.1)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
nokogiri (~> 1.12)
|
nokogiri (~> 1.12)
|
||||||
omniauth (~> 2.1)
|
omniauth (~> 2.1)
|
||||||
|
@ -515,7 +515,7 @@ GEM
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-active_support (~> 0.7)
|
opentelemetry-instrumentation-active_support (~> 0.7)
|
||||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||||
opentelemetry-instrumentation-action_pack (0.12.3)
|
opentelemetry-instrumentation-action_pack (0.12.1)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||||
opentelemetry-instrumentation-rack (~> 0.21)
|
opentelemetry-instrumentation-rack (~> 0.21)
|
||||||
|
@ -553,7 +553,7 @@ GEM
|
||||||
opentelemetry-instrumentation-faraday (0.27.0)
|
opentelemetry-instrumentation-faraday (0.27.0)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||||
opentelemetry-instrumentation-http (0.25.1)
|
opentelemetry-instrumentation-http (0.25.0)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||||
opentelemetry-instrumentation-http_client (0.23.0)
|
opentelemetry-instrumentation-http_client (0.23.0)
|
||||||
|
@ -597,20 +597,20 @@ GEM
|
||||||
opentelemetry-semantic_conventions (1.11.0)
|
opentelemetry-semantic_conventions (1.11.0)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
orm_adapter (0.5.0)
|
orm_adapter (0.5.0)
|
||||||
ostruct (0.6.3)
|
ostruct (0.6.1)
|
||||||
ox (2.14.23)
|
ox (2.14.23)
|
||||||
bigdecimal (>= 3.0)
|
bigdecimal (>= 3.0)
|
||||||
parallel (1.27.0)
|
parallel (1.27.0)
|
||||||
parser (3.3.9.0)
|
parser (3.3.8.0)
|
||||||
ast (~> 2.4.1)
|
ast (~> 2.4.1)
|
||||||
racc
|
racc
|
||||||
parslet (2.0.0)
|
parslet (2.0.0)
|
||||||
pastel (0.8.0)
|
pastel (0.8.0)
|
||||||
tty-color (~> 0.5)
|
tty-color (~> 0.5)
|
||||||
pg (1.6.1)
|
pg (1.5.9)
|
||||||
pghero (3.7.0)
|
pghero (3.7.0)
|
||||||
activerecord (>= 7.1)
|
activerecord (>= 7.1)
|
||||||
playwright-ruby-client (1.54.1)
|
playwright-ruby-client (1.52.0)
|
||||||
concurrent-ruby (>= 1.1.6)
|
concurrent-ruby (>= 1.1.6)
|
||||||
mime-types (>= 3.0)
|
mime-types (>= 3.0)
|
||||||
pp (0.6.2)
|
pp (0.6.2)
|
||||||
|
@ -627,15 +627,16 @@ GEM
|
||||||
prism (1.4.0)
|
prism (1.4.0)
|
||||||
prometheus_exporter (2.2.0)
|
prometheus_exporter (2.2.0)
|
||||||
webrick
|
webrick
|
||||||
propshaft (1.2.1)
|
propshaft (1.1.0)
|
||||||
actionpack (>= 7.0.0)
|
actionpack (>= 7.0.0)
|
||||||
activesupport (>= 7.0.0)
|
activesupport (>= 7.0.0)
|
||||||
rack
|
rack
|
||||||
|
railties (>= 7.0.0)
|
||||||
psych (5.2.6)
|
psych (5.2.6)
|
||||||
date
|
date
|
||||||
stringio
|
stringio
|
||||||
public_suffix (6.0.2)
|
public_suffix (6.0.2)
|
||||||
puma (6.6.1)
|
puma (6.6.0)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
pundit (2.5.0)
|
pundit (2.5.0)
|
||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
|
@ -681,7 +682,7 @@ GEM
|
||||||
activesupport (= 8.0.2)
|
activesupport (= 8.0.2)
|
||||||
bundler (>= 1.15.0)
|
bundler (>= 1.15.0)
|
||||||
railties (= 8.0.2)
|
railties (= 8.0.2)
|
||||||
rails-dom-testing (2.3.0)
|
rails-dom-testing (2.2.0)
|
||||||
activesupport (>= 5.0.0)
|
activesupport (>= 5.0.0)
|
||||||
minitest
|
minitest
|
||||||
nokogiri (>= 1.6)
|
nokogiri (>= 1.6)
|
||||||
|
@ -701,28 +702,23 @@ GEM
|
||||||
zeitwerk (~> 2.6)
|
zeitwerk (~> 2.6)
|
||||||
rainbow (3.1.1)
|
rainbow (3.1.1)
|
||||||
rake (13.3.0)
|
rake (13.3.0)
|
||||||
rdf (3.3.4)
|
rdf (3.3.2)
|
||||||
bcp47_spec (~> 0.2)
|
bcp47_spec (~> 0.2)
|
||||||
bigdecimal (~> 3.1, >= 3.1.5)
|
bigdecimal (~> 3.1, >= 3.1.5)
|
||||||
link_header (~> 0.0, >= 0.0.8)
|
link_header (~> 0.0, >= 0.0.8)
|
||||||
logger (~> 1.5)
|
|
||||||
ostruct (~> 0.6)
|
|
||||||
readline (~> 0.0)
|
|
||||||
rdf-normalize (0.7.0)
|
rdf-normalize (0.7.0)
|
||||||
rdf (~> 3.3)
|
rdf (~> 3.3)
|
||||||
rdoc (6.14.2)
|
rdoc (6.14.1)
|
||||||
erb
|
erb
|
||||||
psych (>= 4.0.0)
|
psych (>= 4.0.0)
|
||||||
readline (0.0.4)
|
|
||||||
reline
|
|
||||||
redcarpet (3.6.1)
|
redcarpet (3.6.1)
|
||||||
redis (4.8.1)
|
redis (4.8.1)
|
||||||
redis-client (0.25.1)
|
redis-client (0.24.0)
|
||||||
connection_pool
|
connection_pool
|
||||||
redlock (1.3.2)
|
redlock (1.3.2)
|
||||||
redis (>= 3.0.0, < 6.0)
|
redis (>= 3.0.0, < 6.0)
|
||||||
regexp_parser (2.11.0)
|
regexp_parser (2.10.0)
|
||||||
reline (0.6.2)
|
reline (0.6.1)
|
||||||
io-console (~> 0.5)
|
io-console (~> 0.5)
|
||||||
request_store (1.7.0)
|
request_store (1.7.0)
|
||||||
rack (>= 1.4)
|
rack (>= 1.4)
|
||||||
|
@ -731,17 +727,17 @@ GEM
|
||||||
railties (>= 5.2)
|
railties (>= 5.2)
|
||||||
rexml (3.4.1)
|
rexml (3.4.1)
|
||||||
rotp (6.3.0)
|
rotp (6.3.0)
|
||||||
rouge (4.6.0)
|
rouge (4.5.2)
|
||||||
rpam2 (4.0.2)
|
rpam2 (4.0.2)
|
||||||
rqrcode (3.1.0)
|
rqrcode (3.1.0)
|
||||||
chunky_png (~> 1.0)
|
chunky_png (~> 1.0)
|
||||||
rqrcode_core (~> 2.0)
|
rqrcode_core (~> 2.0)
|
||||||
rqrcode_core (2.0.0)
|
rqrcode_core (2.0.0)
|
||||||
rspec (3.13.1)
|
rspec (3.13.0)
|
||||||
rspec-core (~> 3.13.0)
|
rspec-core (~> 3.13.0)
|
||||||
rspec-expectations (~> 3.13.0)
|
rspec-expectations (~> 3.13.0)
|
||||||
rspec-mocks (~> 3.13.0)
|
rspec-mocks (~> 3.13.0)
|
||||||
rspec-core (3.13.5)
|
rspec-core (3.13.4)
|
||||||
rspec-support (~> 3.13.0)
|
rspec-support (~> 3.13.0)
|
||||||
rspec-expectations (3.13.5)
|
rspec-expectations (3.13.5)
|
||||||
diff-lcs (>= 1.2.0, < 2.0)
|
diff-lcs (>= 1.2.0, < 2.0)
|
||||||
|
@ -759,13 +755,13 @@ GEM
|
||||||
rspec-expectations (~> 3.13)
|
rspec-expectations (~> 3.13)
|
||||||
rspec-mocks (~> 3.13)
|
rspec-mocks (~> 3.13)
|
||||||
rspec-support (~> 3.13)
|
rspec-support (~> 3.13)
|
||||||
rspec-sidekiq (5.2.0)
|
rspec-sidekiq (5.1.0)
|
||||||
rspec-core (~> 3.0)
|
rspec-core (~> 3.0)
|
||||||
rspec-expectations (~> 3.0)
|
rspec-expectations (~> 3.0)
|
||||||
rspec-mocks (~> 3.0)
|
rspec-mocks (~> 3.0)
|
||||||
sidekiq (>= 5, < 9)
|
sidekiq (>= 5, < 9)
|
||||||
rspec-support (3.13.4)
|
rspec-support (3.13.4)
|
||||||
rubocop (1.79.2)
|
rubocop (1.77.0)
|
||||||
json (~> 2.3)
|
json (~> 2.3)
|
||||||
language_server-protocol (~> 3.17.0.2)
|
language_server-protocol (~> 3.17.0.2)
|
||||||
lint_roller (~> 1.1.0)
|
lint_roller (~> 1.1.0)
|
||||||
|
@ -773,10 +769,10 @@ GEM
|
||||||
parser (>= 3.3.0.2)
|
parser (>= 3.3.0.2)
|
||||||
rainbow (>= 2.2.2, < 4.0)
|
rainbow (>= 2.2.2, < 4.0)
|
||||||
regexp_parser (>= 2.9.3, < 3.0)
|
regexp_parser (>= 2.9.3, < 3.0)
|
||||||
rubocop-ast (>= 1.46.0, < 2.0)
|
rubocop-ast (>= 1.45.1, < 2.0)
|
||||||
ruby-progressbar (~> 1.7)
|
ruby-progressbar (~> 1.7)
|
||||||
unicode-display_width (>= 2.4.0, < 4.0)
|
unicode-display_width (>= 2.4.0, < 4.0)
|
||||||
rubocop-ast (1.46.0)
|
rubocop-ast (1.45.1)
|
||||||
parser (>= 3.3.7.2)
|
parser (>= 3.3.7.2)
|
||||||
prism (~> 1.4)
|
prism (~> 1.4)
|
||||||
rubocop-capybara (2.22.1)
|
rubocop-capybara (2.22.1)
|
||||||
|
@ -819,7 +815,7 @@ GEM
|
||||||
sanitize (7.0.0)
|
sanitize (7.0.0)
|
||||||
crass (~> 1.0.2)
|
crass (~> 1.0.2)
|
||||||
nokogiri (>= 1.16.8)
|
nokogiri (>= 1.16.8)
|
||||||
scenic (1.9.0)
|
scenic (1.8.0)
|
||||||
activerecord (>= 4.0.0)
|
activerecord (>= 4.0.0)
|
||||||
railties (>= 4.0.0)
|
railties (>= 4.0.0)
|
||||||
securerandom (0.4.1)
|
securerandom (0.4.1)
|
||||||
|
@ -833,9 +829,10 @@ GEM
|
||||||
redis-client (>= 0.22.2)
|
redis-client (>= 0.22.2)
|
||||||
sidekiq-bulk (0.2.0)
|
sidekiq-bulk (0.2.0)
|
||||||
sidekiq
|
sidekiq
|
||||||
sidekiq-scheduler (6.0.1)
|
sidekiq-scheduler (5.0.6)
|
||||||
rufus-scheduler (~> 3.2)
|
rufus-scheduler (~> 3.2)
|
||||||
sidekiq (>= 7.3, < 9)
|
sidekiq (>= 6, < 8)
|
||||||
|
tilt (>= 1.4.0, < 3)
|
||||||
sidekiq-unique-jobs (8.0.11)
|
sidekiq-unique-jobs (8.0.11)
|
||||||
concurrent-ruby (~> 1.0, >= 1.0.5)
|
concurrent-ruby (~> 1.0, >= 1.0.5)
|
||||||
sidekiq (>= 7.0.0, < 9.0.0)
|
sidekiq (>= 7.0.0, < 9.0.0)
|
||||||
|
@ -849,7 +846,7 @@ GEM
|
||||||
docile (~> 1.1)
|
docile (~> 1.1)
|
||||||
simplecov-html (~> 0.11)
|
simplecov-html (~> 0.11)
|
||||||
simplecov_json_formatter (~> 0.1)
|
simplecov_json_formatter (~> 0.1)
|
||||||
simplecov-html (0.13.2)
|
simplecov-html (0.13.1)
|
||||||
simplecov-lcov (0.8.0)
|
simplecov-lcov (0.8.0)
|
||||||
simplecov_json_formatter (0.1.4)
|
simplecov_json_formatter (0.1.4)
|
||||||
stackprof (0.2.27)
|
stackprof (0.2.27)
|
||||||
|
@ -858,7 +855,7 @@ GEM
|
||||||
stoplight (4.1.1)
|
stoplight (4.1.1)
|
||||||
redlock (~> 1.0)
|
redlock (~> 1.0)
|
||||||
stringio (3.1.7)
|
stringio (3.1.7)
|
||||||
strong_migrations (2.5.0)
|
strong_migrations (2.4.0)
|
||||||
activerecord (>= 7.1)
|
activerecord (>= 7.1)
|
||||||
swd (2.0.3)
|
swd (2.0.3)
|
||||||
activesupport (>= 3)
|
activesupport (>= 3)
|
||||||
|
@ -866,14 +863,14 @@ GEM
|
||||||
faraday (~> 2.0)
|
faraday (~> 2.0)
|
||||||
faraday-follow_redirects
|
faraday-follow_redirects
|
||||||
sysexits (1.2.0)
|
sysexits (1.2.0)
|
||||||
temple (0.10.4)
|
temple (0.10.3)
|
||||||
terminal-table (4.0.0)
|
terminal-table (4.0.0)
|
||||||
unicode-display_width (>= 1.1.1, < 4)
|
unicode-display_width (>= 1.1.1, < 4)
|
||||||
terrapin (1.1.1)
|
terrapin (1.1.0)
|
||||||
climate_control
|
climate_control
|
||||||
test-prof (1.4.4)
|
test-prof (1.4.4)
|
||||||
thor (1.4.0)
|
thor (1.4.0)
|
||||||
tilt (2.6.1)
|
tilt (2.6.0)
|
||||||
timeout (0.4.3)
|
timeout (0.4.3)
|
||||||
tpm-key_attestation (0.14.1)
|
tpm-key_attestation (0.14.1)
|
||||||
bindata (~> 2.4)
|
bindata (~> 2.4)
|
||||||
|
@ -935,7 +932,7 @@ GEM
|
||||||
crack (>= 0.3.2)
|
crack (>= 0.3.2)
|
||||||
hashdiff (>= 0.4.0, < 2.0.0)
|
hashdiff (>= 0.4.0, < 2.0.0)
|
||||||
webrick (1.9.1)
|
webrick (1.9.1)
|
||||||
websocket-driver (0.8.0)
|
websocket-driver (0.7.7)
|
||||||
base64
|
base64
|
||||||
websocket-extensions (>= 0.1.0)
|
websocket-extensions (>= 0.1.0)
|
||||||
websocket-extensions (0.1.5)
|
websocket-extensions (0.1.5)
|
||||||
|
@ -1011,7 +1008,7 @@ DEPENDENCIES
|
||||||
letter_opener (~> 1.8)
|
letter_opener (~> 1.8)
|
||||||
letter_opener_web (~> 3.0)
|
letter_opener_web (~> 3.0)
|
||||||
link_header (~> 0.0)
|
link_header (~> 0.0)
|
||||||
linzer (~> 0.7.7)
|
linzer (~> 0.7.2)
|
||||||
lograge (~> 0.12)
|
lograge (~> 0.12)
|
||||||
mail (~> 2.8)
|
mail (~> 2.8)
|
||||||
mario-redis-lock (~> 1.2)
|
mario-redis-lock (~> 1.2)
|
||||||
|
@ -1081,7 +1078,7 @@ DEPENDENCIES
|
||||||
shoulda-matchers
|
shoulda-matchers
|
||||||
sidekiq (< 8)
|
sidekiq (< 8)
|
||||||
sidekiq-bulk (~> 0.2.0)
|
sidekiq-bulk (~> 0.2.0)
|
||||||
sidekiq-scheduler (~> 6.0)
|
sidekiq-scheduler (~> 5.0)
|
||||||
sidekiq-unique-jobs (> 8)
|
sidekiq-unique-jobs (> 8)
|
||||||
simple-navigation (~> 4.4)
|
simple-navigation (~> 4.4)
|
||||||
simple_form (~> 5.2)
|
simple_form (~> 5.2)
|
||||||
|
@ -1105,4 +1102,4 @@ RUBY VERSION
|
||||||
ruby 3.4.1p0
|
ruby 3.4.1p0
|
||||||
|
|
||||||
BUNDLED WITH
|
BUNDLED WITH
|
||||||
2.7.1
|
2.6.9
|
||||||
|
|
64
README.md
64
README.md
|
@ -17,71 +17,71 @@
|
||||||
<img src="https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg" alt="Crowdin" /></a>
|
<img src="https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg" alt="Crowdin" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
Mastodon is a **free, open-source social network server** based on [ActivityPub](https://www.w3.org/TR/activitypub/) where users can follow friends and discover new ones. On Mastodon, users can publish anything they want: links, pictures, text, and video. All Mastodon servers are interoperable as a federated network (users on one server can seamlessly communicate with users from another one, including non-Mastodon software that implements ActivityPub!)
|
Mastodon is a **free, open-source social network server** based on ActivityPub where users can follow friends and discover new ones. On Mastodon, users can publish anything they want: links, pictures, text, and video. All Mastodon servers are interoperable as a federated network (users on one server can seamlessly communicate with users from another one, including non-Mastodon software that implements ActivityPub!)
|
||||||
|
|
||||||
## Navigation
|
## Navigation
|
||||||
|
|
||||||
- [Project homepage 🐘](https://joinmastodon.org)
|
- [Project homepage 🐘](https://joinmastodon.org)
|
||||||
- [Donate to support development 🎁](https://joinmastodon.org/sponsors#donate)
|
- [Support the development via Patreon][patreon]
|
||||||
- [View sponsors](https://joinmastodon.org/sponsors)
|
- [View sponsors](https://joinmastodon.org/sponsors)
|
||||||
- [Blog 📰](https://blog.joinmastodon.org)
|
- [Blog](https://blog.joinmastodon.org)
|
||||||
- [Documentation 📚](https://docs.joinmastodon.org)
|
- [Documentation](https://docs.joinmastodon.org)
|
||||||
- [Official container image 🚢](https://github.com/mastodon/mastodon/pkgs/container/mastodon)
|
- [Roadmap](https://joinmastodon.org/roadmap)
|
||||||
|
- [Official Docker image](https://github.com/mastodon/mastodon/pkgs/container/mastodon)
|
||||||
|
- [Browse Mastodon servers](https://joinmastodon.org/communities)
|
||||||
|
- [Browse Mastodon apps](https://joinmastodon.org/apps)
|
||||||
|
|
||||||
|
[patreon]: https://www.patreon.com/mastodon
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
<img src="./app/javascript/images/elephant_ui_working.svg?raw=true" align="right" width="30%" />
|
<img src="/app/javascript/images/elephant_ui_working.svg?raw=true" align="right" width="30%" />
|
||||||
|
|
||||||
**Part of the Fediverse. Based on open standards, with no vendor lock-in.** - the network goes beyond just Mastodon; anything that implements ActivityPub is part of a broader social network known as [the Fediverse](https://jointhefediverse.net/). You can follow and interact with users on other servers (including those running different software), and they can follow you back.
|
**No vendor lock-in: Fully interoperable with any conforming platform** - It doesn't have to be Mastodon; whatever implements ActivityPub is part of the social network! [Learn more](https://blog.joinmastodon.org/2018/06/why-activitypub-is-the-future/)
|
||||||
|
|
||||||
**Real-time, chronological timeline updates** - updates of people you're following appear in real-time in the UI.
|
**Real-time, chronological timeline updates** - updates of people you're following appear in real-time in the UI via WebSockets. There's a firehose view as well!
|
||||||
|
|
||||||
**Media attachments** - upload and view images and videos attached to the updates. Videos with no audio track are treated like animated GIFs; normal videos loop continuously.
|
**Media attachments like images and short videos** - upload and view images and WebM/MP4 videos attached to the updates. Videos with no audio track are treated like GIFs; normal videos loop continuously!
|
||||||
|
|
||||||
**Safety and moderation tools** - Mastodon includes private posts, locked accounts, phrase filtering, muting, blocking, and many other features, along with a reporting and moderation system.
|
**Safety and moderation tools** - Mastodon includes private posts, locked accounts, phrase filtering, muting, blocking, and all sorts of other features, along with a reporting and moderation system. [Learn more](https://blog.joinmastodon.org/2018/07/cage-the-mastodon/)
|
||||||
|
|
||||||
**OAuth2 and a straightforward REST API** - Mastodon acts as an OAuth2 provider, and third party apps can use the REST and Streaming APIs. This results in a [rich app ecosystem](https://joinmastodon.org/apps) with a variety of choices!
|
**OAuth2 and a straightforward REST API** - Mastodon acts as an OAuth2 provider, so 3rd party apps can use the REST and Streaming APIs. This results in a rich app ecosystem with a lot of choices!
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
### Tech stack
|
### Tech stack
|
||||||
|
|
||||||
- [Ruby on Rails](https://github.com/rails/rails) powers the REST API and other web pages.
|
- **Ruby on Rails** powers the REST API and other web pages
|
||||||
- [PostgreSQL](https://www.postgresql.org/) is the main database.
|
- **React.js** and **Redux** are used for the dynamic parts of the interface
|
||||||
- [Redis](https://redis.io/) and [Sidekiq](https://sidekiq.org/) are used for caching and queueing.
|
- **Node.js** powers the streaming API
|
||||||
- [Node.js](https://nodejs.org/) powers the streaming API.
|
|
||||||
- [React.js](https://reactjs.org/) and [Redux](https://redux.js.org/) are used for the dynamic parts of the interface.
|
|
||||||
- [BrowserStack](https://www.browserstack.com/) supports testing on real devices and browsers. (This project is tested with BrowserStack)
|
|
||||||
- [Chromatic](https://www.chromatic.com/) provides visual regression testing. (This project is tested with Chromatic)
|
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
- **Ruby** 3.2+
|
|
||||||
- **PostgreSQL** 13+
|
- **PostgreSQL** 13+
|
||||||
- **Redis** 6.2+
|
- **Redis** 6.2+
|
||||||
|
- **Ruby** 3.2+
|
||||||
- **Node.js** 20+
|
- **Node.js** 20+
|
||||||
|
|
||||||
This repository includes deployment configurations for **Docker and docker-compose**, as well as for other environments like Heroku and Scalingo. For Helm charts, reference the [mastodon/chart repository](https://github.com/mastodon/chart). A [**standalone** installation guide](https://docs.joinmastodon.org/admin/install/) is available in the main documentation.
|
The repository includes deployment configurations for **Docker and docker-compose** as well as specific platforms like **Heroku**, and **Scalingo**. For Helm charts, reference the [mastodon/chart repository](https://github.com/mastodon/chart). The [**standalone** installation guide](https://docs.joinmastodon.org/admin/install/) is available in the documentation.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Mastodon is **free, open-source software** licensed under **AGPLv3**. We welcome contributions and help from anyone who wants to improve the project.
|
Mastodon is **free, open-source software** licensed under **AGPLv3**.
|
||||||
|
|
||||||
You should read the overall [CONTRIBUTING](https://github.com/mastodon/.github/blob/main/CONTRIBUTING.md) guide, which covers our development processes.
|
You can open issues for bugs you've found or features you think are missing. You
|
||||||
|
can also submit pull requests to this repository or translations via Crowdin. To
|
||||||
|
get started, look at the [CONTRIBUTING] and [DEVELOPMENT] guides. For changes
|
||||||
|
accepted into Mastodon, you can request to be paid through our [OpenCollective].
|
||||||
|
|
||||||
You should also read and understand the [CODE OF CONDUCT](https://github.com/mastodon/.github/blob/main/CODE_OF_CONDUCT.md) that enables us to maintain a welcoming and inclusive community. Collaboration begins with mutual respect and understanding.
|
**IRC channel**: #mastodon on [`irc.libera.chat`](https://libera.chat)
|
||||||
|
|
||||||
You can learn about setting up a development environment in the [DEVELOPMENT](docs/DEVELOPMENT.md) documentation.
|
## License
|
||||||
|
|
||||||
If you would like to help with translations 🌐 you can do so on [Crowdin](https://crowdin.com/project/mastodon).
|
|
||||||
|
|
||||||
## LICENSE
|
|
||||||
|
|
||||||
Copyright (c) 2016-2025 Eugen Rochko (+ [`mastodon authors`](AUTHORS.md))
|
Copyright (c) 2016-2025 Eugen Rochko (+ [`mastodon authors`](AUTHORS.md))
|
||||||
|
|
||||||
Licensed under GNU Affero General Public License as stated in the [LICENSE](LICENSE):
|
Licensed under GNU Affero General Public License as stated in the [LICENSE](LICENSE):
|
||||||
|
|
||||||
```text
|
```
|
||||||
Copyright (c) 2016-2025 Eugen Rochko & other Mastodon contributors
|
Copyright (c) 2016-2025 Eugen Rochko & other Mastodon contributors
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify it under
|
This program is free software: you can redistribute it and/or modify it under
|
||||||
|
@ -97,3 +97,7 @@ details.
|
||||||
You should have received a copy of the GNU Affero General Public License along
|
You should have received a copy of the GNU Affero General Public License along
|
||||||
with this program. If not, see https://www.gnu.org/licenses/
|
with this program. If not, see https://www.gnu.org/licenses/
|
||||||
```
|
```
|
||||||
|
|
||||||
|
[CONTRIBUTING]: CONTRIBUTING.md
|
||||||
|
[DEVELOPMENT]: docs/DEVELOPMENT.md
|
||||||
|
[OpenCollective]: https://opencollective.com/mastodon
|
||||||
|
|
|
@ -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
|
def batch
|
||||||
authorize :account, :index?
|
authorize :account, :index?
|
||||||
|
|
||||||
@form = Form::AccountBatch.new(
|
@form = Form::AccountBatch.new(form_account_batch_params)
|
||||||
form_account_batch_params.merge(
|
@form.current_account = current_account
|
||||||
action: action_from_button,
|
@form.action = action_from_button
|
||||||
current_account:,
|
@form.select_all_matching = params[:select_all_matching]
|
||||||
query: filtered_accounts,
|
@form.query = filtered_accounts
|
||||||
select_all_matching: params[:select_all_matching]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
@form.save
|
@form.save
|
||||||
rescue ActionController::ParameterMissing
|
rescue ActionController::ParameterMissing
|
||||||
flash[:alert] = I18n.t('admin.accounts.no_account_selected')
|
flash[:alert] = I18n.t('admin.accounts.no_account_selected')
|
||||||
|
|
|
@ -6,7 +6,7 @@ module Admin
|
||||||
|
|
||||||
def index
|
def index
|
||||||
authorize :audit_log, :index?
|
authorize :audit_log, :index?
|
||||||
@auditable_accounts = Account.auditable.select(:id, :username).order(username: :asc)
|
@auditable_accounts = Account.auditable.select(:id, :username)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
|
@ -19,13 +19,15 @@ module Admin
|
||||||
|
|
||||||
log_action :resend, @user
|
log_action :resend, @user
|
||||||
|
|
||||||
redirect_to admin_accounts_path, notice: t('admin.accounts.resend_confirmation.success')
|
flash[:notice] = I18n.t('admin.accounts.resend_confirmation.success')
|
||||||
|
redirect_to admin_accounts_path
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def redirect_confirmed_user
|
def redirect_confirmed_user
|
||||||
redirect_to admin_accounts_path, flash: { error: t('admin.accounts.resend_confirmation.already_confirmed') }
|
flash[:error] = I18n.t('admin.accounts.resend_confirmation.already_confirmed')
|
||||||
|
redirect_to admin_accounts_path
|
||||||
end
|
end
|
||||||
|
|
||||||
def user_confirmed?
|
def user_confirmed?
|
||||||
|
|
|
@ -18,7 +18,7 @@ class Admin::Disputes::AppealsController < Admin::BaseController
|
||||||
end
|
end
|
||||||
|
|
||||||
def reject
|
def reject
|
||||||
authorize @appeal, :reject?
|
authorize @appeal, :approve?
|
||||||
log_action :reject, @appeal
|
log_action :reject, @appeal
|
||||||
@appeal.reject!(current_account)
|
@appeal.reject!(current_account)
|
||||||
UserMailer.appeal_rejected(@appeal.account.user, @appeal).deliver_later
|
UserMailer.appeal_rejected(@appeal.account.user, @appeal).deliver_later
|
||||||
|
|
|
@ -36,7 +36,7 @@ module Admin
|
||||||
end
|
end
|
||||||
|
|
||||||
def edit
|
def edit
|
||||||
authorize :domain_block, :update?
|
authorize :domain_block, :create?
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
|
@ -129,7 +129,7 @@ module Admin
|
||||||
end
|
end
|
||||||
|
|
||||||
def requires_confirmation?
|
def requires_confirmation?
|
||||||
@domain_block.valid? && (@domain_block.new_record? || @domain_block.severity_changed?) && @domain_block.suspend? && !params[:confirm]
|
@domain_block.valid? && (@domain_block.new_record? || @domain_block.severity_changed?) && @domain_block.severity.to_s == 'suspend' && !params[:confirm]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -13,9 +13,27 @@ class Admin::Reports::ActionsController < Admin::BaseController
|
||||||
|
|
||||||
case action_from_button
|
case action_from_button
|
||||||
when 'delete', 'mark_as_sensitive'
|
when 'delete', 'mark_as_sensitive'
|
||||||
Admin::StatusBatchAction.new(status_batch_action_params).save!
|
status_batch_action = Admin::StatusBatchAction.new(
|
||||||
|
type: action_from_button,
|
||||||
|
status_ids: @report.status_ids,
|
||||||
|
current_account: current_account,
|
||||||
|
report_id: @report.id,
|
||||||
|
send_email_notification: !@report.spam?,
|
||||||
|
text: params[:text]
|
||||||
|
)
|
||||||
|
|
||||||
|
status_batch_action.save!
|
||||||
when 'silence', 'suspend'
|
when 'silence', 'suspend'
|
||||||
Admin::AccountAction.new(account_action_params).save!
|
account_action = Admin::AccountAction.new(
|
||||||
|
type: action_from_button,
|
||||||
|
report_id: @report.id,
|
||||||
|
target_account: @report.target_account,
|
||||||
|
current_account: current_account,
|
||||||
|
send_email_notification: !@report.spam?,
|
||||||
|
text: params[:text]
|
||||||
|
)
|
||||||
|
|
||||||
|
account_action.save!
|
||||||
else
|
else
|
||||||
return redirect_to admin_report_path(@report), alert: I18n.t('admin.reports.unknown_action_msg', action: action_from_button)
|
return redirect_to admin_report_path(@report), alert: I18n.t('admin.reports.unknown_action_msg', action: action_from_button)
|
||||||
end
|
end
|
||||||
|
@ -25,26 +43,6 @@ class Admin::Reports::ActionsController < Admin::BaseController
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def status_batch_action_params
|
|
||||||
shared_params
|
|
||||||
.merge(status_ids: @report.status_ids)
|
|
||||||
end
|
|
||||||
|
|
||||||
def account_action_params
|
|
||||||
shared_params
|
|
||||||
.merge(target_account: @report.target_account)
|
|
||||||
end
|
|
||||||
|
|
||||||
def shared_params
|
|
||||||
{
|
|
||||||
current_account: current_account,
|
|
||||||
report_id: @report.id,
|
|
||||||
send_email_notification: !@report.spam?,
|
|
||||||
text: params[:text],
|
|
||||||
type: action_from_button,
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_report
|
def set_report
|
||||||
@report = Report.find(params[:report_id])
|
@report = Report.find(params[:report_id])
|
||||||
end
|
end
|
||||||
|
|
|
@ -14,7 +14,8 @@ module Admin
|
||||||
@admin_settings = Form::AdminSettings.new(settings_params)
|
@admin_settings = Form::AdminSettings.new(settings_params)
|
||||||
|
|
||||||
if @admin_settings.save
|
if @admin_settings.save
|
||||||
redirect_to after_update_redirect_path, notice: t('generic.changes_saved_msg')
|
flash[:notice] = I18n.t('generic.changes_saved_msg')
|
||||||
|
redirect_to after_update_redirect_path
|
||||||
else
|
else
|
||||||
render :show
|
render :show
|
||||||
end
|
end
|
||||||
|
|
|
@ -5,7 +5,6 @@ module Admin
|
||||||
before_action :set_tag, except: [:index]
|
before_action :set_tag, except: [:index]
|
||||||
|
|
||||||
PER_PAGE = 20
|
PER_PAGE = 20
|
||||||
PERIOD_DAYS = 6.days
|
|
||||||
|
|
||||||
def index
|
def index
|
||||||
authorize :tag, :index?
|
authorize :tag, :index?
|
||||||
|
@ -16,7 +15,7 @@ module Admin
|
||||||
def show
|
def show
|
||||||
authorize @tag, :show?
|
authorize @tag, :show?
|
||||||
|
|
||||||
@time_period = report_range
|
@time_period = (6.days.ago.to_date...Time.now.utc.to_date)
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
|
@ -25,7 +24,7 @@ module Admin
|
||||||
if @tag.update(tag_params.merge(reviewed_at: Time.now.utc))
|
if @tag.update(tag_params.merge(reviewed_at: Time.now.utc))
|
||||||
redirect_to admin_tag_path(@tag.id), notice: I18n.t('admin.tags.updated_msg')
|
redirect_to admin_tag_path(@tag.id), notice: I18n.t('admin.tags.updated_msg')
|
||||||
else
|
else
|
||||||
@time_period = report_range
|
@time_period = (6.days.ago.to_date...Time.now.utc.to_date)
|
||||||
|
|
||||||
render :show
|
render :show
|
||||||
end
|
end
|
||||||
|
@ -37,10 +36,6 @@ module Admin
|
||||||
@tag = Tag.find(params[:id])
|
@tag = Tag.find(params[:id])
|
||||||
end
|
end
|
||||||
|
|
||||||
def report_range
|
|
||||||
(PERIOD_DAYS.ago.to_date...Time.now.utc.to_date)
|
|
||||||
end
|
|
||||||
|
|
||||||
def tag_params
|
def tag_params
|
||||||
params
|
params
|
||||||
.expect(tag: [:name, :display_name, :trendable, :usable, :listable])
|
.expect(tag: [:name, :display_name, :trendable, :usable, :listable])
|
||||||
|
|
|
@ -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
|
class Api::V1::Admin::TagsController < Api::BaseController
|
||||||
include Authorization
|
include Authorization
|
||||||
|
|
||||||
before_action -> { authorize_if_got_token! :'admin:read' }, only: [:index, :show]
|
before_action -> { authorize_if_got_token! :'admin:read' }, only: [:index, :show]
|
||||||
before_action -> { authorize_if_got_token! :'admin:write' }, only: :update
|
before_action -> { authorize_if_got_token! :'admin:write' }, only: :update
|
||||||
|
|
||||||
|
|
|
@ -7,7 +7,6 @@ class Api::V1::InvitesController < Api::BaseController
|
||||||
skip_around_action :set_locale
|
skip_around_action :set_locale
|
||||||
|
|
||||||
before_action :set_invite
|
before_action :set_invite
|
||||||
before_action :check_valid_usage!
|
|
||||||
before_action :check_enabled_registrations!
|
before_action :check_enabled_registrations!
|
||||||
|
|
||||||
# Override `current_user` to avoid reading session cookies
|
# Override `current_user` to avoid reading session cookies
|
||||||
|
@ -23,11 +22,9 @@ class Api::V1::InvitesController < Api::BaseController
|
||||||
@invite = Invite.find_by!(code: params[:invite_code])
|
@invite = Invite.find_by!(code: params[:invite_code])
|
||||||
end
|
end
|
||||||
|
|
||||||
def check_valid_usage!
|
|
||||||
render json: { error: I18n.t('invites.invalid') }, status: 401 unless @invite.valid_for_use?
|
|
||||||
end
|
|
||||||
|
|
||||||
def check_enabled_registrations!
|
def check_enabled_registrations!
|
||||||
|
return render json: { error: I18n.t('invites.invalid') }, status: 401 unless @invite.valid_for_use?
|
||||||
|
|
||||||
raise Mastodon::NotPermittedError unless allowed_registration?(request.remote_ip, @invite)
|
raise Mastodon::NotPermittedError unless allowed_registration?(request.remote_ip, @invite)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -16,7 +16,16 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController
|
||||||
def create
|
def create
|
||||||
with_redis_lock("push_subscription:#{current_user.id}") do
|
with_redis_lock("push_subscription:#{current_user.id}") do
|
||||||
destroy_web_push_subscriptions!
|
destroy_web_push_subscriptions!
|
||||||
@push_subscription = Web::PushSubscription.create!(web_push_subscription_params)
|
|
||||||
|
@push_subscription = Web::PushSubscription.create!(
|
||||||
|
endpoint: subscription_params[:endpoint],
|
||||||
|
key_p256dh: subscription_params[:keys][:p256dh],
|
||||||
|
key_auth: subscription_params[:keys][:auth],
|
||||||
|
standard: subscription_params[:standard] || false,
|
||||||
|
data: data_params,
|
||||||
|
user_id: current_user.id,
|
||||||
|
access_token_id: doorkeeper_token.id
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer
|
render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer
|
||||||
|
@ -46,18 +55,6 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController
|
||||||
not_found if @push_subscription.nil?
|
not_found if @push_subscription.nil?
|
||||||
end
|
end
|
||||||
|
|
||||||
def web_push_subscription_params
|
|
||||||
{
|
|
||||||
access_token_id: doorkeeper_token.id,
|
|
||||||
data: data_params,
|
|
||||||
endpoint: subscription_params[:endpoint],
|
|
||||||
key_auth: subscription_params[:keys][:auth],
|
|
||||||
key_p256dh: subscription_params[:keys][:p256dh],
|
|
||||||
standard: subscription_params[:standard] || false,
|
|
||||||
user_id: current_user.id,
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def subscription_params
|
def subscription_params
|
||||||
params.expect(subscription: [:endpoint, :standard, keys: [:auth, :p256dh]])
|
params.expect(subscription: [:endpoint, :standard, keys: [:auth, :p256dh]])
|
||||||
end
|
end
|
||||||
|
|
|
@ -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
|
class Api::V1::StatusesController < Api::BaseController
|
||||||
include Authorization
|
include Authorization
|
||||||
include AsyncRefreshesConcern
|
|
||||||
|
|
||||||
before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [:create, :update, :destroy]
|
before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [:create, :update, :destroy]
|
||||||
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:create, :update, :destroy]
|
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:create, :update, :destroy]
|
||||||
|
@ -10,7 +9,6 @@ class Api::V1::StatusesController < Api::BaseController
|
||||||
before_action :set_statuses, only: [:index]
|
before_action :set_statuses, only: [:index]
|
||||||
before_action :set_status, only: [:show, :context]
|
before_action :set_status, only: [:show, :context]
|
||||||
before_action :set_thread, only: [:create]
|
before_action :set_thread, only: [:create]
|
||||||
before_action :set_quoted_status, only: [:create]
|
|
||||||
before_action :check_statuses_limit, only: [:index]
|
before_action :check_statuses_limit, only: [:index]
|
||||||
|
|
||||||
override_rate_limit_headers :create, family: :statuses
|
override_rate_limit_headers :create, family: :statuses
|
||||||
|
@ -59,21 +57,9 @@ class Api::V1::StatusesController < Api::BaseController
|
||||||
@context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants)
|
@context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants)
|
||||||
statuses = [@status] + @context.ancestors + @context.descendants
|
statuses = [@status] + @context.ancestors + @context.descendants
|
||||||
|
|
||||||
refresh_key = "context:#{@status.id}:refresh"
|
|
||||||
async_refresh = AsyncRefresh.new(refresh_key)
|
|
||||||
|
|
||||||
if async_refresh.running?
|
|
||||||
add_async_refresh_header(async_refresh)
|
|
||||||
elsif !current_account.nil? && @status.should_fetch_replies?
|
|
||||||
add_async_refresh_header(AsyncRefresh.create(refresh_key))
|
|
||||||
|
|
||||||
WorkerBatch.new.within do |batch|
|
|
||||||
batch.connect(refresh_key, threshold: 1.0)
|
|
||||||
ActivityPub::FetchAllRepliesWorker.perform_async(@status.id, { 'batch_id' => batch.id })
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id)
|
render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id)
|
||||||
|
|
||||||
|
ActivityPub::FetchAllRepliesWorker.perform_async(@status.id) if !current_account.nil? && @status.should_fetch_replies?
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
|
@ -81,8 +67,6 @@ class Api::V1::StatusesController < Api::BaseController
|
||||||
current_user.account,
|
current_user.account,
|
||||||
text: status_params[:status],
|
text: status_params[:status],
|
||||||
thread: @thread,
|
thread: @thread,
|
||||||
quoted_status: @quoted_status,
|
|
||||||
quote_approval_policy: quote_approval_policy,
|
|
||||||
media_ids: status_params[:media_ids],
|
media_ids: status_params[:media_ids],
|
||||||
sensitive: status_params[:sensitive],
|
sensitive: status_params[:sensitive],
|
||||||
spoiler_text: status_params[:spoiler_text],
|
spoiler_text: status_params[:spoiler_text],
|
||||||
|
@ -114,8 +98,7 @@ class Api::V1::StatusesController < Api::BaseController
|
||||||
sensitive: status_params[:sensitive],
|
sensitive: status_params[:sensitive],
|
||||||
language: status_params[:language],
|
language: status_params[:language],
|
||||||
spoiler_text: status_params[:spoiler_text],
|
spoiler_text: status_params[:spoiler_text],
|
||||||
poll: status_params[:poll],
|
poll: status_params[:poll]
|
||||||
quote_approval_policy: quote_approval_policy
|
|
||||||
)
|
)
|
||||||
|
|
||||||
render json: @status, serializer: REST::StatusSerializer
|
render json: @status, serializer: REST::StatusSerializer
|
||||||
|
@ -155,16 +138,6 @@ class Api::V1::StatusesController < Api::BaseController
|
||||||
render json: { error: I18n.t('statuses.errors.in_reply_not_found') }, status: 404
|
render json: { error: I18n.t('statuses.errors.in_reply_not_found') }, status: 404
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_quoted_status
|
|
||||||
return unless Mastodon::Feature.outgoing_quotes_enabled?
|
|
||||||
|
|
||||||
@quoted_status = Status.find(status_params[:quoted_status_id]) if status_params[:quoted_status_id].present?
|
|
||||||
authorize(@quoted_status, :quote?) if @quoted_status.present?
|
|
||||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
|
||||||
# TODO: distinguish between non-existing and non-quotable posts
|
|
||||||
render json: { error: I18n.t('statuses.errors.quoted_status_not_found') }, status: 404
|
|
||||||
end
|
|
||||||
|
|
||||||
def check_statuses_limit
|
def check_statuses_limit
|
||||||
raise(Mastodon::ValidationError) if status_ids.size > DEFAULT_STATUSES_LIMIT
|
raise(Mastodon::ValidationError) if status_ids.size > DEFAULT_STATUSES_LIMIT
|
||||||
end
|
end
|
||||||
|
@ -181,8 +154,6 @@ class Api::V1::StatusesController < Api::BaseController
|
||||||
params.permit(
|
params.permit(
|
||||||
:status,
|
:status,
|
||||||
:in_reply_to_id,
|
:in_reply_to_id,
|
||||||
:quoted_status_id,
|
|
||||||
:quote_approval_policy,
|
|
||||||
:sensitive,
|
:sensitive,
|
||||||
:spoiler_text,
|
:spoiler_text,
|
||||||
:visibility,
|
:visibility,
|
||||||
|
@ -205,23 +176,6 @@ class Api::V1::StatusesController < Api::BaseController
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def quote_approval_policy
|
|
||||||
# TODO: handle `nil` separately
|
|
||||||
return nil unless Mastodon::Feature.outgoing_quotes_enabled? && status_params[:quote_approval_policy].present?
|
|
||||||
|
|
||||||
case status_params[:quote_approval_policy]
|
|
||||||
when 'public'
|
|
||||||
Status::QUOTE_APPROVAL_POLICY_FLAGS[:public] << 16
|
|
||||||
when 'followers'
|
|
||||||
Status::QUOTE_APPROVAL_POLICY_FLAGS[:followers] << 16
|
|
||||||
when 'nobody'
|
|
||||||
0
|
|
||||||
else
|
|
||||||
# TODO: raise more useful message
|
|
||||||
raise ActiveRecord::RecordInvalid
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def serializer_for_status
|
def serializer_for_status
|
||||||
@status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer
|
@status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer
|
||||||
end
|
end
|
||||||
|
|
|
@ -20,7 +20,7 @@ class Api::V2::SearchController < Api::BaseController
|
||||||
@search = Search.new(search_results)
|
@search = Search.new(search_results)
|
||||||
render json: @search, serializer: REST::SearchSerializer
|
render json: @search, serializer: REST::SearchSerializer
|
||||||
rescue Mastodon::SyntaxError
|
rescue Mastodon::SyntaxError
|
||||||
unprocessable_content
|
unprocessable_entity
|
||||||
rescue ActiveRecord::RecordNotFound
|
rescue ActiveRecord::RecordNotFound
|
||||||
not_found
|
not_found
|
||||||
end
|
end
|
||||||
|
|
|
@ -49,7 +49,7 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
|
||||||
{
|
{
|
||||||
policy: 'all',
|
policy: 'all',
|
||||||
alerts: Notification::TYPES.index_with { alerts_enabled },
|
alerts: Notification::TYPES.index_with { alerts_enabled },
|
||||||
}.deep_stringify_keys
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def alerts_enabled
|
def alerts_enabled
|
||||||
|
|
|
@ -28,7 +28,7 @@ class ApplicationController < ActionController::Base
|
||||||
rescue_from Mastodon::NotPermittedError, with: :forbidden
|
rescue_from Mastodon::NotPermittedError, with: :forbidden
|
||||||
rescue_from ActionController::RoutingError, ActiveRecord::RecordNotFound, with: :not_found
|
rescue_from ActionController::RoutingError, ActiveRecord::RecordNotFound, with: :not_found
|
||||||
rescue_from ActionController::UnknownFormat, with: :not_acceptable
|
rescue_from ActionController::UnknownFormat, with: :not_acceptable
|
||||||
rescue_from ActionController::InvalidAuthenticityToken, with: :unprocessable_content
|
rescue_from ActionController::InvalidAuthenticityToken, with: :unprocessable_entity
|
||||||
rescue_from Mastodon::RateLimitExceededError, with: :too_many_requests
|
rescue_from Mastodon::RateLimitExceededError, with: :too_many_requests
|
||||||
|
|
||||||
rescue_from(*Mastodon::HTTP_CONNECTION_ERRORS, with: :internal_server_error)
|
rescue_from(*Mastodon::HTTP_CONNECTION_ERRORS, with: :internal_server_error)
|
||||||
|
@ -123,7 +123,7 @@ class ApplicationController < ActionController::Base
|
||||||
respond_with_error(410)
|
respond_with_error(410)
|
||||||
end
|
end
|
||||||
|
|
||||||
def unprocessable_content
|
def unprocessable_entity
|
||||||
respond_with_error(422)
|
respond_with_error(422)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -38,7 +38,8 @@ class Auth::OmniauthCallbacksController < Devise::OmniauthCallbacksController
|
||||||
private
|
private
|
||||||
|
|
||||||
def record_login_activity
|
def record_login_activity
|
||||||
@user.login_activities.create(
|
LoginActivity.create(
|
||||||
|
user: @user,
|
||||||
success: true,
|
success: true,
|
||||||
authentication_method: :omniauth,
|
authentication_method: :omniauth,
|
||||||
provider: @provider,
|
provider: @provider,
|
||||||
|
|
|
@ -19,7 +19,8 @@ class Auth::PasswordsController < Devise::PasswordsController
|
||||||
private
|
private
|
||||||
|
|
||||||
def redirect_invalid_reset_token
|
def redirect_invalid_reset_token
|
||||||
redirect_to new_password_path(resource_name), flash: { error: t('auth.invalid_reset_password_token') }
|
flash[:error] = I18n.t('auth.invalid_reset_password_token')
|
||||||
|
redirect_to new_password_path(resource_name)
|
||||||
end
|
end
|
||||||
|
|
||||||
def reset_password_token_is_valid?
|
def reset_password_token_is_valid?
|
||||||
|
|
|
@ -12,8 +12,6 @@ class Auth::SessionsController < Devise::SessionsController
|
||||||
skip_before_action :require_functional!
|
skip_before_action :require_functional!
|
||||||
skip_before_action :update_user_sign_in
|
skip_before_action :update_user_sign_in
|
||||||
|
|
||||||
around_action :preserve_stored_location, only: :destroy, if: :continue_after?
|
|
||||||
|
|
||||||
prepend_before_action :check_suspicious!, only: [:create]
|
prepend_before_action :check_suspicious!, only: [:create]
|
||||||
|
|
||||||
include Auth::TwoFactorAuthenticationConcern
|
include Auth::TwoFactorAuthenticationConcern
|
||||||
|
@ -33,9 +31,11 @@ class Auth::SessionsController < Devise::SessionsController
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
|
tmp_stored_location = stored_location_for(:user)
|
||||||
super
|
super
|
||||||
session.delete(:challenge_passed_at)
|
session.delete(:challenge_passed_at)
|
||||||
flash.delete(:notice)
|
flash.delete(:notice)
|
||||||
|
store_location_for(:user, tmp_stored_location) if continue_after?
|
||||||
end
|
end
|
||||||
|
|
||||||
def webauthn_options
|
def webauthn_options
|
||||||
|
@ -96,12 +96,6 @@ class Auth::SessionsController < Devise::SessionsController
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def preserve_stored_location
|
|
||||||
original_stored_location = stored_location_for(:user)
|
|
||||||
yield
|
|
||||||
store_location_for(:user, original_stored_location)
|
|
||||||
end
|
|
||||||
|
|
||||||
def check_suspicious!
|
def check_suspicious!
|
||||||
user = find_user
|
user = find_user
|
||||||
@login_is_suspicious = suspicious_sign_in?(user) unless user.nil?
|
@login_is_suspicious = suspicious_sign_in?(user) unless user.nil?
|
||||||
|
@ -157,11 +151,12 @@ class Auth::SessionsController < Devise::SessionsController
|
||||||
sign_in(user)
|
sign_in(user)
|
||||||
flash.delete(:notice)
|
flash.delete(:notice)
|
||||||
|
|
||||||
user.login_activities.create(
|
LoginActivity.create(
|
||||||
request_details.merge(
|
user: user,
|
||||||
authentication_method: security_measure,
|
success: true,
|
||||||
success: true
|
authentication_method: security_measure,
|
||||||
)
|
ip: request.remote_ip,
|
||||||
|
user_agent: request.user_agent
|
||||||
)
|
)
|
||||||
|
|
||||||
UserMailer.suspicious_sign_in(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later! if @login_is_suspicious
|
UserMailer.suspicious_sign_in(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later! if @login_is_suspicious
|
||||||
|
@ -172,12 +167,13 @@ class Auth::SessionsController < Devise::SessionsController
|
||||||
end
|
end
|
||||||
|
|
||||||
def on_authentication_failure(user, security_measure, failure_reason)
|
def on_authentication_failure(user, security_measure, failure_reason)
|
||||||
user.login_activities.create(
|
LoginActivity.create(
|
||||||
request_details.merge(
|
user: user,
|
||||||
authentication_method: security_measure,
|
success: false,
|
||||||
failure_reason: failure_reason,
|
authentication_method: security_measure,
|
||||||
success: false
|
failure_reason: failure_reason,
|
||||||
)
|
ip: request.remote_ip,
|
||||||
|
user_agent: request.user_agent
|
||||||
)
|
)
|
||||||
|
|
||||||
# Only send a notification email every hour at most
|
# Only send a notification email every hour at most
|
||||||
|
@ -186,13 +182,6 @@ class Auth::SessionsController < Devise::SessionsController
|
||||||
UserMailer.failed_2fa(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later!
|
UserMailer.failed_2fa(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later!
|
||||||
end
|
end
|
||||||
|
|
||||||
def request_details
|
|
||||||
{
|
|
||||||
ip: request.remote_ip,
|
|
||||||
user_agent: request.user_agent,
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def second_factor_attempts_key(user)
|
def second_factor_attempts_key(user)
|
||||||
"2fa_auth_attempts:#{user.id}:#{Time.now.utc.hour}"
|
"2fa_auth_attempts:#{user.id}:#{Time.now.utc.hour}"
|
||||||
end
|
end
|
||||||
|
|
|
@ -5,18 +5,6 @@ module Auth::CaptchaConcern
|
||||||
|
|
||||||
include Hcaptcha::Adapters::ViewMethods
|
include Hcaptcha::Adapters::ViewMethods
|
||||||
|
|
||||||
CAPTCHA_DIRECTIVES = %w(
|
|
||||||
connect_src
|
|
||||||
frame_src
|
|
||||||
script_src
|
|
||||||
style_src
|
|
||||||
).freeze
|
|
||||||
|
|
||||||
CAPTCHA_SOURCES = %w(
|
|
||||||
https://*.hcaptcha.com
|
|
||||||
https://hcaptcha.com
|
|
||||||
).freeze
|
|
||||||
|
|
||||||
included do
|
included do
|
||||||
helper_method :render_captcha
|
helper_method :render_captcha
|
||||||
end
|
end
|
||||||
|
@ -54,9 +42,20 @@ module Auth::CaptchaConcern
|
||||||
end
|
end
|
||||||
|
|
||||||
def extend_csp_for_captcha!
|
def extend_csp_for_captcha!
|
||||||
return unless captcha_required? && request.content_security_policy.present?
|
policy = request.content_security_policy&.clone
|
||||||
|
|
||||||
request.content_security_policy = captcha_adjusted_policy
|
return unless captcha_required? && policy.present?
|
||||||
|
|
||||||
|
%w(script_src frame_src style_src connect_src).each do |directive|
|
||||||
|
values = policy.send(directive)
|
||||||
|
|
||||||
|
values << 'https://hcaptcha.com' unless values.include?('https://hcaptcha.com') || values.include?('https:')
|
||||||
|
values << 'https://*.hcaptcha.com' unless values.include?('https://*.hcaptcha.com') || values.include?('https:')
|
||||||
|
|
||||||
|
policy.send(directive, *values)
|
||||||
|
end
|
||||||
|
|
||||||
|
request.content_security_policy = policy
|
||||||
end
|
end
|
||||||
|
|
||||||
def render_captcha
|
def render_captcha
|
||||||
|
@ -64,24 +63,4 @@ module Auth::CaptchaConcern
|
||||||
|
|
||||||
hcaptcha_tags
|
hcaptcha_tags
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def captcha_adjusted_policy
|
|
||||||
request.content_security_policy.clone.tap do |policy|
|
|
||||||
populate_captcha_policy(policy)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def populate_captcha_policy(policy)
|
|
||||||
CAPTCHA_DIRECTIVES.each do |directive|
|
|
||||||
values = policy.send(directive)
|
|
||||||
|
|
||||||
CAPTCHA_SOURCES.each do |source|
|
|
||||||
values << source unless values.include?(source) || values.include?('https:')
|
|
||||||
end
|
|
||||||
|
|
||||||
policy.send(directive, *values)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
@ -5,6 +5,6 @@ class Settings::LoginActivitiesController < Settings::BaseController
|
||||||
skip_before_action :require_functional!
|
skip_before_action :require_functional!
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@login_activities = current_user.login_activities.order(id: :desc).page(params[:page])
|
@login_activities = LoginActivity.where(user: current_user).order(id: :desc).page(params[:page])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -22,7 +22,7 @@ class Settings::Migration::RedirectsController < Settings::BaseController
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
if current_account.moved?
|
if current_account.moved_to_account_id.present?
|
||||||
current_account.update!(moved_to_account: nil)
|
current_account.update!(moved_to_account: nil)
|
||||||
ActivityPub::UpdateDistributionWorker.perform_async(current_account.id)
|
ActivityPub::UpdateDistributionWorker.perform_async(current_account.id)
|
||||||
end
|
end
|
||||||
|
|
|
@ -8,7 +8,8 @@ class Settings::SessionsController < Settings::BaseController
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
@session.destroy!
|
@session.destroy!
|
||||||
redirect_to edit_user_registration_path, notice: t('sessions.revoke_success')
|
flash[:notice] = I18n.t('sessions.revoke_success')
|
||||||
|
redirect_to edit_user_registration_path
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
|
@ -52,7 +52,7 @@ module Settings
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
flash[:error] = I18n.t('webauthn_credentials.create.error')
|
flash[:error] = I18n.t('webauthn_credentials.create.error')
|
||||||
status = :unprocessable_content
|
status = :unprocessable_entity
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
flash[:error] = t('webauthn_credentials.create.error')
|
flash[:error] = t('webauthn_credentials.create.error')
|
||||||
|
@ -86,11 +86,13 @@ module Settings
|
||||||
private
|
private
|
||||||
|
|
||||||
def redirect_invalid_otp
|
def redirect_invalid_otp
|
||||||
redirect_to settings_two_factor_authentication_methods_path, flash: { error: t('webauthn_credentials.otp_required') }
|
flash[:error] = t('webauthn_credentials.otp_required')
|
||||||
|
redirect_to settings_two_factor_authentication_methods_path
|
||||||
end
|
end
|
||||||
|
|
||||||
def redirect_invalid_webauthn
|
def redirect_invalid_webauthn
|
||||||
redirect_to settings_two_factor_authentication_methods_path, flash: { error: t('webauthn_credentials.not_enabled') }
|
flash[:error] = t('webauthn_credentials.not_enabled')
|
||||||
|
redirect_to settings_two_factor_authentication_methods_path
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -11,7 +11,6 @@ class StatusesController < ApplicationController
|
||||||
before_action :require_account_signature!, only: [:show, :activity], if: -> { request.format == :json && authorized_fetch_mode? }
|
before_action :require_account_signature!, only: [:show, :activity], if: -> { request.format == :json && authorized_fetch_mode? }
|
||||||
before_action :set_status
|
before_action :set_status
|
||||||
before_action :redirect_to_original, only: :show
|
before_action :redirect_to_original, only: :show
|
||||||
before_action :verify_embed_allowed, only: :embed
|
|
||||||
|
|
||||||
after_action :set_link_headers
|
after_action :set_link_headers
|
||||||
|
|
||||||
|
@ -41,6 +40,8 @@ class StatusesController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def embed
|
def embed
|
||||||
|
return not_found if @status.hidden? || @status.reblog?
|
||||||
|
|
||||||
expires_in 180, public: true
|
expires_in 180, public: true
|
||||||
response.headers.delete('X-Frame-Options')
|
response.headers.delete('X-Frame-Options')
|
||||||
|
|
||||||
|
@ -49,10 +50,6 @@ class StatusesController < ApplicationController
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def verify_embed_allowed
|
|
||||||
not_found if @status.hidden? || @status.reblog?
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_link_headers
|
def set_link_headers
|
||||||
response.headers['Link'] = LinkHeader.new(
|
response.headers['Link'] = LinkHeader.new(
|
||||||
[[ActivityPub::TagManager.instance.uri_for(@status), [%w(rel alternate), %w(type application/activity+json)]]]
|
[[ActivityPub::TagManager.instance.uri_for(@status), [%w(rel alternate), %w(type application/activity+json)]]]
|
||||||
|
|
|
@ -13,8 +13,6 @@ module Admin::ActionLogsHelper
|
||||||
end
|
end
|
||||||
when 'UserRole'
|
when 'UserRole'
|
||||||
link_to log.human_identifier, admin_roles_path(log.target_id)
|
link_to log.human_identifier, admin_roles_path(log.target_id)
|
||||||
when 'UsernameBlock'
|
|
||||||
link_to log.human_identifier, edit_admin_username_block_path(log.target_id)
|
|
||||||
when 'Report'
|
when 'Report'
|
||||||
link_to "##{log.human_identifier.presence || log.target_id}", admin_report_path(log.target_id)
|
link_to "##{log.human_identifier.presence || log.target_id}", admin_report_path(log.target_id)
|
||||||
when 'Instance', 'DomainBlock', 'DomainAllow', 'UnavailableDomain'
|
when 'Instance', 'DomainBlock', 'DomainAllow', 'UnavailableDomain'
|
||||||
|
|
|
@ -39,12 +39,6 @@ module ContextHelper
|
||||||
'automaticApproval' => { '@id' => 'gts:automaticApproval', '@type' => '@id' },
|
'automaticApproval' => { '@id' => 'gts:automaticApproval', '@type' => '@id' },
|
||||||
'manualApproval' => { '@id' => 'gts:manualApproval', '@type' => '@id' },
|
'manualApproval' => { '@id' => 'gts:manualApproval', '@type' => '@id' },
|
||||||
},
|
},
|
||||||
quote_authorizations: {
|
|
||||||
'gts' => 'https://gotosocial.org/ns#',
|
|
||||||
'quoteAuthorization' => { '@id' => 'https://w3id.org/fep/044f#quoteAuthorization', '@type' => '@id' },
|
|
||||||
'interactingObject' => { '@id' => 'gts:interactingObject' },
|
|
||||||
'interactionTarget' => { '@id' => 'gts:interactionTarget' },
|
|
||||||
},
|
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
def full_context
|
def full_context
|
||||||
|
|
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,12 +65,12 @@ module FormattingHelper
|
||||||
end
|
end
|
||||||
|
|
||||||
def rss_content_preroll(status)
|
def rss_content_preroll(status)
|
||||||
return unless status.spoiler_text?
|
if status.spoiler_text?
|
||||||
|
safe_join [
|
||||||
safe_join [
|
tag.p { spoiler_with_warning(status) },
|
||||||
tag.p { spoiler_with_warning(status) },
|
tag.hr,
|
||||||
tag.hr,
|
]
|
||||||
]
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def spoiler_with_warning(status)
|
def spoiler_with_warning(status)
|
||||||
|
@ -81,10 +81,10 @@ module FormattingHelper
|
||||||
end
|
end
|
||||||
|
|
||||||
def rss_content_postroll(status)
|
def rss_content_postroll(status)
|
||||||
return unless status.preloadable_poll
|
if status.preloadable_poll
|
||||||
|
tag.p do
|
||||||
tag.p do
|
poll_option_tags(status)
|
||||||
poll_option_tags(status)
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -39,8 +39,18 @@ module HomeHelper
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def field_verified_class(verified)
|
def obscured_counter(count)
|
||||||
if verified
|
if count <= 0
|
||||||
|
'0'
|
||||||
|
elsif count == 1
|
||||||
|
'1'
|
||||||
|
else
|
||||||
|
'1+'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def custom_field_classes(field)
|
||||||
|
if field.verified?
|
||||||
'verified'
|
'verified'
|
||||||
else
|
else
|
||||||
'emojify'
|
'emojify'
|
||||||
|
|
|
@ -134,7 +134,7 @@ module JsonLdHelper
|
||||||
patch_for_forwarding!(value, compacted_value)
|
patch_for_forwarding!(value, compacted_value)
|
||||||
elsif value.is_a?(Array)
|
elsif value.is_a?(Array)
|
||||||
compacted_value = [compacted_value] unless compacted_value.is_a?(Array)
|
compacted_value = [compacted_value] unless compacted_value.is_a?(Array)
|
||||||
return nil if value.size != compacted_value.size
|
return if value.size != compacted_value.size
|
||||||
|
|
||||||
compacted[key] = value.zip(compacted_value).map do |v, vc|
|
compacted[key] = value.zip(compacted_value).map do |v, vc|
|
||||||
if v.is_a?(Hash) && vc.is_a?(Hash)
|
if v.is_a?(Hash) && vc.is_a?(Hash)
|
||||||
|
|
|
@ -24,24 +24,24 @@ module ThemeHelper
|
||||||
end
|
end
|
||||||
|
|
||||||
def custom_stylesheet
|
def custom_stylesheet
|
||||||
return if active_custom_stylesheet.blank?
|
if active_custom_stylesheet.present?
|
||||||
|
stylesheet_link_tag(
|
||||||
stylesheet_link_tag(
|
custom_css_path(active_custom_stylesheet),
|
||||||
custom_css_path(active_custom_stylesheet),
|
host: root_url,
|
||||||
host: root_url,
|
media: :all,
|
||||||
media: :all,
|
skip_pipeline: true
|
||||||
skip_pipeline: true
|
)
|
||||||
)
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def active_custom_stylesheet
|
def active_custom_stylesheet
|
||||||
return if cached_custom_css_digest.blank?
|
if cached_custom_css_digest.present?
|
||||||
|
[:custom, cached_custom_css_digest.to_s.first(8)]
|
||||||
[:custom, cached_custom_css_digest.to_s.first(8)]
|
.compact_blank
|
||||||
.compact_blank
|
.join('-')
|
||||||
.join('-')
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def cached_custom_css_digest
|
def cached_custom_css_digest
|
||||||
|
|
|
@ -1,3 +1 @@
|
||||||
Images in this folder are based on [Tabler.io icons](https://tabler.io/icons).
|
Images in this folder are based on [Tabler.io icons](https://tabler.io/icons).
|
||||||
|
|
||||||
Seems to be 1.5 width icons scaled to 64×64px and centered above a blue square with round corners (24px).
|
|
||||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 1.8 KiB |
|
@ -228,8 +228,6 @@ export function submitCompose() {
|
||||||
visibility: getState().getIn(['compose', 'privacy']),
|
visibility: getState().getIn(['compose', 'privacy']),
|
||||||
poll: getState().getIn(['compose', 'poll'], null),
|
poll: getState().getIn(['compose', 'poll'], null),
|
||||||
language: getState().getIn(['compose', 'language']),
|
language: getState().getIn(['compose', 'language']),
|
||||||
quoted_status_id: getState().getIn(['compose', 'quoted_status_id']),
|
|
||||||
quote_approval_policy: getState().getIn(['compose', 'quote_policy']),
|
|
||||||
},
|
},
|
||||||
headers: {
|
headers: {
|
||||||
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
|
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
|
||||||
|
|
|
@ -1,18 +1,9 @@
|
||||||
import { createAction } from '@reduxjs/toolkit';
|
|
||||||
import type { List as ImmutableList, Map as ImmutableMap } from 'immutable';
|
import type { List as ImmutableList, Map as ImmutableMap } from 'immutable';
|
||||||
|
|
||||||
import { apiUpdateMedia } from 'mastodon/api/compose';
|
import { apiUpdateMedia } from 'mastodon/api/compose';
|
||||||
import type { ApiMediaAttachmentJSON } from 'mastodon/api_types/media_attachments';
|
import type { ApiMediaAttachmentJSON } from 'mastodon/api_types/media_attachments';
|
||||||
import type { MediaAttachment } from 'mastodon/models/media_attachment';
|
import type { MediaAttachment } from 'mastodon/models/media_attachment';
|
||||||
import {
|
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
||||||
createDataLoadingThunk,
|
|
||||||
createAppThunk,
|
|
||||||
} from 'mastodon/store/typed_functions';
|
|
||||||
|
|
||||||
import type { ApiQuotePolicy } from '../api_types/quotes';
|
|
||||||
import type { Status } from '../models/status';
|
|
||||||
|
|
||||||
import { ensureComposeIsVisible } from './compose';
|
|
||||||
|
|
||||||
type SimulatedMediaAttachmentJSON = ApiMediaAttachmentJSON & {
|
type SimulatedMediaAttachmentJSON = ApiMediaAttachmentJSON & {
|
||||||
unattached?: boolean;
|
unattached?: boolean;
|
||||||
|
@ -77,26 +68,3 @@ export const changeUploadCompose = createDataLoadingThunk(
|
||||||
useLoadingBar: false,
|
useLoadingBar: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export const quoteComposeByStatus = createAppThunk(
|
|
||||||
'compose/quoteComposeStatus',
|
|
||||||
(status: Status, { getState }) => {
|
|
||||||
ensureComposeIsVisible(getState);
|
|
||||||
return status;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const quoteComposeById = createAppThunk(
|
|
||||||
(statusId: string, { dispatch, getState }) => {
|
|
||||||
const status = getState().statuses.get(statusId);
|
|
||||||
if (status) {
|
|
||||||
dispatch(quoteComposeByStatus(status));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const quoteComposeCancel = createAction('compose/quoteComposeCancel');
|
|
||||||
|
|
||||||
export const setQuotePolicy = createAction<ApiQuotePolicy>(
|
|
||||||
'compose/setQuotePolicy',
|
|
||||||
);
|
|
||||||
|
|
|
@ -1,8 +1,4 @@
|
||||||
import {
|
import { apiReblog, apiUnreblog } from 'mastodon/api/interactions';
|
||||||
apiReblog,
|
|
||||||
apiUnreblog,
|
|
||||||
apiRevokeQuote,
|
|
||||||
} from 'mastodon/api/interactions';
|
|
||||||
import type { StatusVisibility } from 'mastodon/models/status';
|
import type { StatusVisibility } from 'mastodon/models/status';
|
||||||
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
||||||
|
|
||||||
|
@ -37,19 +33,3 @@ export const unreblog = createDataLoadingThunk(
|
||||||
return discardLoadData;
|
return discardLoadData;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export const revokeQuote = createDataLoadingThunk(
|
|
||||||
'status/revoke_quote',
|
|
||||||
({
|
|
||||||
statusId,
|
|
||||||
quotedStatusId,
|
|
||||||
}: {
|
|
||||||
statusId: string;
|
|
||||||
quotedStatusId: string;
|
|
||||||
}) => apiRevokeQuote(quotedStatusId, statusId),
|
|
||||||
(data, { dispatch, discardLoadData }) => {
|
|
||||||
dispatch(importFetchedStatus(data));
|
|
||||||
|
|
||||||
return discardLoadData;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
|
@ -31,9 +31,7 @@ import { NOTIFICATIONS_FILTER_SET } from './notifications';
|
||||||
import { saveSettings } from './settings';
|
import { saveSettings } from './settings';
|
||||||
|
|
||||||
function excludeAllTypesExcept(filter: string) {
|
function excludeAllTypesExcept(filter: string) {
|
||||||
return allNotificationTypes.filter(
|
return allNotificationTypes.filter((item) => item !== filter);
|
||||||
(item) => item !== filter && !(item === 'quote' && filter === 'mention'),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getExcludedTypes(state: RootState) {
|
function getExcludedTypes(state: RootState) {
|
||||||
|
@ -158,15 +156,12 @@ export const processNewNotificationForGroups = createAppAsyncThunk(
|
||||||
const showInColumn =
|
const showInColumn =
|
||||||
activeFilter === 'all'
|
activeFilter === 'all'
|
||||||
? notificationShows[notification.type] !== false
|
? notificationShows[notification.type] !== false
|
||||||
: activeFilter === notification.type ||
|
: activeFilter === notification.type;
|
||||||
(activeFilter === 'mention' && notification.type === 'quote');
|
|
||||||
|
|
||||||
if (!showInColumn) return;
|
if (!showInColumn) return;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(notification.type === 'mention' ||
|
(notification.type === 'mention' || notification.type === 'update') &&
|
||||||
notification.type === 'update' ||
|
|
||||||
notification.type === 'quote') &&
|
|
||||||
notification.status?.filtered
|
notification.status?.filtered
|
||||||
) {
|
) {
|
||||||
const filters = notification.status.filtered.filter((result) =>
|
const filters = notification.status.filtered.filter((result) =>
|
||||||
|
|
|
@ -31,7 +31,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
|
||||||
|
|
||||||
let filtered = false;
|
let filtered = false;
|
||||||
|
|
||||||
if (['mention', 'status', 'quote'].includes(notification.type) && notification.status.filtered) {
|
if (['mention', 'status'].includes(notification.type) && notification.status.filtered) {
|
||||||
const filters = notification.status.filtered.filter(result => result.filter.context.includes('notifications'));
|
const filters = notification.status.filtered.filter(result => result.filter.context.includes('notifications'));
|
||||||
|
|
||||||
if (filters.some(result => result.filter.filter_action === 'hide')) {
|
if (filters.some(result => result.filter.filter_action === 'hide')) {
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
import { createAction } from '@reduxjs/toolkit';
|
|
||||||
|
|
||||||
import { apiGetContext } from 'mastodon/api/statuses';
|
import { apiGetContext } from 'mastodon/api/statuses';
|
||||||
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
||||||
|
|
||||||
|
@ -8,18 +6,13 @@ import { importFetchedStatuses } from './importer';
|
||||||
export const fetchContext = createDataLoadingThunk(
|
export const fetchContext = createDataLoadingThunk(
|
||||||
'status/context',
|
'status/context',
|
||||||
({ statusId }: { statusId: string }) => apiGetContext(statusId),
|
({ statusId }: { statusId: string }) => apiGetContext(statusId),
|
||||||
({ context, refresh }, { dispatch }) => {
|
(context, { dispatch }) => {
|
||||||
const statuses = context.ancestors.concat(context.descendants);
|
const statuses = context.ancestors.concat(context.descendants);
|
||||||
|
|
||||||
dispatch(importFetchedStatuses(statuses));
|
dispatch(importFetchedStatuses(statuses));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
context,
|
context,
|
||||||
refresh,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export const completeContextRefresh = createAction<{ statusId: string }>(
|
|
||||||
'status/context/complete',
|
|
||||||
);
|
|
||||||
|
|
|
@ -20,50 +20,6 @@ export const getLinks = (response: AxiosResponse) => {
|
||||||
return LinkHeader.parse(value);
|
return LinkHeader.parse(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface AsyncRefreshHeader {
|
|
||||||
id: string;
|
|
||||||
retry: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isAsyncRefreshHeader = (obj: object): obj is AsyncRefreshHeader =>
|
|
||||||
'id' in obj && 'retry' in obj;
|
|
||||||
|
|
||||||
export const getAsyncRefreshHeader = (
|
|
||||||
response: AxiosResponse,
|
|
||||||
): AsyncRefreshHeader | null => {
|
|
||||||
const value = response.headers['mastodon-async-refresh'] as
|
|
||||||
| string
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
if (!value) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const asyncRefreshHeader: Record<string, unknown> = {};
|
|
||||||
|
|
||||||
value.split(/,\s*/).forEach((pair) => {
|
|
||||||
const [key, val] = pair.split('=', 2);
|
|
||||||
|
|
||||||
let typedValue: string | number;
|
|
||||||
|
|
||||||
if (key && ['id', 'retry'].includes(key) && val) {
|
|
||||||
if (val.startsWith('"')) {
|
|
||||||
typedValue = val.slice(1, -1);
|
|
||||||
} else {
|
|
||||||
typedValue = parseInt(val);
|
|
||||||
}
|
|
||||||
|
|
||||||
asyncRefreshHeader[key] = typedValue;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isAsyncRefreshHeader(asyncRefreshHeader)) {
|
|
||||||
return asyncRefreshHeader;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const csrfHeader: RawAxiosRequestHeaders = {};
|
const csrfHeader: RawAxiosRequestHeaders = {};
|
||||||
|
|
||||||
const setCSRFHeader = () => {
|
const setCSRFHeader = () => {
|
||||||
|
@ -127,7 +83,7 @@ export default function api(withAuthorization = true) {
|
||||||
return instance;
|
return instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ApiUrl = `v${1 | '1_alpha' | 2}/${string}`;
|
type ApiUrl = `v${1 | 2}/${string}`;
|
||||||
type RequestParamsOrData = Record<string, unknown>;
|
type RequestParamsOrData = Record<string, unknown>;
|
||||||
|
|
||||||
export async function apiRequest<ApiResponse = unknown>(
|
export async function apiRequest<ApiResponse = unknown>(
|
||||||
|
|
|
@ -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) =>
|
export const apiUnreblog = (statusId: string) =>
|
||||||
apiRequestPost<Status>(`v1/statuses/${statusId}/unreblog`);
|
apiRequestPost<Status>(`v1/statuses/${statusId}/unreblog`);
|
||||||
|
|
||||||
export const apiRevokeQuote = (quotedStatusId: string, statusId: string) =>
|
|
||||||
apiRequestPost<Status>(
|
|
||||||
`v1/statuses/${quotedStatusId}/quotes/${statusId}/revoke`,
|
|
||||||
);
|
|
||||||
|
|
|
@ -1,14 +1,5 @@
|
||||||
import api, { getAsyncRefreshHeader } from 'mastodon/api';
|
import { apiRequestGet } from 'mastodon/api';
|
||||||
import type { ApiContextJSON } from 'mastodon/api_types/statuses';
|
import type { ApiContextJSON } from 'mastodon/api_types/statuses';
|
||||||
|
|
||||||
export const apiGetContext = async (statusId: string) => {
|
export const apiGetContext = (statusId: string) =>
|
||||||
const response = await api().request<ApiContextJSON>({
|
apiRequestGet<ApiContextJSON>(`v1/statuses/${statusId}/context`);
|
||||||
method: 'GET',
|
|
||||||
url: `/api/v1/statuses/${statusId}/context`,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
context: response.data,
|
|
||||||
refresh: getAsyncRefreshHeader(response),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
|
@ -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',
|
'favourite',
|
||||||
'reblog',
|
'reblog',
|
||||||
'mention',
|
'mention',
|
||||||
'quote',
|
|
||||||
'poll',
|
'poll',
|
||||||
'status',
|
'status',
|
||||||
'update',
|
'update',
|
||||||
|
@ -29,7 +28,6 @@ export type NotificationWithStatusType =
|
||||||
| 'reblog'
|
| 'reblog'
|
||||||
| 'status'
|
| 'status'
|
||||||
| 'mention'
|
| 'mention'
|
||||||
| 'quote'
|
|
||||||
| 'poll'
|
| 'poll'
|
||||||
| 'update';
|
| 'update';
|
||||||
|
|
||||||
|
|
|
@ -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 { ApiCustomEmojiJSON } from './custom_emoji';
|
||||||
import type { ApiMediaAttachmentJSON } from './media_attachments';
|
import type { ApiMediaAttachmentJSON } from './media_attachments';
|
||||||
import type { ApiPollJSON } from './polls';
|
import type { ApiPollJSON } from './polls';
|
||||||
import type { ApiQuoteJSON } from './quotes';
|
|
||||||
|
|
||||||
// See app/modals/status.rb
|
// See app/modals/status.rb
|
||||||
export type StatusVisibility =
|
export type StatusVisibility =
|
||||||
|
@ -119,7 +118,6 @@ export interface ApiStatusJSON {
|
||||||
|
|
||||||
card?: ApiPreviewCardJSON;
|
card?: ApiPreviewCardJSON;
|
||||||
poll?: ApiPollJSON;
|
poll?: ApiPollJSON;
|
||||||
quote?: ApiQuoteJSON;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiContextJSON {
|
export interface ApiContextJSON {
|
||||||
|
|
|
@ -2,42 +2,27 @@ import { useCallback } from 'react';
|
||||||
|
|
||||||
import { useLinks } from 'mastodon/hooks/useLinks';
|
import { useLinks } from 'mastodon/hooks/useLinks';
|
||||||
|
|
||||||
import { EmojiHTML } from '../features/emoji/emoji_html';
|
|
||||||
import { useAppSelector } from '../store';
|
|
||||||
import { isModernEmojiEnabled } from '../utils/environment';
|
|
||||||
|
|
||||||
interface AccountBioProps {
|
interface AccountBioProps {
|
||||||
|
note: string;
|
||||||
className: string;
|
className: string;
|
||||||
accountId: string;
|
dropdownAccountId?: string;
|
||||||
showDropdown?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AccountBio: React.FC<AccountBioProps> = ({
|
export const AccountBio: React.FC<AccountBioProps> = ({
|
||||||
|
note,
|
||||||
className,
|
className,
|
||||||
accountId,
|
dropdownAccountId,
|
||||||
showDropdown = false,
|
|
||||||
}) => {
|
}) => {
|
||||||
const handleClick = useLinks(showDropdown);
|
const handleClick = useLinks(!!dropdownAccountId);
|
||||||
const handleNodeChange = useCallback(
|
const handleNodeChange = useCallback(
|
||||||
(node: HTMLDivElement | null) => {
|
(node: HTMLDivElement | null) => {
|
||||||
if (!showDropdown || !node || node.childNodes.length === 0) {
|
if (!dropdownAccountId || !node || node.childNodes.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
addDropdownToHashtags(node, accountId);
|
addDropdownToHashtags(node, dropdownAccountId);
|
||||||
},
|
},
|
||||||
[showDropdown, accountId],
|
[dropdownAccountId],
|
||||||
);
|
);
|
||||||
const note = useAppSelector((state) => {
|
|
||||||
const account = state.accounts.get(accountId);
|
|
||||||
if (!account) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
return isModernEmojiEnabled() ? account.note : account.note_emojified;
|
|
||||||
});
|
|
||||||
const extraEmojis = useAppSelector((state) => {
|
|
||||||
const account = state.accounts.get(accountId);
|
|
||||||
return account?.emojis;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (note.length === 0) {
|
if (note.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
|
@ -46,11 +31,10 @@ export const AccountBio: React.FC<AccountBioProps> = ({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`${className} translate`}
|
className={`${className} translate`}
|
||||||
|
dangerouslySetInnerHTML={{ __html: note }}
|
||||||
onClickCapture={handleClick}
|
onClickCapture={handleClick}
|
||||||
ref={handleNodeChange}
|
ref={handleNodeChange}
|
||||||
>
|
/>
|
||||||
<EmojiHTML htmlString={note} extraEmojis={extraEmojis} />
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -37,6 +37,7 @@ export const GIFV = forwardRef<HTMLVideoElement, Props>(
|
||||||
role='button'
|
role='button'
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label={alt}
|
aria-label={alt}
|
||||||
|
title={alt}
|
||||||
lang={lang}
|
lang={lang}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
/>
|
/>
|
||||||
|
@ -48,6 +49,7 @@ export const GIFV = forwardRef<HTMLVideoElement, Props>(
|
||||||
role='button'
|
role='button'
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label={alt}
|
aria-label={alt}
|
||||||
|
title={alt}
|
||||||
lang={lang}
|
lang={lang}
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
|
|
|
@ -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'>
|
<div className='hover-card__text-row'>
|
||||||
<AccountBio
|
<AccountBio
|
||||||
accountId={account.id}
|
note={account.note_emojified}
|
||||||
className='hover-card__bio'
|
className='hover-card__bio'
|
||||||
/>
|
/>
|
||||||
<AccountFields fields={account.fields} limit={2} />
|
<AccountFields fields={account.fields} limit={2} />
|
||||||
|
|
|
@ -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 ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
|
|
||||||
|
import { HotKeys } from 'react-hotkeys';
|
||||||
|
|
||||||
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
||||||
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
|
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
|
||||||
import { Hotkeys } from 'mastodon/components/hotkeys';
|
|
||||||
import { ContentWarning } from 'mastodon/components/content_warning';
|
import { ContentWarning } from 'mastodon/components/content_warning';
|
||||||
import { FilterWarning } from 'mastodon/components/filter_warning';
|
import { FilterWarning } from 'mastodon/components/filter_warning';
|
||||||
import { Icon } from 'mastodon/components/icon';
|
import { Icon } from 'mastodon/components/icon';
|
||||||
|
@ -34,6 +35,7 @@ import StatusActionBar from './status_action_bar';
|
||||||
import StatusContent from './status_content';
|
import StatusContent from './status_content';
|
||||||
import { StatusThreadLabel } from './status_thread_label';
|
import { StatusThreadLabel } from './status_thread_label';
|
||||||
import { VisibilityIcon } from './visibility_icon';
|
import { VisibilityIcon } from './visibility_icon';
|
||||||
|
|
||||||
const domParser = new DOMParser();
|
const domParser = new DOMParser();
|
||||||
|
|
||||||
export const textForScreenReader = (intl, status, rebloggedByText = false) => {
|
export const textForScreenReader = (intl, status, rebloggedByText = false) => {
|
||||||
|
@ -323,11 +325,11 @@ class Status extends ImmutablePureComponent {
|
||||||
};
|
};
|
||||||
|
|
||||||
handleHotkeyMoveUp = e => {
|
handleHotkeyMoveUp = e => {
|
||||||
this.props.onMoveUp?.(this.props.status.get('id'), this.node.getAttribute('data-featured'));
|
this.props.onMoveUp(this.props.status.get('id'), e.target.getAttribute('data-featured'));
|
||||||
};
|
};
|
||||||
|
|
||||||
handleHotkeyMoveDown = e => {
|
handleHotkeyMoveDown = e => {
|
||||||
this.props.onMoveDown?.(this.props.status.get('id'), this.node.getAttribute('data-featured'));
|
this.props.onMoveDown(this.props.status.get('id'), e.target.getAttribute('data-featured'));
|
||||||
};
|
};
|
||||||
|
|
||||||
handleHotkeyToggleHidden = () => {
|
handleHotkeyToggleHidden = () => {
|
||||||
|
@ -435,13 +437,13 @@ class Status extends ImmutablePureComponent {
|
||||||
|
|
||||||
if (hidden) {
|
if (hidden) {
|
||||||
return (
|
return (
|
||||||
<Hotkeys handlers={handlers} focusable={!unfocusable}>
|
<HotKeys handlers={handlers} tabIndex={unfocusable ? null : -1}>
|
||||||
<div ref={this.handleRef} className={classNames('status__wrapper', { focusable: !this.props.muted })} tabIndex={unfocusable ? null : 0}>
|
<div ref={this.handleRef} className={classNames('status__wrapper', { focusable: !this.props.muted })} tabIndex={unfocusable ? null : 0}>
|
||||||
<span>{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}</span>
|
<span>{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}</span>
|
||||||
{status.get('spoiler_text').length > 0 && (<span>{status.get('spoiler_text')}</span>)}
|
{status.get('spoiler_text').length > 0 && (<span>{status.get('spoiler_text')}</span>)}
|
||||||
{expanded && <span>{status.get('content')}</span>}
|
{expanded && <span>{status.get('content')}</span>}
|
||||||
</div>
|
</div>
|
||||||
</Hotkeys>
|
</HotKeys>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -541,7 +543,7 @@ class Status extends ImmutablePureComponent {
|
||||||
const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status);
|
const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Hotkeys handlers={handlers} focusable={!unfocusable}>
|
<HotKeys handlers={handlers} tabIndex={unfocusable ? null : -1}>
|
||||||
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted || unfocusable ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef} data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined}>
|
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted || unfocusable ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef} data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined}>
|
||||||
{!skipPrepend && prepend}
|
{!skipPrepend && prepend}
|
||||||
|
|
||||||
|
@ -602,7 +604,7 @@ class Status extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Hotkeys>
|
</HotKeys>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -67,28 +67,21 @@ const messages = defineMessages({
|
||||||
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
|
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
|
||||||
filter: { id: 'status.filter', defaultMessage: 'Filter this post' },
|
filter: { id: 'status.filter', defaultMessage: 'Filter this post' },
|
||||||
openOriginalPage: { id: 'account.open_original_page', defaultMessage: 'Open original page' },
|
openOriginalPage: { id: 'account.open_original_page', defaultMessage: 'Open original page' },
|
||||||
revokeQuote: { id: 'status.revoke_quote', defaultMessage: 'Remove my post from @{name}’s post' },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapStateToProps = (state, { status }) => {
|
const mapStateToProps = (state, { status }) => ({
|
||||||
const quotedStatusId = status.getIn(['quote', 'quoted_status']);
|
relationship: state.getIn(['relationships', status.getIn(['account', 'id'])]),
|
||||||
return ({
|
});
|
||||||
relationship: state.getIn(['relationships', status.getIn(['account', 'id'])]),
|
|
||||||
quotedAccountId: quotedStatusId ? state.getIn(['statuses', quotedStatusId, 'account']) : null,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
class StatusActionBar extends ImmutablePureComponent {
|
class StatusActionBar extends ImmutablePureComponent {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
identity: identityContextPropShape,
|
identity: identityContextPropShape,
|
||||||
status: ImmutablePropTypes.map.isRequired,
|
status: ImmutablePropTypes.map.isRequired,
|
||||||
relationship: ImmutablePropTypes.record,
|
relationship: ImmutablePropTypes.record,
|
||||||
quotedAccountId: ImmutablePropTypes.string,
|
|
||||||
onReply: PropTypes.func,
|
onReply: PropTypes.func,
|
||||||
onFavourite: PropTypes.func,
|
onFavourite: PropTypes.func,
|
||||||
onReblog: PropTypes.func,
|
onReblog: PropTypes.func,
|
||||||
onDelete: PropTypes.func,
|
onDelete: PropTypes.func,
|
||||||
onRevokeQuote: PropTypes.func,
|
|
||||||
onDirect: PropTypes.func,
|
onDirect: PropTypes.func,
|
||||||
onMention: PropTypes.func,
|
onMention: PropTypes.func,
|
||||||
onMute: PropTypes.func,
|
onMute: PropTypes.func,
|
||||||
|
@ -117,7 +110,6 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||||
updateOnProps = [
|
updateOnProps = [
|
||||||
'status',
|
'status',
|
||||||
'relationship',
|
'relationship',
|
||||||
'quotedAccountId',
|
|
||||||
'withDismiss',
|
'withDismiss',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -198,10 +190,6 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
handleRevokeQuoteClick = () => {
|
|
||||||
this.props.onRevokeQuote(this.props.status);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleBlockClick = () => {
|
handleBlockClick = () => {
|
||||||
const { status, relationship, onBlock, onUnblock } = this.props;
|
const { status, relationship, onBlock, onUnblock } = this.props;
|
||||||
const account = status.get('account');
|
const account = status.get('account');
|
||||||
|
@ -253,7 +241,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||||
};
|
};
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { status, relationship, quotedAccountId, intl, withDismiss, withCounters, scrollKey } = this.props;
|
const { status, relationship, intl, withDismiss, withCounters, scrollKey } = this.props;
|
||||||
const { signedIn, permissions } = this.props.identity;
|
const { signedIn, permissions } = this.props.identity;
|
||||||
|
|
||||||
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
|
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
|
||||||
|
@ -303,10 +291,6 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||||
menu.push({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.handleDirectClick });
|
menu.push({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.handleDirectClick });
|
||||||
menu.push(null);
|
menu.push(null);
|
||||||
|
|
||||||
if (quotedAccountId === me) {
|
|
||||||
menu.push({ text: intl.formatMessage(messages.revokeQuote, { name: account.get('username') }), action: this.handleRevokeQuoteClick, dangerous: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (relationship && relationship.get('muting')) {
|
if (relationship && relationship.get('muting')) {
|
||||||
menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.handleMuteClick });
|
menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.handleMuteClick });
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -14,8 +14,6 @@ import { Icon } from 'mastodon/components/icon';
|
||||||
import { Poll } from 'mastodon/components/poll';
|
import { Poll } from 'mastodon/components/poll';
|
||||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
||||||
import { autoPlayGif, languages as preloadedLanguages } from 'mastodon/initial_state';
|
import { autoPlayGif, languages as preloadedLanguages } from 'mastodon/initial_state';
|
||||||
import { EmojiHTML } from '../features/emoji/emoji_html';
|
|
||||||
import { isModernEmojiEnabled } from '../utils/environment';
|
|
||||||
|
|
||||||
const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
|
const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
|
||||||
|
|
||||||
|
@ -25,9 +23,6 @@ const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
export function getStatusContent(status) {
|
export function getStatusContent(status) {
|
||||||
if (isModernEmojiEnabled()) {
|
|
||||||
return status.getIn(['translation', 'content']) || status.get('content');
|
|
||||||
}
|
|
||||||
return status.getIn(['translation', 'contentHtml']) || status.get('contentHtml');
|
return status.getIn(['translation', 'contentHtml']) || status.get('contentHtml');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -48,13 +43,13 @@ class TranslateButton extends PureComponent {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='translate-button'>
|
<div className='translate-button'>
|
||||||
<button className='link-button' onClick={onClick}>
|
|
||||||
<FormattedMessage id='status.show_original' defaultMessage='Show original' />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className='translate-button__meta'>
|
<div className='translate-button__meta'>
|
||||||
<FormattedMessage id='status.translated_from_with' defaultMessage='Translated from {lang} using {provider}' values={{ lang: languageName, provider }} />
|
<FormattedMessage id='status.translated_from_with' defaultMessage='Translated from {lang} using {provider}' values={{ lang: languageName, provider }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button className='link-button' onClick={onClick}>
|
||||||
|
<FormattedMessage id='status.show_original' defaultMessage='Show original' />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -138,16 +133,6 @@ class StatusContent extends PureComponent {
|
||||||
|
|
||||||
onCollapsedToggle(collapsed);
|
onCollapsedToggle(collapsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove quote fallback link from the DOM so it doesn't
|
|
||||||
// mess with paragraph margins
|
|
||||||
if (!!status.get('quote')) {
|
|
||||||
const inlineQuote = node.querySelector('.quote-inline');
|
|
||||||
|
|
||||||
if (inlineQuote) {
|
|
||||||
inlineQuote.remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleMouseEnter = ({ currentTarget }) => {
|
handleMouseEnter = ({ currentTarget }) => {
|
||||||
|
@ -243,7 +228,7 @@ class StatusContent extends PureComponent {
|
||||||
const targetLanguages = this.props.languages?.get(status.get('language') || 'und');
|
const targetLanguages = this.props.languages?.get(status.get('language') || 'und');
|
||||||
const renderTranslate = this.props.onTranslate && this.props.identity.signedIn && ['public', 'unlisted'].includes(status.get('visibility')) && status.get('search_index').trim().length > 0 && targetLanguages?.includes(contentLocale);
|
const renderTranslate = this.props.onTranslate && this.props.identity.signedIn && ['public', 'unlisted'].includes(status.get('visibility')) && status.get('search_index').trim().length > 0 && targetLanguages?.includes(contentLocale);
|
||||||
|
|
||||||
const content = statusContent ?? getStatusContent(status);
|
const content = { __html: statusContent ?? getStatusContent(status) };
|
||||||
const language = status.getIn(['translation', 'language']) || status.get('language');
|
const language = status.getIn(['translation', 'language']) || status.get('language');
|
||||||
const classNames = classnames('status__content', {
|
const classNames = classnames('status__content', {
|
||||||
'status__content--with-action': this.props.onClick && this.props.history,
|
'status__content--with-action': this.props.onClick && this.props.history,
|
||||||
|
@ -268,12 +253,7 @@ class StatusContent extends PureComponent {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={classNames} ref={this.setRef} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
<div className={classNames} ref={this.setRef} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
||||||
<EmojiHTML
|
<div className='status__content__text status__content__text--visible translate' lang={language} dangerouslySetInnerHTML={content} />
|
||||||
className='status__content__text status__content__text--visible translate'
|
|
||||||
lang={language}
|
|
||||||
htmlString={content}
|
|
||||||
extraEmojis={status.get('emojis')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{poll}
|
{poll}
|
||||||
{translateButton}
|
{translateButton}
|
||||||
|
@ -285,12 +265,7 @@ class StatusContent extends PureComponent {
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<div className={classNames} ref={this.setRef} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
<div className={classNames} ref={this.setRef} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
||||||
<EmojiHTML
|
<div className='status__content__text status__content__text--visible translate' lang={language} dangerouslySetInnerHTML={content} />
|
||||||
className='status__content__text status__content__text--visible translate'
|
|
||||||
lang={language}
|
|
||||||
htmlString={content}
|
|
||||||
extraEmojis={status.get('emojis')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{poll}
|
{poll}
|
||||||
{translateButton}
|
{translateButton}
|
||||||
|
|
|
@ -40,14 +40,6 @@ export default class StatusList extends ImmutablePureComponent {
|
||||||
trackScroll: true,
|
trackScroll: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
this.columnHeaderHeight = this.node?.node
|
|
||||||
? parseFloat(
|
|
||||||
getComputedStyle(this.node.node).getPropertyValue('--column-header-height')
|
|
||||||
) || 0
|
|
||||||
: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
getFeaturedStatusCount = () => {
|
getFeaturedStatusCount = () => {
|
||||||
return this.props.featuredStatusIds ? this.props.featuredStatusIds.size : 0;
|
return this.props.featuredStatusIds ? this.props.featuredStatusIds.size : 0;
|
||||||
};
|
};
|
||||||
|
@ -61,68 +53,34 @@ export default class StatusList extends ImmutablePureComponent {
|
||||||
};
|
};
|
||||||
|
|
||||||
handleMoveUp = (id, featured) => {
|
handleMoveUp = (id, featured) => {
|
||||||
const index = this.getCurrentStatusIndex(id, featured);
|
const elementIndex = this.getCurrentStatusIndex(id, featured) - 1;
|
||||||
this._selectChild(id, index, -1);
|
this._selectChild(elementIndex, true);
|
||||||
};
|
};
|
||||||
|
|
||||||
handleMoveDown = (id, featured) => {
|
handleMoveDown = (id, featured) => {
|
||||||
const index = this.getCurrentStatusIndex(id, featured);
|
const elementIndex = this.getCurrentStatusIndex(id, featured) + 1;
|
||||||
this._selectChild(id, index, 1);
|
this._selectChild(elementIndex, false);
|
||||||
};
|
};
|
||||||
|
|
||||||
_selectChild = (id, index, direction) => {
|
|
||||||
const listContainer = this.node?.node;
|
|
||||||
let listItem = listContainer?.querySelector(
|
|
||||||
// :nth-child uses 1-based indexing
|
|
||||||
`.item-list > :nth-child(${index + 1 + direction})`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!listItem) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If selected container element is empty, we skip it
|
|
||||||
if (listItem.matches(':empty')) {
|
|
||||||
this._selectChild(id, index + direction, direction);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the list item is a post
|
|
||||||
let targetElement = listItem.querySelector('.focusable');
|
|
||||||
|
|
||||||
// Otherwise, check if the item contains follow suggestions or
|
|
||||||
// is a 'load more' button.
|
|
||||||
if (
|
|
||||||
!targetElement && (
|
|
||||||
listItem.querySelector('.inline-follow-suggestions') ||
|
|
||||||
listItem.matches('.load-more')
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
targetElement = listItem;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetElement) {
|
|
||||||
const elementRect = targetElement.getBoundingClientRect();
|
|
||||||
|
|
||||||
const isFullyVisible =
|
|
||||||
elementRect.top >= this.columnHeaderHeight &&
|
|
||||||
elementRect.bottom <= window.innerHeight;
|
|
||||||
|
|
||||||
if (!isFullyVisible) {
|
|
||||||
targetElement.scrollIntoView({
|
|
||||||
block: direction === 1 ? 'start' : 'center',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
targetElement.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleLoadOlder = debounce(() => {
|
handleLoadOlder = debounce(() => {
|
||||||
const { statusIds, lastId, onLoadMore } = this.props;
|
const { statusIds, lastId, onLoadMore } = this.props;
|
||||||
onLoadMore(lastId || (statusIds.size > 0 ? statusIds.last() : undefined));
|
onLoadMore(lastId || (statusIds.size > 0 ? statusIds.last() : undefined));
|
||||||
}, 300, { leading: true });
|
}, 300, { leading: true });
|
||||||
|
|
||||||
|
_selectChild (index, align_top) {
|
||||||
|
const container = this.node.node;
|
||||||
|
const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
if (align_top && container.scrollTop > element.offsetTop) {
|
||||||
|
element.scrollIntoView(true);
|
||||||
|
} else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
|
||||||
|
element.scrollIntoView(false);
|
||||||
|
}
|
||||||
|
element.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setRef = c => {
|
setRef = c => {
|
||||||
this.node = c;
|
this.node = c;
|
||||||
};
|
};
|
||||||
|
|
|
@ -3,15 +3,19 @@ import { useEffect, useMemo } from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import type { Map as ImmutableMap } from 'immutable';
|
import type { Map as ImmutableMap } from 'immutable';
|
||||||
|
|
||||||
import { LearnMoreLink } from 'mastodon/components/learn_more_link';
|
import ArticleIcon from '@/material-icons/400-24px/article.svg?react';
|
||||||
|
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
||||||
|
import { Icon } from 'mastodon/components/icon';
|
||||||
import StatusContainer from 'mastodon/containers/status_container';
|
import StatusContainer from 'mastodon/containers/status_container';
|
||||||
import type { Status } from 'mastodon/models/status';
|
import type { Status } from 'mastodon/models/status';
|
||||||
import type { RootState } from 'mastodon/store';
|
import type { RootState } from 'mastodon/store';
|
||||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||||
|
|
||||||
|
import QuoteIcon from '../../images/quote.svg?react';
|
||||||
import { fetchStatus } from '../actions/statuses';
|
import { fetchStatus } from '../actions/statuses';
|
||||||
import { makeGetStatus } from '../selectors';
|
import { makeGetStatus } from '../selectors';
|
||||||
|
|
||||||
|
@ -27,6 +31,7 @@ const QuoteWrapper: React.FC<{
|
||||||
'status__quote--error': isError,
|
'status__quote--error': isError,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
|
<Icon id='quote' icon={QuoteIcon} className='status__quote-icon' />
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -40,20 +45,27 @@ const NestedQuoteLink: React.FC<{
|
||||||
accountId ? state.accounts.get(accountId) : undefined,
|
accountId ? state.accounts.get(accountId) : undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
const quoteAuthorName = account?.acct;
|
const quoteAuthorName = account?.display_name_html;
|
||||||
|
|
||||||
if (!quoteAuthorName) {
|
if (!quoteAuthorName) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const quoteAuthorElement = (
|
||||||
|
<span dangerouslySetInnerHTML={{ __html: quoteAuthorName }} />
|
||||||
|
);
|
||||||
|
const quoteUrl = `/@${account.get('acct')}/${status.get('id') as string}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='status__quote-author-button'>
|
<Link to={quoteUrl} className='status__quote-author-button'>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='status.quote_post_author'
|
id='status.quote_post_author'
|
||||||
defaultMessage='Quoted a post by @{name}'
|
defaultMessage='Post by {name}'
|
||||||
values={{ name: quoteAuthorName }}
|
values={{ name: quoteAuthorElement }}
|
||||||
/>
|
/>
|
||||||
</div>
|
<Icon id='chevron_right' icon={ChevronRightIcon} />
|
||||||
|
<Icon id='article' icon={ArticleIcon} />
|
||||||
|
</Link>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -100,42 +112,39 @@ export const QuotedStatus: React.FC<{
|
||||||
defaultMessage='Hidden due to one of your filters'
|
defaultMessage='Hidden due to one of your filters'
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (quoteState === 'pending') {
|
} else if (quoteState === 'deleted') {
|
||||||
quoteError = (
|
|
||||||
<>
|
|
||||||
<FormattedMessage
|
|
||||||
id='status.quote_error.pending_approval'
|
|
||||||
defaultMessage='Post pending'
|
|
||||||
/>
|
|
||||||
|
|
||||||
<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'
|
|
||||||
) {
|
|
||||||
quoteError = (
|
quoteError = (
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='status.quote_error.not_available'
|
id='status.quote_error.removed'
|
||||||
defaultMessage='Post unavailable'
|
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='This post is pending approval from the original author.'
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (quoteState === 'rejected' || quoteState === 'revoked') {
|
||||||
|
quoteError = (
|
||||||
|
<FormattedMessage
|
||||||
|
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
|
isQuotedPost
|
||||||
id={quotedStatusId}
|
id={quotedStatusId}
|
||||||
contextType={contextType}
|
contextType={contextType}
|
||||||
avatarSize={32}
|
avatarSize={40}
|
||||||
>
|
>
|
||||||
{canRenderChildQuote && (
|
{canRenderChildQuote && (
|
||||||
<QuotedStatus
|
<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) {
|
onEdit (status) {
|
||||||
dispatch((_, getState) => {
|
dispatch((_, getState) => {
|
||||||
let state = getState();
|
let state = getState();
|
||||||
|
|
|
@ -898,7 +898,8 @@ export const AccountHeader: React.FC<{
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<AccountBio
|
<AccountBio
|
||||||
accountId={accountId}
|
note={account.note_emojified}
|
||||||
|
dropdownAccountId={accountId}
|
||||||
className='account__header__content'
|
className='account__header__content'
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
@ -92,29 +92,10 @@ class ComposeForm extends ImmutablePureComponent {
|
||||||
this.props.onChange(e.target.value);
|
this.props.onChange(e.target.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
blurOnEscape = (e) => {
|
handleKeyDown = (e) => {
|
||||||
if (['esc', 'escape'].includes(e.key.toLowerCase())) {
|
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
|
||||||
e.target.blur();
|
this.handleSubmit();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
handleKeyDownPost = (e) => {
|
|
||||||
if (e.key.toLowerCase() === 'enter' && (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 = () => {
|
getFulltextForCharacterCounting = () => {
|
||||||
|
@ -267,7 +248,7 @@ class ComposeForm extends ImmutablePureComponent {
|
||||||
value={this.props.spoilerText}
|
value={this.props.spoilerText}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
onChange={this.handleChangeSpoilerText}
|
onChange={this.handleChangeSpoilerText}
|
||||||
onKeyDown={this.handleKeyDownSpoiler}
|
onKeyDown={this.handleKeyDown}
|
||||||
ref={this.setSpoilerText}
|
ref={this.setSpoilerText}
|
||||||
suggestions={this.props.suggestions}
|
suggestions={this.props.suggestions}
|
||||||
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
||||||
|
@ -292,7 +273,7 @@ class ComposeForm extends ImmutablePureComponent {
|
||||||
onChange={this.handleChange}
|
onChange={this.handleChange}
|
||||||
suggestions={this.props.suggestions}
|
suggestions={this.props.suggestions}
|
||||||
onFocus={this.handleFocus}
|
onFocus={this.handleFocus}
|
||||||
onKeyDown={this.handleKeyDownPost}
|
onKeyDown={this.handleKeyDown}
|
||||||
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
||||||
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
|
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
|
||||||
onSuggestionSelected={this.onSuggestionSelected}
|
onSuggestionSelected={this.onSuggestionSelected}
|
||||||
|
|
|
@ -10,13 +10,15 @@ import { createSelector } from '@reduxjs/toolkit';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
|
||||||
|
|
||||||
|
import { HotKeys } from 'react-hotkeys';
|
||||||
|
|
||||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||||
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
|
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
|
||||||
import { replyCompose } from 'mastodon/actions/compose';
|
import { replyCompose } from 'mastodon/actions/compose';
|
||||||
import { markConversationRead, deleteConversation } from 'mastodon/actions/conversations';
|
import { markConversationRead, deleteConversation } from 'mastodon/actions/conversations';
|
||||||
import { openModal } from 'mastodon/actions/modal';
|
import { openModal } from 'mastodon/actions/modal';
|
||||||
import { muteStatus, unmuteStatus, toggleStatusSpoilers } from 'mastodon/actions/statuses';
|
import { muteStatus, unmuteStatus, toggleStatusSpoilers } from 'mastodon/actions/statuses';
|
||||||
import { Hotkeys } from 'mastodon/components/hotkeys';
|
|
||||||
import AttachmentList from 'mastodon/components/attachment_list';
|
import AttachmentList from 'mastodon/components/attachment_list';
|
||||||
import AvatarComposite from 'mastodon/components/avatar_composite';
|
import AvatarComposite from 'mastodon/components/avatar_composite';
|
||||||
import { IconButton } from 'mastodon/components/icon_button';
|
import { IconButton } from 'mastodon/components/icon_button';
|
||||||
|
@ -167,7 +169,7 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Hotkeys handlers={handlers}>
|
<HotKeys handlers={handlers}>
|
||||||
<div className={classNames('conversation focusable muted', { unread })} tabIndex={0}>
|
<div className={classNames('conversation focusable muted', { unread })} tabIndex={0}>
|
||||||
<div className='conversation__avatar' onClick={handleClick} role='presentation'>
|
<div className='conversation__avatar' onClick={handleClick} role='presentation'>
|
||||||
<AvatarComposite accounts={accounts} size={48} />
|
<AvatarComposite accounts={accounts} size={48} />
|
||||||
|
@ -217,7 +219,7 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Hotkeys>
|
</HotKeys>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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>
|
</div>
|
||||||
</section>
|
</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'>
|
<section role='group' aria-labelledby='notifications-poll'>
|
||||||
<h3 id='notifications-poll'><FormattedMessage id='notifications.column_settings.poll' defaultMessage='Poll results:' /></h3>
|
<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 ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
|
|
||||||
|
import { HotKeys } from 'react-hotkeys';
|
||||||
|
|
||||||
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
|
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
|
||||||
import FlagIcon from '@/material-icons/400-24px/flag-fill.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 HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
|
||||||
import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react';
|
import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react';
|
||||||
import PersonIcon from '@/material-icons/400-24px/person-fill.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 StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
|
||||||
import { Account } from 'mastodon/components/account';
|
import { Account } from 'mastodon/components/account';
|
||||||
import { Icon } from 'mastodon/components/icon';
|
import { Icon } from 'mastodon/components/icon';
|
||||||
import { Hotkeys } from 'mastodon/components/hotkeys';
|
|
||||||
import { StatusQuoteManager } from 'mastodon/components/status_quoted';
|
import { StatusQuoteManager } from 'mastodon/components/status_quoted';
|
||||||
import { me } from 'mastodon/initial_state';
|
import { me } from 'mastodon/initial_state';
|
||||||
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
|
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
|
||||||
|
@ -42,7 +42,6 @@ const messages = defineMessages({
|
||||||
adminReport: { id: 'notification.admin.report', defaultMessage: '{name} reported {target}' },
|
adminReport: { id: 'notification.admin.report', defaultMessage: '{name} reported {target}' },
|
||||||
relationshipsSevered: { id: 'notification.relationships_severance_event', defaultMessage: 'Lost connections with {name}' },
|
relationshipsSevered: { id: 'notification.relationships_severance_event', defaultMessage: 'Lost connections with {name}' },
|
||||||
moderationWarning: { id: 'notification.moderation_warning', defaultMessage: 'You have received a moderation warning' },
|
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) => {
|
const notificationForScreenReader = (intl, message, timestamp) => {
|
||||||
|
@ -138,7 +137,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
const { intl, unread } = this.props;
|
const { intl, unread } = this.props;
|
||||||
|
|
||||||
return (
|
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={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'>
|
<div className='notification__message'>
|
||||||
<Icon id='user-plus' icon={PersonAddIcon} />
|
<Icon id='user-plus' icon={PersonAddIcon} />
|
||||||
|
@ -150,7 +149,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
|
|
||||||
<Account id={account.get('id')} hidden={this.props.hidden} />
|
<Account id={account.get('id')} hidden={this.props.hidden} />
|
||||||
</div>
|
</div>
|
||||||
</Hotkeys>
|
</HotKeys>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -158,7 +157,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
const { intl, unread } = this.props;
|
const { intl, unread } = this.props;
|
||||||
|
|
||||||
return (
|
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={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'>
|
<div className='notification__message'>
|
||||||
<Icon id='user' icon={PersonIcon} />
|
<Icon id='user' icon={PersonIcon} />
|
||||||
|
@ -170,7 +169,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
|
|
||||||
<FollowRequestContainer id={account.get('id')} hidden={this.props.hidden} />
|
<FollowRequestContainer id={account.get('id')} hidden={this.props.hidden} />
|
||||||
</div>
|
</div>
|
||||||
</Hotkeys>
|
</HotKeys>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -196,7 +195,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
const { intl, unread } = this.props;
|
const { intl, unread } = this.props;
|
||||||
|
|
||||||
return (
|
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={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'>
|
<div className='notification__message'>
|
||||||
<Icon id='star' icon={StarIcon} className='star-icon' />
|
<Icon id='star' icon={StarIcon} className='star-icon' />
|
||||||
|
@ -218,7 +217,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
cacheMediaWidth={this.props.cacheMediaWidth}
|
cacheMediaWidth={this.props.cacheMediaWidth}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Hotkeys>
|
</HotKeys>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -226,7 +225,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
const { intl, unread } = this.props;
|
const { intl, unread } = this.props;
|
||||||
|
|
||||||
return (
|
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={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'>
|
<div className='notification__message'>
|
||||||
<Icon id='retweet' icon={RepeatIcon} />
|
<Icon id='retweet' icon={RepeatIcon} />
|
||||||
|
@ -248,37 +247,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
cacheMediaWidth={this.props.cacheMediaWidth}
|
cacheMediaWidth={this.props.cacheMediaWidth}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Hotkeys>
|
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -290,7 +259,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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={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'>
|
<div className='notification__message'>
|
||||||
<Icon id='home' icon={HomeIcon} />
|
<Icon id='home' icon={HomeIcon} />
|
||||||
|
@ -313,7 +282,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
cacheMediaWidth={this.props.cacheMediaWidth}
|
cacheMediaWidth={this.props.cacheMediaWidth}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Hotkeys>
|
</HotKeys>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -325,7 +294,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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={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'>
|
<div className='notification__message'>
|
||||||
<Icon id='pencil' icon={EditIcon} />
|
<Icon id='pencil' icon={EditIcon} />
|
||||||
|
@ -348,7 +317,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
cacheMediaWidth={this.props.cacheMediaWidth}
|
cacheMediaWidth={this.props.cacheMediaWidth}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Hotkeys>
|
</HotKeys>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -362,7 +331,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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={classNames('notification notification-poll focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, message, notification.get('created_at'))}>
|
||||||
<div className='notification__message'>
|
<div className='notification__message'>
|
||||||
<Icon id='tasks' icon={InsertChartIcon} />
|
<Icon id='tasks' icon={InsertChartIcon} />
|
||||||
|
@ -389,7 +358,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
cacheMediaWidth={this.props.cacheMediaWidth}
|
cacheMediaWidth={this.props.cacheMediaWidth}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Hotkeys>
|
</HotKeys>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -402,7 +371,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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'))}>
|
<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
|
<RelationshipsSeveranceEvent
|
||||||
type={event.get('type')}
|
type={event.get('type')}
|
||||||
|
@ -412,7 +381,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Hotkeys>
|
</HotKeys>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -425,7 +394,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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'))}>
|
<div className={classNames('notification notification-moderation-warning focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.moderationWarning), notification.get('created_at'))}>
|
||||||
<ModerationWarning
|
<ModerationWarning
|
||||||
action={warning.get('action')}
|
action={warning.get('action')}
|
||||||
|
@ -433,7 +402,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Hotkeys>
|
</HotKeys>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -441,7 +410,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
const { intl, unread } = this.props;
|
const { intl, unread } = this.props;
|
||||||
|
|
||||||
return (
|
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={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'>
|
<div className='notification__message'>
|
||||||
<Icon id='user-plus' icon={PersonAddIcon} />
|
<Icon id='user-plus' icon={PersonAddIcon} />
|
||||||
|
@ -453,7 +422,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
|
|
||||||
<Account id={account.get('id')} hidden={this.props.hidden} />
|
<Account id={account.get('id')} hidden={this.props.hidden} />
|
||||||
</div>
|
</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>;
|
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 (
|
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={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'>
|
<div className='notification__message'>
|
||||||
<Icon id='flag' icon={FlagIcon} />
|
<Icon id='flag' icon={FlagIcon} />
|
||||||
|
@ -481,7 +450,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
|
|
||||||
<Report account={account} report={notification.get('report')} hidden={this.props.hidden} />
|
<Report account={account} report={notification.get('report')} hidden={this.props.hidden} />
|
||||||
</div>
|
</div>
|
||||||
</Hotkeys>
|
</HotKeys>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -498,8 +467,6 @@ class Notification extends ImmutablePureComponent {
|
||||||
return this.renderFollowRequest(notification, account, link);
|
return this.renderFollowRequest(notification, account, link);
|
||||||
case 'mention':
|
case 'mention':
|
||||||
return this.renderMention(notification);
|
return this.renderMention(notification);
|
||||||
case 'quote':
|
|
||||||
return this.renderQuote(notification);
|
|
||||||
case 'favourite':
|
case 'favourite':
|
||||||
return this.renderFavourite(notification, link);
|
return this.renderFavourite(notification, link);
|
||||||
case 'reblog':
|
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