Merge branch 'main' into fix/rendering-of-polls-in-history-modal

This commit is contained in:
Emelia Smith 2025-09-01 18:24:09 +02:00 committed by GitHub
commit 7f5ea3c8f8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
593 changed files with 15708 additions and 5178 deletions

View File

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

View File

@ -9,7 +9,6 @@ permissions:
jobs:
compute-suffix:
runs-on: ubuntu-latest
if: github.repository == 'mastodon/mastodon'
steps:
- id: version_vars
env:

View File

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

2
.nvmrc
View File

@ -1 +1 @@
22.17
22.19

View File

@ -1,17 +1,21 @@
---
Metrics/AbcSize:
Exclude:
- lib/mastodon/cli/*.rb
Enabled: false
Metrics/BlockLength:
Enabled: false
Metrics/BlockNesting:
Enabled: false
Metrics/ClassLength:
Enabled: false
Metrics/CollectionLiteralLength:
Enabled: false
Metrics/CyclomaticComplexity:
Exclude:
- lib/mastodon/cli/*.rb
Enabled: false
Metrics/MethodLength:
Enabled: false
@ -20,4 +24,7 @@ Metrics/ModuleLength:
Enabled: false
Metrics/ParameterLists:
CountKeywordArgs: false
Enabled: false
Metrics/PerceivedComplexity:
Enabled: false

View File

@ -1,28 +1,11 @@
# This configuration was generated by
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp`
# using RuboCop version 1.79.0.
# using RuboCop version 1.79.2.
# The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new
# versions of RuboCop, may require this file to be generated again.
# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes.
Metrics/AbcSize:
Max: 82
# Configuration parameters: CountBlocks, CountModifierForms, Max.
Metrics/BlockNesting:
Exclude:
- 'lib/tasks/mastodon.rake'
# Configuration parameters: AllowedMethods, AllowedPatterns.
Metrics/CyclomaticComplexity:
Max: 25
# Configuration parameters: AllowedMethods, AllowedPatterns.
Metrics/PerceivedComplexity:
Max: 27
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: AllowedVars, DefaultToNil.
Style/FetchEnvVar:

View File

@ -7,7 +7,7 @@
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = '2.10.2'
const PACKAGE_VERSION = '2.10.4'
const INTEGRITY_CHECKSUM = 'f5825c521429caf22a4dd13b66e243af'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()

View File

@ -2,6 +2,28 @@
All notable changes to this project will be documented in this file.
## [4.4.3] - 2025-08-05
### Security
- Update dependencies
- Fix incorrect rate-limit handling [GHSA-84ch-6436-c7mg](https://github.com/mastodon/mastodon/security/advisories/GHSA-84ch-6436-c7mg)
### Fixed
- Fix race condition caused by ActiveRecord query cache in `Create` critical path (#35662 by @ClearlyClaire)
- Fix race condition caused by quote post processing (#35657 by @ClearlyClaire)
- Fix WebUI crashing for accounts with `null` URL (#35651 by @ClearlyClaire)
- Fix friends-of-friends recommendations suggesting already-requested accounts (#35604 by @ClearlyClaire)
- Fix synchronous recursive fetching of deeply-nested quoted posts (#35600 by @ClearlyClaire)
- Fix “Expand this post” link including user `@undefined` (#35478 by @ClearlyClaire)
### Changed
- Change `StatusReachFinder` to consider quotes as well as reblogs (#35601 by @ClearlyClaire)
- Add restrictions on which quote posts can trend (#35507 by @ClearlyClaire)
- Change quote verification to not bypass authorization flow for mentions (#35528 by @ClearlyClaire)
## [4.4.2] - 2025-07-23
### Security
@ -561,7 +583,6 @@ The following changelog entries focus on changes visible to users, administrator
You can now separately filter or drop notifications from people you don't follow, people who don't follow you, accounts created within the past 30 days, as well as unsolicited private mentions, and accounts limited by the moderation.\
Instead of being outright dropped, notifications that you chose to filter are put in a separate “Filtered notifications” box that you can review separately without it clogging your main notifications.\
This adds the following REST API endpoints:
- `GET /api/v2/notifications/policy`: https://docs.joinmastodon.org/methods/notifications/#get-policy
- `PATCH /api/v2/notifications/policy`: https://docs.joinmastodon.org/methods/notifications/#update-the-filtering-policy-for-notifications
- `GET /api/v1/notifications/requests`: https://docs.joinmastodon.org/methods/notifications/#get-requests
@ -573,7 +594,6 @@ The following changelog entries focus on changes visible to users, administrator
- `GET /api/v1/notifications/requests/merged`: https://docs.joinmastodon.org/methods/notifications/#requests-merged
In addition, accepting one or more notification requests generates a new streaming event:
- `notifications_merged`: an event of this type indicates accepted notification requests have finished merging, and the notifications list should be refreshed
- **Add notifications of severed relationships** (#27511, #29665, #29668, #29670, #29700, #29714, #29712, and #29731 by @ClearlyClaire and @Gargron)\

View File

@ -17,11 +17,11 @@ ARG RUBY_VERSION="3.4.5"
# # Node.js version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"]
# renovate: datasource=node-version depName=node
ARG NODE_MAJOR_VERSION="22"
# Debian image to use for base image, change with [--build-arg DEBIAN_VERSION="bookworm"]
ARG DEBIAN_VERSION="bookworm"
# Node.js image to use for base image based on combined variables (ex: 20-bookworm-slim)
# Debian image to use for base image, change with [--build-arg DEBIAN_VERSION="trixie"]
ARG DEBIAN_VERSION="trixie"
# Node.js image to use for base image based on combined variables (ex: 20-trixie-slim)
FROM ${BASE_REGISTRY}/node:${NODE_MAJOR_VERSION}-${DEBIAN_VERSION}-slim AS node
# Ruby image to use for base image based on combined variables (ex: 3.4.x-slim-bookworm)
# Ruby image to use for base image based on combined variables (ex: 3.4.x-slim-trixie)
FROM ${BASE_REGISTRY}/ruby:${RUBY_VERSION}-slim-${DEBIAN_VERSION} AS ruby
# Resulting version string is vX.X.X-MASTODON_VERSION_PRERELEASE+MASTODON_VERSION_METADATA
@ -96,9 +96,6 @@ RUN \
# Set /opt/mastodon as working directory
WORKDIR /opt/mastodon
# Add backport repository for some specific packages where we need the latest version
RUN echo 'deb http://deb.debian.org/debian bookworm-backports main' >> /etc/apt/sources.list
# hadolint ignore=DL3008,DL3005
RUN \
# Mount Apt cache and lib directories from Docker buildx caches
@ -161,11 +158,11 @@ RUN \
libexif-dev \
libexpat1-dev \
libgirepository1.0-dev \
libheif-dev/bookworm-backports \
libheif-dev \
libhwy-dev \
libimagequant-dev \
libjpeg62-turbo-dev \
liblcms2-dev \
liborc-dev \
libspng-dev \
libtiff-dev \
libwebp-dev \
@ -209,7 +206,7 @@ FROM build AS ffmpeg
# ffmpeg version to compile, change with [--build-arg FFMPEG_VERSION="7.0.x"]
# renovate: datasource=repology depName=ffmpeg packageName=openpkg_current/ffmpeg
ARG FFMPEG_VERSION=7.1
ARG FFMPEG_VERSION=7.1.1
# ffmpeg download URL, change with [--build-arg FFMPEG_URL="https://ffmpeg.org/releases"]
ARG FFMPEG_URL=https://ffmpeg.org/releases
@ -327,28 +324,28 @@ RUN \
# Apt update install non-dev versions of necessary components
apt-get install -y --no-install-recommends \
libexpat1 \
libglib2.0-0 \
libicu72 \
libglib2.0-0t64 \
libicu76 \
libidn12 \
libpq5 \
libreadline8 \
libssl3 \
libreadline8t64 \
libssl3t64 \
libyaml-0-2 \
# libvips components
libcgif0 \
libexif12 \
libheif1/bookworm-backports \
libheif1 \
libhwy1t64 \
libimagequant0 \
libjpeg62-turbo \
liblcms2-2 \
liborc-0.4-0 \
libspng0 \
libtiff6 \
libwebp7 \
libwebpdemux2 \
libwebpmux3 \
# ffmpeg components
libdav1d6 \
libdav1d7 \
libmp3lame0 \
libopencore-amrnb0 \
libopencore-amrwb0 \
@ -358,9 +355,9 @@ RUN \
libvorbis0a \
libvorbisenc2 \
libvorbisfile3 \
libvpx7 \
libvpx9 \
libx264-164 \
libx265-199 \
libx265-215 \
;
# Copy Mastodon sources into final layer

18
Gemfile
View File

@ -82,13 +82,13 @@ gem 'rqrcode', '~> 3.0'
gem 'ruby-progressbar', '~> 1.13'
gem 'sanitize', '~> 7.0'
gem 'scenic', '~> 1.7'
gem 'sidekiq', '< 8'
gem 'sidekiq', '< 9'
gem 'sidekiq-bulk', '~> 0.2.0'
gem 'sidekiq-scheduler', '~> 5.0'
gem 'sidekiq-scheduler', '~> 6.0'
gem 'sidekiq-unique-jobs', '> 8'
gem 'simple_form', '~> 5.2'
gem 'simple-navigation', '~> 4.4'
gem 'stoplight', '~> 4.1'
gem 'stoplight', github: 'ClearlyClaire/stoplight', ref: 'f13e0c0d5e6d34af8d3cfc888871caa84237db42'
gem 'strong_migrations'
gem 'tty-prompt', '~> 0.23', require: false
gem 'twitter-text', '~> 3.1.0'
@ -102,17 +102,17 @@ gem 'rdf-normalize', '~> 0.5'
gem 'prometheus_exporter', '~> 2.2', require: false
gem 'opentelemetry-api', '~> 1.5.0'
gem 'opentelemetry-api', '~> 1.6.0'
group :opentelemetry do
gem 'opentelemetry-exporter-otlp', '~> 0.30.0', require: false
gem 'opentelemetry-instrumentation-active_job', '~> 0.8.0', require: false
gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.22.0', require: false
gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.22.0', require: false
gem 'opentelemetry-instrumentation-excon', '~> 0.23.0', require: false
gem 'opentelemetry-instrumentation-faraday', '~> 0.27.0', require: false
gem 'opentelemetry-instrumentation-excon', '~> 0.24.0', require: false
gem 'opentelemetry-instrumentation-faraday', '~> 0.28.0', require: false
gem 'opentelemetry-instrumentation-http', '~> 0.25.0', require: false
gem 'opentelemetry-instrumentation-http_client', '~> 0.23.0', require: false
gem 'opentelemetry-instrumentation-http_client', '~> 0.24.0', require: false
gem 'opentelemetry-instrumentation-net_http', '~> 0.23.0', require: false
gem 'opentelemetry-instrumentation-pg', '~> 0.30.0', require: false
gem 'opentelemetry-instrumentation-rack', '~> 0.26.0', require: false
@ -146,7 +146,7 @@ group :test do
gem 'climate_control'
# Validate schemas in specs
gem 'json-schema', '~> 5.0'
gem 'json-schema', '~> 6.0'
# Test harness fo rack components
gem 'rack-test', '~> 2.1'
@ -223,7 +223,7 @@ gem 'connection_pool', require: false
gem 'xorcist', '~> 1.1'
gem 'net-http', '~> 0.6.0'
gem 'rubyzip', '~> 2.3'
gem 'rubyzip', '~> 3.0'
gem 'hcaptcha', '~> 7.1'

View File

@ -1,3 +1,11 @@
GIT
remote: https://github.com/ClearlyClaire/stoplight.git
revision: f13e0c0d5e6d34af8d3cfc888871caa84237db42
ref: f13e0c0d5e6d34af8d3cfc888871caa84237db42
specs:
stoplight (5.3.1)
zeitwerk
GIT
remote: https://github.com/mastodon/webpush.git
revision: 9631ac63045cfabddacc69fc06e919b4c13eb913
@ -10,29 +18,29 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (8.0.2)
actionpack (= 8.0.2)
activesupport (= 8.0.2)
actioncable (8.0.2.1)
actionpack (= 8.0.2.1)
activesupport (= 8.0.2.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (8.0.2)
actionpack (= 8.0.2)
activejob (= 8.0.2)
activerecord (= 8.0.2)
activestorage (= 8.0.2)
activesupport (= 8.0.2)
actionmailbox (8.0.2.1)
actionpack (= 8.0.2.1)
activejob (= 8.0.2.1)
activerecord (= 8.0.2.1)
activestorage (= 8.0.2.1)
activesupport (= 8.0.2.1)
mail (>= 2.8.0)
actionmailer (8.0.2)
actionpack (= 8.0.2)
actionview (= 8.0.2)
activejob (= 8.0.2)
activesupport (= 8.0.2)
actionmailer (8.0.2.1)
actionpack (= 8.0.2.1)
actionview (= 8.0.2.1)
activejob (= 8.0.2.1)
activesupport (= 8.0.2.1)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (8.0.2)
actionview (= 8.0.2)
activesupport (= 8.0.2)
actionpack (8.0.2.1)
actionview (= 8.0.2.1)
activesupport (= 8.0.2.1)
nokogiri (>= 1.8.5)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
@ -40,15 +48,15 @@ GEM
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (8.0.2)
actionpack (= 8.0.2)
activerecord (= 8.0.2)
activestorage (= 8.0.2)
activesupport (= 8.0.2)
actiontext (8.0.2.1)
actionpack (= 8.0.2.1)
activerecord (= 8.0.2.1)
activestorage (= 8.0.2.1)
activesupport (= 8.0.2.1)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (8.0.2)
activesupport (= 8.0.2)
actionview (8.0.2.1)
activesupport (= 8.0.2.1)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
@ -58,22 +66,22 @@ GEM
activemodel (>= 4.1)
case_transform (>= 0.2)
jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
activejob (8.0.2)
activesupport (= 8.0.2)
activejob (8.0.2.1)
activesupport (= 8.0.2.1)
globalid (>= 0.3.6)
activemodel (8.0.2)
activesupport (= 8.0.2)
activerecord (8.0.2)
activemodel (= 8.0.2)
activesupport (= 8.0.2)
activemodel (8.0.2.1)
activesupport (= 8.0.2.1)
activerecord (8.0.2.1)
activemodel (= 8.0.2.1)
activesupport (= 8.0.2.1)
timeout (>= 0.4.0)
activestorage (8.0.2)
actionpack (= 8.0.2)
activejob (= 8.0.2)
activerecord (= 8.0.2)
activesupport (= 8.0.2)
activestorage (8.0.2.1)
actionpack (= 8.0.2.1)
activejob (= 8.0.2.1)
activerecord (= 8.0.2.1)
activesupport (= 8.0.2.1)
marcel (~> 1.0)
activesupport (8.0.2)
activesupport (8.0.2.1)
base64
benchmark (>= 0.3)
bigdecimal
@ -90,13 +98,13 @@ GEM
public_suffix (>= 2.0.2, < 7.0)
aes_key_wrap (1.1.0)
android_key_attestation (0.3.0)
annotaterb (4.17.0)
annotaterb (4.18.0)
activerecord (>= 6.0.0)
activesupport (>= 6.0.0)
ast (2.4.3)
attr_required (1.0.2)
aws-eventstream (1.4.0)
aws-partitions (1.1131.0)
aws-partitions (1.1135.0)
aws-sdk-core (3.215.1)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
@ -144,7 +152,7 @@ GEM
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
capybara-playwright-driver (0.5.6)
capybara-playwright-driver (0.5.7)
addressable
capybara
playwright-ruby-client (>= 1.16.0)
@ -175,9 +183,9 @@ GEM
css_parser (1.21.1)
addressable
csv (3.3.5)
database_cleaner-active_record (2.2.1)
database_cleaner-active_record (2.2.2)
activerecord (>= 5.a)
database_cleaner-core (~> 2.0.0)
database_cleaner-core (~> 2.0)
database_cleaner-core (2.0.1)
date (3.4.1)
debug (1.11.0)
@ -233,7 +241,7 @@ GEM
fabrication (3.0.0)
faker (3.5.2)
i18n (>= 1.8.11, < 2)
faraday (2.13.2)
faraday (2.13.4)
faraday-net_http (>= 2.0, < 3.5)
json
logger
@ -287,7 +295,7 @@ GEM
activesupport (>= 5.1)
haml (>= 4.0.6)
railties (>= 5.1)
haml_lint (0.65.1)
haml_lint (0.66.0)
haml (>= 5.0)
parallel (~> 1.10)
rainbow
@ -300,8 +308,8 @@ GEM
highline (3.1.2)
reline
hiredis (0.6.3)
hiredis-client (0.25.1)
redis-client (= 0.25.1)
hiredis-client (0.25.2)
redis-client (= 0.25.2)
hkdf (0.3.0)
htmlentities (4.3.4)
http (5.3.1)
@ -315,7 +323,7 @@ GEM
http_accept_language (2.1.1)
httpclient (2.9.0)
mutex_m
httplog (1.7.2)
httplog (1.7.3)
rack (>= 2.0)
rainbow (>= 2.0.0)
i18n (1.14.7)
@ -345,7 +353,7 @@ GEM
azure-blob (~> 0.5.2)
hashie (~> 5.0)
jmespath (1.6.2)
json (2.13.0)
json (2.13.2)
json-canonicalization (1.0.0)
json-jwt (1.16.7)
activesupport (>= 4.2)
@ -365,7 +373,7 @@ GEM
json-ld-preloaded (3.3.2)
json-ld (~> 3.3)
rdf (~> 3.3)
json-schema (5.2.1)
json-schema (6.0.0)
addressable (~> 2.8)
bigdecimal (~> 3.1)
jsonapi-renderer (0.2.2)
@ -438,7 +446,7 @@ GEM
mime-types (3.7.0)
logger
mime-types-data (~> 3.2025, >= 3.2025.0507)
mime-types-data (3.2025.0715)
mime-types-data (3.2025.0729)
mini_mime (1.1.5)
mini_portile2 (2.8.9)
minitest (5.25.5)
@ -497,7 +505,7 @@ GEM
openssl (3.3.0)
openssl-signature_algorithm (1.3.0)
openssl (> 2.0)
opentelemetry-api (1.5.0)
opentelemetry-api (1.6.0)
opentelemetry-common (0.22.0)
opentelemetry-api (~> 1.0)
opentelemetry-exporter-otlp (0.30.0)
@ -547,19 +555,19 @@ GEM
opentelemetry-instrumentation-concurrent_ruby (0.22.0)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-excon (0.23.0)
opentelemetry-instrumentation-excon (0.24.0)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-faraday (0.27.0)
opentelemetry-instrumentation-faraday (0.28.0)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-http (0.25.1)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-http_client (0.23.0)
opentelemetry-instrumentation-http_client (0.24.0)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-net_http (0.23.0)
opentelemetry-instrumentation-net_http (0.23.1)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-pg (0.30.1)
@ -589,7 +597,7 @@ GEM
opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-registry (0.4.0)
opentelemetry-api (~> 1.1)
opentelemetry-sdk (1.8.0)
opentelemetry-sdk (1.8.1)
opentelemetry-api (~> 1.1)
opentelemetry-common (~> 0.20)
opentelemetry-registry (~> 0.2)
@ -601,16 +609,16 @@ GEM
ox (2.14.23)
bigdecimal (>= 3.0)
parallel (1.27.0)
parser (3.3.8.0)
parser (3.3.9.0)
ast (~> 2.4.1)
racc
parslet (2.0.0)
pastel (0.8.0)
tty-color (~> 0.5)
pg (1.5.9)
pg (1.6.1)
pghero (3.7.0)
activerecord (>= 7.1)
playwright-ruby-client (1.54.0)
playwright-ruby-client (1.54.1)
concurrent-ruby (>= 1.1.6)
mime-types (>= 3.0)
pp (0.6.2)
@ -625,7 +633,7 @@ GEM
premailer (~> 1.7, >= 1.7.9)
prettyprint (0.2.0)
prism (1.4.0)
prometheus_exporter (2.2.0)
prometheus_exporter (2.3.0)
webrick
propshaft (1.2.1)
actionpack (>= 7.0.0)
@ -635,7 +643,7 @@ GEM
date
stringio
public_suffix (6.0.2)
puma (6.6.0)
puma (6.6.1)
nio4r (~> 2.0)
pundit (2.5.0)
activesupport (>= 3.0.0)
@ -667,20 +675,20 @@ GEM
rack (>= 1.3)
rackup (2.2.1)
rack (>= 3)
rails (8.0.2)
actioncable (= 8.0.2)
actionmailbox (= 8.0.2)
actionmailer (= 8.0.2)
actionpack (= 8.0.2)
actiontext (= 8.0.2)
actionview (= 8.0.2)
activejob (= 8.0.2)
activemodel (= 8.0.2)
activerecord (= 8.0.2)
activestorage (= 8.0.2)
activesupport (= 8.0.2)
rails (8.0.2.1)
actioncable (= 8.0.2.1)
actionmailbox (= 8.0.2.1)
actionmailer (= 8.0.2.1)
actionpack (= 8.0.2.1)
actiontext (= 8.0.2.1)
actionview (= 8.0.2.1)
activejob (= 8.0.2.1)
activemodel (= 8.0.2.1)
activerecord (= 8.0.2.1)
activestorage (= 8.0.2.1)
activesupport (= 8.0.2.1)
bundler (>= 1.15.0)
railties (= 8.0.2)
railties (= 8.0.2.1)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
@ -688,12 +696,12 @@ GEM
rails-html-sanitizer (1.6.2)
loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails-i18n (8.0.1)
rails-i18n (8.0.2)
i18n (>= 0.7, < 2)
railties (>= 8.0.0, < 9)
railties (8.0.2)
actionpack (= 8.0.2)
activesupport (= 8.0.2)
railties (8.0.2.1)
actionpack (= 8.0.2.1)
activesupport (= 8.0.2.1)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
@ -717,11 +725,9 @@ GEM
reline
redcarpet (3.6.1)
redis (4.8.1)
redis-client (0.25.1)
redis-client (0.25.2)
connection_pool
redlock (1.3.2)
redis (>= 3.0.0, < 6.0)
regexp_parser (2.10.0)
regexp_parser (2.11.2)
reline (0.6.2)
io-console (~> 0.5)
request_store (1.7.0)
@ -731,7 +737,7 @@ GEM
railties (>= 5.2)
rexml (3.4.1)
rotp (6.3.0)
rouge (4.5.2)
rouge (4.6.0)
rpam2 (4.0.2)
rqrcode (3.1.0)
chunky_png (~> 1.0)
@ -751,7 +757,7 @@ GEM
rspec-mocks (3.13.5)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-rails (8.0.1)
rspec-rails (8.0.2)
actionpack (>= 7.2)
activesupport (>= 7.2)
railties (>= 7.2)
@ -765,7 +771,7 @@ GEM
rspec-mocks (~> 3.0)
sidekiq (>= 5, < 9)
rspec-support (3.13.4)
rubocop (1.79.0)
rubocop (1.79.2)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@ -775,7 +781,6 @@ GEM
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.46.0, < 2.0)
ruby-progressbar (~> 1.7)
tsort (>= 0.2.0)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.46.0)
parser (>= 3.3.7.2)
@ -790,7 +795,7 @@ GEM
lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.38.0, < 2.0)
rubocop-rails (2.32.0)
rubocop-rails (2.33.3)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
@ -806,13 +811,13 @@ GEM
ruby-prof (1.7.2)
base64
ruby-progressbar (1.13.0)
ruby-saml (1.18.0)
ruby-saml (1.18.1)
nokogiri (>= 1.13.10)
rexml
ruby-vips (2.2.4)
ruby-vips (2.2.5)
ffi (~> 1.12)
logger
rubyzip (2.4.1)
rubyzip (3.0.2)
rufus-scheduler (3.9.2)
fugit (~> 1.1, >= 1.11.1)
safety_net_attestation (0.4.0)
@ -826,18 +831,17 @@ GEM
securerandom (0.4.1)
shoulda-matchers (6.5.0)
activesupport (>= 5.2.0)
sidekiq (7.3.9)
base64
connection_pool (>= 2.3.0)
logger
rack (>= 2.2.4)
redis-client (>= 0.22.2)
sidekiq (8.0.7)
connection_pool (>= 2.5.0)
json (>= 2.9.0)
logger (>= 1.6.2)
rack (>= 3.1.0)
redis-client (>= 0.23.2)
sidekiq-bulk (0.2.0)
sidekiq
sidekiq-scheduler (5.0.6)
sidekiq-scheduler (6.0.1)
rufus-scheduler (~> 3.2)
sidekiq (>= 6, < 8)
tilt (>= 1.4.0, < 3)
sidekiq (>= 7.3, < 9)
sidekiq-unique-jobs (8.0.11)
concurrent-ruby (~> 1.0, >= 1.0.5)
sidekiq (>= 7.0.0, < 9.0.0)
@ -857,8 +861,6 @@ GEM
stackprof (0.2.27)
starry (0.2.0)
base64
stoplight (4.1.1)
redlock (~> 1.0)
stringio (3.1.7)
strong_migrations (2.5.0)
activerecord (>= 7.1)
@ -868,7 +870,7 @@ GEM
faraday (~> 2.0)
faraday-follow_redirects
sysexits (1.2.0)
temple (0.10.3)
temple (0.10.4)
terminal-table (4.0.0)
unicode-display_width (>= 1.1.1, < 4)
terrapin (1.1.1)
@ -881,7 +883,6 @@ GEM
bindata (~> 2.4)
openssl (> 2.0)
openssl-signature_algorithm (~> 1.0)
tsort (0.2.0)
tty-color (0.6.0)
tty-cursor (0.7.1)
tty-prompt (0.23.1)
@ -1008,7 +1009,7 @@ DEPENDENCIES
jd-paperclip-azure (~> 3.0)
json-ld
json-ld-preloaded (~> 3.2)
json-schema (~> 5.0)
json-schema (~> 6.0)
kaminari (~> 1.2)
kt-paperclip (~> 7.2)
letter_opener (~> 1.8)
@ -1030,15 +1031,15 @@ DEPENDENCIES
omniauth-rails_csrf_protection (~> 1.0)
omniauth-saml (~> 2.0)
omniauth_openid_connect (~> 0.8.0)
opentelemetry-api (~> 1.5.0)
opentelemetry-api (~> 1.6.0)
opentelemetry-exporter-otlp (~> 0.30.0)
opentelemetry-instrumentation-active_job (~> 0.8.0)
opentelemetry-instrumentation-active_model_serializers (~> 0.22.0)
opentelemetry-instrumentation-concurrent_ruby (~> 0.22.0)
opentelemetry-instrumentation-excon (~> 0.23.0)
opentelemetry-instrumentation-faraday (~> 0.27.0)
opentelemetry-instrumentation-excon (~> 0.24.0)
opentelemetry-instrumentation-faraday (~> 0.28.0)
opentelemetry-instrumentation-http (~> 0.25.0)
opentelemetry-instrumentation-http_client (~> 0.23.0)
opentelemetry-instrumentation-http_client (~> 0.24.0)
opentelemetry-instrumentation-net_http (~> 0.23.0)
opentelemetry-instrumentation-pg (~> 0.30.0)
opentelemetry-instrumentation-rack (~> 0.26.0)
@ -1078,20 +1079,20 @@ DEPENDENCIES
ruby-prof
ruby-progressbar (~> 1.13)
ruby-vips (~> 2.2)
rubyzip (~> 2.3)
rubyzip (~> 3.0)
sanitize (~> 7.0)
scenic (~> 1.7)
shoulda-matchers
sidekiq (< 8)
sidekiq (< 9)
sidekiq-bulk (~> 0.2.0)
sidekiq-scheduler (~> 5.0)
sidekiq-scheduler (~> 6.0)
sidekiq-unique-jobs (> 8)
simple-navigation (~> 4.4)
simple_form (~> 5.2)
simplecov (~> 0.22)
simplecov-lcov (~> 0.8)
stackprof
stoplight (~> 4.1)
stoplight!
strong_migrations
test-prof
thor (~> 1.2)
@ -1108,4 +1109,4 @@ RUBY VERSION
ruby 3.4.1p0
BUNDLED WITH
2.7.0
2.7.1

View File

@ -58,7 +58,7 @@ Mastodon is a **free, open-source social network server** based on [ActivityPub]
- **Ruby** 3.2+
- **PostgreSQL** 13+
- **Redis** 6.2+
- **Redis** 7.0+
- **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.

3
Vagrantfile vendored
View File

@ -54,6 +54,7 @@ sudo apt-get install \
pkg-config \
protobuf-compiler \
zlib1g-dev \
libvips42t64 \
-y
# Install rvm
@ -134,7 +135,7 @@ VAGRANTFILE_API_VERSION = "2"
Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
config.vm.box = "ubuntu/focal64"
config.vm.box = "bento/ubuntu-24.04"
config.vm.provider :virtualbox do |vb|
vb.name = "mastodon"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -48,6 +48,7 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController
default_privacy: source_params.fetch(:privacy, @account.user.setting_default_privacy),
default_sensitive: source_params.fetch(:sensitive, @account.user.setting_default_sensitive),
default_language: source_params.fetch(:language, @account.user.setting_default_language),
default_quote_policy: source_params.fetch(:quote_policy, @account.user.setting_default_quote_policy),
},
}
end

View File

@ -0,0 +1,33 @@
# frozen_string_literal: true
class Api::V1::Statuses::InteractionPoliciesController < Api::V1::Statuses::BaseController
include Api::InteractionPoliciesConcern
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }
before_action -> { check_feature_enabled }
def update
authorize @status, :update?
@status.update!(quote_approval_policy: quote_approval_policy)
broadcast_updates! if @status.quote_approval_policy_previously_changed?
render json: @status, serializer: REST::StatusSerializer
end
private
def status_params
params.permit(:quote_approval_policy)
end
def check_feature_enabled
raise ActionController::RoutingError unless Mastodon::Feature.outgoing_quotes_enabled?
end
def broadcast_updates!
DistributionWorker.perform_async(@status.id, { 'update' => true })
ActivityPub::StatusUpdateDistributionWorker.perform_async(@status.id, { 'updated_at' => Time.now.utc.iso8601 })
end
end

View File

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

View File

@ -3,6 +3,7 @@
class Api::V1::StatusesController < Api::BaseController
include Authorization
include AsyncRefreshesConcern
include Api::InteractionPoliciesConcern
before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [:create, :update, :destroy]
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:create, :update, :destroy]
@ -66,7 +67,11 @@ class Api::V1::StatusesController < Api::BaseController
add_async_refresh_header(async_refresh)
elsif !current_account.nil? && @status.should_fetch_replies?
add_async_refresh_header(AsyncRefresh.create(refresh_key))
ActivityPub::FetchAllRepliesWorker.perform_async(@status.id)
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)
@ -78,6 +83,7 @@ class Api::V1::StatusesController < Api::BaseController
text: status_params[:status],
thread: @thread,
quoted_status: @quoted_status,
quote_approval_policy: quote_approval_policy,
media_ids: status_params[:media_ids],
sensitive: status_params[:sensitive],
spoiler_text: status_params[:spoiler_text],
@ -109,7 +115,8 @@ class Api::V1::StatusesController < Api::BaseController
sensitive: status_params[:sensitive],
language: status_params[:language],
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
@ -176,6 +183,7 @@ class Api::V1::StatusesController < Api::BaseController
:status,
:in_reply_to_id,
:quoted_status_id,
:quote_approval_policy,
:sensitive,
:spoiler_text,
:visibility,

View File

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

View File

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

View File

@ -23,11 +23,11 @@ class Auth::RegistrationsController < Devise::RegistrationsController
super(&:build_invite_request)
end
def edit # rubocop:disable Lint/UselessMethodDefinition
def edit
super
end
def create # rubocop:disable Lint/UselessMethodDefinition
def create
super
end

View File

@ -12,6 +12,8 @@ class Auth::SessionsController < Devise::SessionsController
skip_before_action :require_functional!
skip_before_action :update_user_sign_in
around_action :preserve_stored_location, only: :destroy, if: :continue_after?
prepend_before_action :check_suspicious!, only: [:create]
include Auth::TwoFactorAuthenticationConcern
@ -31,11 +33,9 @@ class Auth::SessionsController < Devise::SessionsController
end
def destroy
tmp_stored_location = stored_location_for(:user)
super
session.delete(:challenge_passed_at)
flash.delete(:notice)
store_location_for(:user, tmp_stored_location) if continue_after?
end
def webauthn_options
@ -96,6 +96,12 @@ class Auth::SessionsController < Devise::SessionsController
private
def preserve_stored_location
original_stored_location = stored_location_for(:user)
yield
store_location_for(:user, original_stored_location)
end
def check_suspicious!
user = find_user
@login_is_suspicious = suspicious_sign_in?(user) unless user.nil?

View File

@ -0,0 +1,22 @@
# frozen_string_literal: true
module Api::InteractionPoliciesConcern
extend ActiveSupport::Concern
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
end

View File

@ -9,6 +9,8 @@ module SignatureVerification
EXPIRATION_WINDOW_LIMIT = 12.hours
CLOCK_SKEW_MARGIN = 1.hour
STOPLIGHT_COOL_OFF_TIME = 5.minutes.seconds
STOPLIGHT_THRESHOLD = 1
def require_account_signature!
render json: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_account
@ -107,10 +109,12 @@ module SignatureVerification
end
def stoplight_wrapper
Stoplight("source:#{request.remote_ip}")
.with_threshold(1)
.with_cool_off_time(5.minutes.seconds)
.with_error_handler { |error, handle| error.is_a?(HTTP::Error) || error.is_a?(OpenSSL::SSL::SSLError) ? handle.call(error) : raise(error) }
Stoplight(
"source:#{request.remote_ip}",
cool_off_time: STOPLIGHT_COOL_OFF_TIME,
threshold: STOPLIGHT_THRESHOLD,
tracked_errors: [HTTP::Error, OpenSSL::SSL::SSLError]
)
end
def actor_refresh_key!(actor)

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
class Settings::Preferences::PostingDefaultsController < Settings::Preferences::BaseController
private
def after_update_redirect_path
settings_preferences_posting_defaults_path
end
end

View File

@ -52,7 +52,7 @@ module Settings
end
else
flash[:error] = I18n.t('webauthn_credentials.create.error')
status = :unprocessable_entity
status = :unprocessable_content
end
else
flash[:error] = t('webauthn_credentials.create.error')

View File

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

View File

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

View File

@ -102,6 +102,16 @@ module ApplicationHelper
policy(record).public_send(:"#{action}?")
end
def conditional_link_to(condition, name, options = {}, html_options = {}, &block)
if condition && !current_page?(block_given? ? name : options)
link_to(name, options, html_options, &block)
elsif block_given?
content_tag(:span, options, html_options, &block)
else
content_tag(:span, name, html_options)
end
end
def material_symbol(icon, attributes = {})
safe_join(
[
@ -233,6 +243,10 @@ module ApplicationHelper
tag.input(type: :text, maxlength: 999, spellcheck: false, readonly: true, **options)
end
def recent_tag_users(tag)
tag.statuses.public_visibility.joins(:account).merge(Account.without_suspended.without_silenced).includes(:account).limit(3).map(&:account)
end
def recent_tag_usage(tag)
people = tag.history.aggregate(2.days.ago.to_date..Time.zone.today).accounts
I18n.t 'user_mailer.welcome.hashtags_recent_count', people: number_with_delimiter(people), count: people

View File

@ -39,6 +39,12 @@ module ContextHelper
'automaticApproval' => { '@id' => 'gts:automaticApproval', '@type' => '@id' },
'manualApproval' => { '@id' => 'gts:manualApproval', '@type' => '@id' },
},
quote_authorizations: {
'gts' => 'https://gotosocial.org/ns#',
'quoteAuthorization' => { '@id' => 'https://w3id.org/fep/044f#quoteAuthorization', '@type' => '@id' },
'interactingObject' => { '@id' => 'gts:interactingObject' },
'interactionTarget' => { '@id' => 'gts:interactionTarget' },
},
}.freeze
def full_context

View File

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

View File

@ -39,16 +39,6 @@ module HomeHelper
end
end
def obscured_counter(count)
if count <= 0
'0'
elsif count == 1
'1'
else
'1+'
end
end
def field_verified_class(verified)
if verified
'verified'

View File

@ -1 +1,3 @@
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.

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -84,6 +84,7 @@ export const COMPOSE_FOCUS = 'COMPOSE_FOCUS';
const messages = defineMessages({
uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' },
uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' },
uploadQuote: { id: 'upload_error.quote', defaultMessage: 'File upload not allowed with quotes.' },
open: { id: 'compose.published.open', defaultMessage: 'Open' },
published: { id: 'compose.published.body', defaultMessage: 'Post published.' },
saved: { id: 'compose.saved.body', defaultMessage: 'Post saved.' },
@ -96,12 +97,17 @@ export const ensureComposeIsVisible = (getState) => {
};
export function setComposeToStatus(status, text, spoiler_text) {
return{
type: COMPOSE_SET_STATUS,
status,
text,
spoiler_text,
};
return (dispatch, getState) => {
const maxOptions = getState().server.getIn(['server', 'configuration', 'polls', 'max_options']);
dispatch({
type: COMPOSE_SET_STATUS,
status,
text,
spoiler_text,
maxOptions,
});
}
}
export function changeCompose(text) {
@ -146,7 +152,7 @@ export function resetCompose() {
};
}
export const focusCompose = (defaultText) => (dispatch, getState) => {
export const focusCompose = (defaultText = '') => (dispatch, getState) => {
dispatch({
type: COMPOSE_FOCUS,
defaultText,
@ -183,7 +189,7 @@ export function directCompose(account) {
};
}
export function submitCompose() {
export function submitCompose(successCallback) {
return function (dispatch, getState) {
const status = getState().getIn(['compose', 'text'], '');
const media = getState().getIn(['compose', 'media_attachments']);
@ -215,6 +221,7 @@ export function submitCompose() {
});
}
const visibility = getState().getIn(['compose', 'privacy']);
api().request({
url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`,
method: statusId === null ? 'post' : 'put',
@ -225,9 +232,11 @@ export function submitCompose() {
media_attributes,
sensitive: getState().getIn(['compose', 'sensitive']),
spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '',
visibility: getState().getIn(['compose', 'privacy']),
visibility: visibility,
poll: getState().getIn(['compose', 'poll'], null),
language: getState().getIn(['compose', 'language']),
quoted_status_id: getState().getIn(['compose', 'quoted_status_id']),
quote_approval_policy: visibility === 'private' || visibility === 'direct' ? 'nobody' : getState().getIn(['compose', 'quote_policy']),
},
headers: {
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
@ -239,6 +248,9 @@ export function submitCompose() {
dispatch(insertIntoTagHistory(response.data.tags, status));
dispatch(submitComposeSuccess({ ...response.data }));
if (typeof successCallback === 'function') {
successCallback(response.data);
}
// To make the app more responsive, immediately push the status
// into the columns
@ -298,6 +310,11 @@ export function submitComposeFail(error) {
export function uploadCompose(files) {
return function (dispatch, getState) {
// Exit if there's a quote.
if (getState().compose.get('quoted_status_id')) {
dispatch(showAlert({ message: messages.uploadQuote }));
return;
}
const uploadLimit = getState().getIn(['server', 'server', 'configuration', 'statuses', 'max_media_attachments']);
const media = getState().getIn(['compose', 'media_attachments']);
const pending = getState().getIn(['compose', 'pending_media_attachments']);

View File

@ -1,9 +1,40 @@
import { defineMessages } from 'react-intl';
import { createAction } from '@reduxjs/toolkit';
import type { List as ImmutableList, Map as ImmutableMap } from 'immutable';
import { apiUpdateMedia } from 'mastodon/api/compose';
import type { ApiMediaAttachmentJSON } from 'mastodon/api_types/media_attachments';
import type { MediaAttachment } from 'mastodon/models/media_attachment';
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
import {
createDataLoadingThunk,
createAppThunk,
} from 'mastodon/store/typed_functions';
import type { ApiQuotePolicy } from '../api_types/quotes';
import type { Status } from '../models/status';
import { showAlert } from './alerts';
import { focusCompose } from './compose';
const messages = defineMessages({
quoteErrorUpload: {
id: 'quote_error.upload',
defaultMessage: 'Quoting is not allowed with media attachments.',
},
quoteErrorPoll: {
id: 'quote_error.poll',
defaultMessage: 'Quoting is not allowed with polls.',
},
quoteErrorQuote: {
id: 'quote_error.quote',
defaultMessage: 'Only one quote at a time is allowed.',
},
quoteErrorUnauthorized: {
id: 'quote_error.unauthorized',
defaultMessage: 'You are not authorized to quote this post.',
},
});
type SimulatedMediaAttachmentJSON = ApiMediaAttachmentJSON & {
unattached?: boolean;
@ -68,3 +99,55 @@ export const changeUploadCompose = createDataLoadingThunk(
useLoadingBar: false,
},
);
export const quoteCompose = createAppThunk(
'compose/quoteComposeStatus',
(status: Status, { dispatch }) => {
dispatch(focusCompose());
return status;
},
);
export const quoteComposeByStatus = createAppThunk(
(status: Status, { dispatch, getState }) => {
const composeState = getState().compose;
const mediaAttachments = composeState.get('media_attachments');
if (composeState.get('poll')) {
dispatch(showAlert({ message: messages.quoteErrorPoll }));
} else if (
composeState.get('is_uploading') ||
(mediaAttachments &&
typeof mediaAttachments !== 'string' &&
typeof mediaAttachments !== 'number' &&
typeof mediaAttachments !== 'boolean' &&
mediaAttachments.size !== 0)
) {
dispatch(showAlert({ message: messages.quoteErrorUpload }));
} else if (composeState.get('quoted_status_id')) {
dispatch(showAlert({ message: messages.quoteErrorQuote }));
} else if (
status.getIn(['quote_approval', 'current_user']) !== 'automatic' &&
status.getIn(['quote_approval', 'current_user']) !== 'manual'
) {
dispatch(showAlert({ message: messages.quoteErrorUnauthorized }));
} else {
dispatch(quoteCompose(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 setComposeQuotePolicy = createAction<ApiQuotePolicy>(
'compose/setQuotePolicy',
);

View File

@ -1,8 +1,13 @@
import { apiReblog, apiUnreblog } from 'mastodon/api/interactions';
import {
apiReblog,
apiUnreblog,
apiRevokeQuote,
apiGetQuotes,
} from 'mastodon/api/interactions';
import type { StatusVisibility } from 'mastodon/models/status';
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
import { importFetchedStatus } from './importer';
import { importFetchedStatus, importFetchedStatuses } from './importer';
export const reblog = createDataLoadingThunk(
'status/reblog',
@ -33,3 +38,35 @@ export const unreblog = createDataLoadingThunk(
return discardLoadData;
},
);
export const revokeQuote = createDataLoadingThunk(
'status/revoke_quote',
({
statusId,
quotedStatusId,
}: {
statusId: string;
quotedStatusId: string;
}) => apiRevokeQuote(quotedStatusId, statusId),
(data, { dispatch, discardLoadData }) => {
dispatch(importFetchedStatus(data));
return discardLoadData;
},
);
export const fetchQuotes = createDataLoadingThunk(
'status/fetch_quotes',
async ({ statusId, next }: { statusId: string; next?: string }) => {
const { links, statuses } = await apiGetQuotes(statusId, next);
return {
links,
statuses,
replace: !next,
};
},
(payload, { dispatch }) => {
dispatch(importFetchedStatuses(payload.statuses));
},
);

View File

@ -30,8 +30,21 @@ import { importFetchedAccounts, importFetchedStatuses } from './importer';
import { NOTIFICATIONS_FILTER_SET } from './notifications';
import { saveSettings } from './settings';
function notificationTypeForFilter(type: NotificationType) {
if (type === 'quoted_update') return 'update';
else return type;
}
function notificationTypeForQuickFilter(type: NotificationType) {
if (type === 'quoted_update') return 'update';
else if (type === 'quote') return 'mention';
else return type;
}
function excludeAllTypesExcept(filter: string) {
return allNotificationTypes.filter((item) => item !== filter);
return allNotificationTypes.filter(
(item) => notificationTypeForQuickFilter(item) !== filter,
);
}
function getExcludedTypes(state: RootState) {
@ -155,13 +168,17 @@ export const processNewNotificationForGroups = createAppAsyncThunk(
const showInColumn =
activeFilter === 'all'
? notificationShows[notification.type] !== false
: activeFilter === notification.type;
? notificationShows[notificationTypeForFilter(notification.type)] !==
false
: activeFilter === notificationTypeForQuickFilter(notification.type);
if (!showInColumn) return;
if (
(notification.type === 'mention' || notification.type === 'update') &&
(notification.type === 'mention' ||
notification.type === 'quote' ||
notification.type === 'update' ||
notification.type === 'quoted_update') &&
notification.status?.filtered
) {
const filters = notification.status.filtered.filter((result) =>

View File

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

View File

@ -1,9 +1,12 @@
import { defineMessages } from 'react-intl';
import { browserHistory } from 'mastodon/components/router';
import api from '../api';
import { showAlert } from './alerts';
import { ensureComposeIsVisible, setComposeToStatus } from './compose';
import { importFetchedStatus, importFetchedStatuses, importFetchedAccount } from './importer';
import { importFetchedStatus, importFetchedAccount } from './importer';
import { fetchContext } from './statuses_typed';
import { deleteFromTimelines } from './timelines';
@ -40,6 +43,10 @@ export const STATUS_TRANSLATE_SUCCESS = 'STATUS_TRANSLATE_SUCCESS';
export const STATUS_TRANSLATE_FAIL = 'STATUS_TRANSLATE_FAIL';
export const STATUS_TRANSLATE_UNDO = 'STATUS_TRANSLATE_UNDO';
const messages = defineMessages({
deleteSuccess: { id: 'status.delete.success', defaultMessage: 'Post deleted' },
});
export function fetchStatusRequest(id, skipLoading) {
return {
type: STATUS_FETCH_REQUEST,
@ -48,7 +55,18 @@ export function fetchStatusRequest(id, skipLoading) {
};
}
export function fetchStatus(id, forceFetch = false, alsoFetchContext = true) {
/**
* @param {string} id
* @param {Object} [options]
* @param {boolean} [options.forceFetch]
* @param {boolean} [options.alsoFetchContext]
* @param {string | null | undefined} [options.parentQuotePostId]
*/
export function fetchStatus(id, {
forceFetch = false,
alsoFetchContext = true,
parentQuotePostId,
} = {}) {
return (dispatch, getState) => {
const skipLoading = !forceFetch && getState().getIn(['statuses', id], null) !== null;
@ -66,7 +84,7 @@ export function fetchStatus(id, forceFetch = false, alsoFetchContext = true) {
dispatch(importFetchedStatus(response.data));
dispatch(fetchStatusSuccess(skipLoading));
}).catch(error => {
dispatch(fetchStatusFail(id, error, skipLoading));
dispatch(fetchStatusFail(id, error, skipLoading, parentQuotePostId));
});
};
}
@ -78,21 +96,27 @@ export function fetchStatusSuccess(skipLoading) {
};
}
export function fetchStatusFail(id, error, skipLoading) {
export function fetchStatusFail(id, error, skipLoading, parentQuotePostId) {
return {
type: STATUS_FETCH_FAIL,
id,
error,
parentQuotePostId,
skipLoading,
skipAlert: true,
};
}
export function redraft(status, raw_text) {
return {
type: REDRAFT,
status,
raw_text,
return (dispatch, getState) => {
const maxOptions = getState().server.getIn(['server', 'configuration', 'polls', 'max_options']);
dispatch({
type: REDRAFT,
status,
raw_text,
maxOptions,
});
};
}
@ -137,7 +161,7 @@ export function deleteStatus(id, withRedraft = false) {
dispatch(deleteStatusRequest(id));
api().delete(`/api/v1/statuses/${id}`, { params: { delete_media: !withRedraft } }).then(response => {
return api().delete(`/api/v1/statuses/${id}`, { params: { delete_media: !withRedraft } }).then(response => {
dispatch(deleteStatusSuccess(id));
dispatch(deleteFromTimelines(id));
dispatch(importFetchedAccount(response.data.account));
@ -145,9 +169,14 @@ export function deleteStatus(id, withRedraft = false) {
if (withRedraft) {
dispatch(redraft(status, response.data.text));
ensureComposeIsVisible(getState);
} else {
dispatch(showAlert({ message: messages.deleteSuccess }));
}
return response;
}).catch(error => {
dispatch(deleteStatusFail(id, error));
throw error;
});
};
}

View File

@ -1,8 +1,10 @@
import { createAction } from '@reduxjs/toolkit';
import { apiGetContext } from 'mastodon/api/statuses';
import { apiGetContext, apiSetQuotePolicy } from 'mastodon/api/statuses';
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
import type { ApiQuotePolicy } from '../api_types/quotes';
import { importFetchedStatuses } from './importer';
export const fetchContext = createDataLoadingThunk(
@ -23,3 +25,10 @@ export const fetchContext = createDataLoadingThunk(
export const completeContextRefresh = createAction<{ statusId: string }>(
'status/context/complete',
);
export const setStatusQuotePolicy = createDataLoadingThunk(
'status/setQuotePolicy',
({ statusId, policy }: { statusId: string; policy: ApiQuotePolicy }) => {
return apiSetQuotePolicy(statusId, policy);
},
);

View File

@ -1,10 +1,28 @@
import { apiRequestPost } from 'mastodon/api';
import type { Status, StatusVisibility } from 'mastodon/models/status';
import api, { apiRequestPost, getLinks } from 'mastodon/api';
import type { ApiStatusJSON } from 'mastodon/api_types/statuses';
import type { StatusVisibility } from 'mastodon/models/status';
export const apiReblog = (statusId: string, visibility: StatusVisibility) =>
apiRequestPost<{ reblog: Status }>(`v1/statuses/${statusId}/reblog`, {
apiRequestPost<{ reblog: ApiStatusJSON }>(`v1/statuses/${statusId}/reblog`, {
visibility,
});
export const apiUnreblog = (statusId: string) =>
apiRequestPost<Status>(`v1/statuses/${statusId}/unreblog`);
apiRequestPost<ApiStatusJSON>(`v1/statuses/${statusId}/unreblog`);
export const apiRevokeQuote = (quotedStatusId: string, statusId: string) =>
apiRequestPost<ApiStatusJSON>(
`v1/statuses/${quotedStatusId}/quotes/${statusId}/revoke`,
);
export const apiGetQuotes = async (statusId: string, url?: string) => {
const response = await api().request<ApiStatusJSON[]>({
method: 'GET',
url: url ?? `/api/v1/statuses/${statusId}/quotes`,
});
return {
statuses: response.data,
links: getLinks(response),
};
};

View File

@ -1,5 +1,10 @@
import api, { getAsyncRefreshHeader } from 'mastodon/api';
import type { ApiContextJSON } from 'mastodon/api_types/statuses';
import api, { apiRequestPut, getAsyncRefreshHeader } from 'mastodon/api';
import type {
ApiContextJSON,
ApiStatusJSON,
} from 'mastodon/api_types/statuses';
import type { ApiQuotePolicy } from '../api_types/quotes';
export const apiGetContext = async (statusId: string) => {
const response = await api().request<ApiContextJSON>({
@ -12,3 +17,15 @@ export const apiGetContext = async (statusId: string) => {
refresh: getAsyncRefreshHeader(response),
};
};
export const apiSetQuotePolicy = async (
statusId: string,
policy: ApiQuotePolicy,
) => {
return apiRequestPut<ApiStatusJSON>(
`v1/statuses/${statusId}/interaction_policy`,
{
quote_approval_policy: policy,
},
);
};

View File

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

View File

@ -7,12 +7,13 @@ import type { ApiReportJSON } from './reports';
import type { ApiStatusJSON } from './statuses';
// See app/model/notification.rb
export const allNotificationTypes = [
export const allNotificationTypes: NotificationType[] = [
'follow',
'follow_request',
'favourite',
'reblog',
'mention',
'quote',
'poll',
'status',
'update',
@ -28,8 +29,10 @@ export type NotificationWithStatusType =
| 'reblog'
| 'status'
| 'mention'
| 'quote'
| 'poll'
| 'update';
| 'update'
| 'quoted_update';
export type NotificationType =
| NotificationWithStatusType

View File

@ -0,0 +1,38 @@
import type { ApiStatusJSON } from './statuses';
export type ApiQuoteState = 'accepted' | 'pending' | 'revoked' | 'unauthorized';
export type ApiQuotePolicy =
| 'public'
| 'followers'
| 'nobody'
| 'unsupported_policy';
export type ApiUserQuotePolicy = 'automatic' | 'manual' | 'denied' | 'unknown';
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;
export interface ApiQuotePolicyJSON {
automatic: ApiQuotePolicy[];
manual: ApiQuotePolicy[];
current_user: ApiUserQuotePolicy;
}
export function isQuotePolicy(policy: string): policy is ApiQuotePolicy {
return ['public', 'followers', 'nobody'].includes(policy);
}

View File

@ -4,6 +4,7 @@ import type { ApiAccountJSON } from './accounts';
import type { ApiCustomEmojiJSON } from './custom_emoji';
import type { ApiMediaAttachmentJSON } from './media_attachments';
import type { ApiPollJSON } from './polls';
import type { ApiQuoteJSON, ApiQuotePolicyJSON } from './quotes';
// See app/modals/status.rb
export type StatusVisibility =
@ -95,6 +96,7 @@ export interface ApiStatusJSON {
replies_count: number;
reblogs_count: number;
favorites_count: number;
quotes_count: number;
edited_at?: string;
favorited?: boolean;
@ -118,9 +120,23 @@ export interface ApiStatusJSON {
card?: ApiPreviewCardJSON;
poll?: ApiPollJSON;
quote?: ApiQuoteJSON;
quote_approval?: ApiQuotePolicyJSON;
}
export interface ApiContextJSON {
ancestors: ApiStatusJSON[];
descendants: ApiStatusJSON[];
}
export interface ApiStatusSourceJSON {
id: string;
text: string;
spoiler_text: string;
}
export function isStatusVisibility(
visibility: string,
): visibility is StatusVisibility {
return ['public', 'unlisted', 'private', 'direct'].includes(visibility);
}

View File

@ -53,6 +53,7 @@ const AutosuggestTextarea = forwardRef(({
onFocus,
autoFocus = true,
lang,
className,
}, textareaRef) => {
const [suggestionsHidden, setSuggestionsHidden] = useState(true);
@ -192,7 +193,7 @@ const AutosuggestTextarea = forwardRef(({
};
return (
<div className='autosuggest-textarea'>
<div className={classNames('autosuggest-textarea', className)}>
<Textarea
ref={textareaRef}
className='autosuggest-textarea__textarea'

View File

@ -0,0 +1,114 @@
import { useCallback, useId, useMemo, useRef, useState } from 'react';
import type { ComponentPropsWithoutRef, FC } from 'react';
import { FormattedMessage } from 'react-intl';
import type { MessageDescriptor } from 'react-intl';
import classNames from 'classnames';
import Overlay from 'react-overlays/Overlay';
import type { SelectItem } from '../dropdown_selector';
import { DropdownSelector } from '../dropdown_selector';
interface DropdownProps {
title: string;
disabled?: boolean;
items: SelectItem[];
onChange: (value: string) => void;
current: string;
emptyText?: MessageDescriptor;
classPrefix: string;
}
export const Dropdown: FC<
DropdownProps & Omit<ComponentPropsWithoutRef<'button'>, keyof DropdownProps>
> = ({
title,
disabled,
items,
current,
onChange,
classPrefix,
className,
...buttonProps
}) => {
const buttonRef = useRef<HTMLButtonElement>(null);
const accessibilityId = useId();
const [open, setOpen] = useState(false);
const handleToggle = useCallback(() => {
if (!disabled) {
setOpen((prevOpen) => !prevOpen);
}
}, [disabled]);
const handleClose = useCallback(() => {
setOpen(false);
}, []);
const currentText = useMemo(
() => items.find((i) => i.value === current)?.text,
[current, items],
);
return (
<>
<button
type='button'
{...buttonProps}
title={title}
aria-expanded={open}
aria-controls={accessibilityId}
onClick={handleToggle}
disabled={disabled}
className={classNames(
`${classPrefix}__button`,
{
active: open,
disabled,
},
className,
)}
ref={buttonRef}
>
{currentText ?? (
<FormattedMessage
id='dropdown.empty'
defaultMessage='Select an option'
/>
)}
</button>
<Overlay
show={open}
offset={[0, 4]}
placement='bottom-start'
onHide={handleClose}
flip
target={buttonRef.current}
popperConfig={{
strategy: 'fixed',
}}
>
{({ props, placement }) => (
<div {...props} className={`${classPrefix}__overlay`}>
<div
className={classNames(
'dropdown-animation',
`${classPrefix}__dropdown`,
placement,
)}
id={accessibilityId}
>
<DropdownSelector
items={items}
value={current}
onClose={handleClose}
onChange={onChange}
classNamePrefix={classPrefix}
/>
</div>
</div>
)}
</Overlay>
</>
);
};

View File

@ -41,13 +41,16 @@ import { IconButton } from './icon_button';
let id = 0;
type RenderItemFn<Item = MenuItem> = (
export interface RenderItemFnHandlers {
onClick: React.MouseEventHandler;
onKeyUp: React.KeyboardEventHandler;
}
export type RenderItemFn<Item = MenuItem> = (
item: Item,
index: number,
handlers: {
onClick: (e: React.MouseEvent) => void;
onKeyUp: (e: React.KeyboardEvent) => void;
},
handlers: RenderItemFnHandlers,
focusRefCallback?: (c: HTMLAnchorElement | HTMLButtonElement | null) => void,
) => React.ReactNode;
type ItemClickFn<Item = MenuItem> = (item: Item, index: number) => void;
@ -173,7 +176,7 @@ export const DropdownMenu = <Item = MenuItem,>({
onItemClick(item, i);
} else if (isActionItem(item)) {
e.preventDefault();
item.action();
item.action(e);
}
},
[onClose, onItemClick, items],
@ -277,10 +280,15 @@ export const DropdownMenu = <Item = MenuItem,>({
})}
>
{items.map((option, i) =>
renderItemMethod(option, i, {
onClick: handleItemClick,
onKeyUp: handleItemKeyUp,
}),
renderItemMethod(
option,
i,
{
onClick: handleItemClick,
onKeyUp: handleItemKeyUp,
},
i === 0 ? handleFocusedItemRef : undefined,
),
)}
</ul>
)}
@ -307,7 +315,9 @@ interface DropdownProps<Item = MenuItem> {
forceDropdown?: boolean;
renderItem?: RenderItemFn<Item>;
renderHeader?: RenderHeaderFn<Item>;
onOpen?: () => void;
onOpen?: // Must use a union type for the full function as a union with void is not allowed.
| ((event: React.MouseEvent | React.KeyboardEvent) => void)
| ((event: React.MouseEvent | React.KeyboardEvent) => boolean);
onItemClick?: ItemClickFn<Item>;
}
@ -376,7 +386,7 @@ export const Dropdown = <Item = MenuItem,>({
onItemClick(item, i);
} else if (isActionItem(item)) {
e.preventDefault();
item.action();
item.action(e);
}
},
[handleClose, onItemClick, items],
@ -389,7 +399,10 @@ export const Dropdown = <Item = MenuItem,>({
if (open) {
handleClose();
} else {
onOpen?.();
const allow = onOpen?.(e);
if (allow === false) {
return;
}
if (prefetchAccountId) {
dispatch(fetchRelationships([prefetchAccountId]));

View File

@ -13,8 +13,8 @@ const listenerOptions = supportsPassiveEvents
? { passive: true, capture: true }
: true;
export interface SelectItem {
value: string;
export interface SelectItem<Value extends string = string> {
value: Value;
icon?: string;
iconComponent?: IconProp;
text: string;
@ -24,7 +24,7 @@ export interface SelectItem {
interface Props {
value: string;
classNamePrefix: string;
classNamePrefix?: string;
style?: React.CSSProperties;
items: SelectItem[];
onChange: (value: string) => void;
@ -98,13 +98,13 @@ export const DropdownSelector: React.FC<Props> = ({
break;
case 'Tab':
if (e.shiftKey) {
element =
nodeRef.current?.children[index + 1] ??
nodeRef.current?.firstElementChild;
} else {
element =
nodeRef.current?.children[index - 1] ??
nodeRef.current?.lastElementChild;
} else {
element =
nodeRef.current?.children[index + 1] ??
nodeRef.current?.firstElementChild;
}
break;
case 'Home':

View File

@ -7,11 +7,7 @@ import { normalizeKey, isKeyboardEvent } from './utils';
* the hotkey with a higher priority is selected. All others
* are ignored.
*/
const hotkeyPriority = {
singleKey: 0,
combo: 1,
sequence: 2,
} as const;
const hotkeyPriority = { singleKey: 0, combo: 1, sequence: 2 } as const;
/**
* This type of function receives a keyboard event and an array of
@ -105,14 +101,16 @@ const hotkeyMatcherMap = {
new: just('n'),
forceNew: optionPlus('n'),
focusColumn: any('1', '2', '3', '4', '5', '6', '7', '8', '9'),
focusLoadMore: just('l'),
reply: just('r'),
favourite: just('f'),
boost: just('b'),
quote: just('q'),
mention: just('m'),
open: any('enter', 'o'),
openProfile: just('p'),
moveDown: any('down', 'j'),
moveUp: any('up', 'k'),
moveDown: just('j'),
moveUp: just('k'),
toggleHidden: just('x'),
toggleSensitive: just('h'),
toggleComposeSpoilers: optionPlus('x'),

View File

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

View File

@ -10,6 +10,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
import CancelFillIcon from '@/material-icons/400-24px/cancel-fill.svg?react';
import { Hotkeys } from 'mastodon/components/hotkeys';
import { ContentWarning } from 'mastodon/components/content_warning';
import { FilterWarning } from 'mastodon/components/filter_warning';
@ -34,6 +35,8 @@ import StatusActionBar from './status_action_bar';
import StatusContent from './status_content';
import { StatusThreadLabel } from './status_thread_label';
import { VisibilityIcon } from './visibility_icon';
import { IconButton } from './icon_button';
const domParser = new DOMParser();
export const textForScreenReader = (intl, status, rebloggedByText = false) => {
@ -75,6 +78,7 @@ const messages = defineMessages({
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Specific people' },
edited: { id: 'status.edited', defaultMessage: 'Edited {date}' },
quote_cancel: { id: 'status.quote.cancel', defaultMessage: 'Cancel quote' },
});
class Status extends ImmutablePureComponent {
@ -92,6 +96,7 @@ class Status extends ImmutablePureComponent {
onReply: PropTypes.func,
onFavourite: PropTypes.func,
onReblog: PropTypes.func,
onQuote: PropTypes.func,
onDelete: PropTypes.func,
onDirect: PropTypes.func,
onMention: PropTypes.func,
@ -106,11 +111,10 @@ class Status extends ImmutablePureComponent {
onToggleCollapsed: PropTypes.func,
onTranslate: PropTypes.func,
onInteractionModal: PropTypes.func,
onQuoteCancel: PropTypes.func,
muted: PropTypes.bool,
hidden: PropTypes.bool,
unread: PropTypes.bool,
onMoveUp: PropTypes.func,
onMoveDown: PropTypes.func,
showThread: PropTypes.bool,
isQuotedPost: PropTypes.bool,
getScrollPosition: PropTypes.func,
@ -126,6 +130,7 @@ class Status extends ImmutablePureComponent {
inUse: PropTypes.bool,
available: PropTypes.bool,
}),
contextType: PropTypes.string,
...WithOptionalRouterPropTypes,
};
@ -272,6 +277,10 @@ class Status extends ImmutablePureComponent {
this.props.onReblog(this._properStatus(), e);
};
handleHotkeyQuote = () => {
this.props.onQuote(this._properStatus());
};
handleHotkeyMention = e => {
e.preventDefault();
this.props.onMention(this._properStatus().get('account'));
@ -322,14 +331,6 @@ class Status extends ImmutablePureComponent {
history.push(`/@${status.getIn(['account', 'acct'])}`);
};
handleHotkeyMoveUp = e => {
this.props.onMoveUp?.(this.props.status.get('id'), this.node.getAttribute('data-featured'));
};
handleHotkeyMoveDown = e => {
this.props.onMoveDown?.(this.props.status.get('id'), this.node.getAttribute('data-featured'));
};
handleHotkeyToggleHidden = () => {
const { onToggleHidden } = this.props;
const status = this._properStatus();
@ -359,6 +360,10 @@ class Status extends ImmutablePureComponent {
this.setState(state => ({ ...state, showDespiteFilter: !state.showDespiteFilter }));
};
handleQuoteCancel = () => {
this.props.onQuoteCancel?.();
}
_properStatus () {
const { status } = this.props;
@ -386,11 +391,10 @@ class Status extends ImmutablePureComponent {
reply: this.handleHotkeyReply,
favourite: this.handleHotkeyFavourite,
boost: this.handleHotkeyBoost,
quote: this.handleHotkeyQuote,
mention: this.handleHotkeyMention,
open: this.handleHotkeyOpen,
openProfile: this.handleHotkeyOpenProfile,
moveUp: this.handleHotkeyMoveUp,
moveDown: this.handleHotkeyMoveDown,
toggleHidden: this.handleHotkeyToggleHidden,
toggleSensitive: this.handleHotkeyToggleSensitive,
openMedia: this.handleHotkeyOpenMedia,
@ -573,6 +577,16 @@ class Status extends ImmutablePureComponent {
<DisplayName account={status.get('account')} />
</Link>
{isQuotedPost && !!this.props.onQuoteCancel && (
<IconButton
onClick={this.handleQuoteCancel}
className='status__quote-cancel'
title={intl.formatMessage(messages.quote_cancel)}
icon="cancel-fill"
iconComponent={CancelFillIcon}
/>
)}
</div>
{matchedFilters && <FilterWarning title={matchedFilters.join(', ')} expanded={this.state.showDespiteFilter} onClick={this.handleFilterToggle} />}

View File

@ -0,0 +1,89 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import type { StatusVisibility } from '@/mastodon/api_types/statuses';
import { statusFactoryState } from '@/testing/factories';
import { LegacyReblogButton, StatusReblogButton } from './reblog_button';
interface StoryProps {
visibility: StatusVisibility;
quoteAllowed: boolean;
alreadyBoosted: boolean;
reblogCount: number;
}
const meta = {
title: 'Components/Status/ReblogButton',
args: {
visibility: 'public',
quoteAllowed: true,
alreadyBoosted: false,
reblogCount: 0,
},
argTypes: {
visibility: {
name: 'Visibility',
control: { type: 'select' },
options: ['public', 'unlisted', 'private', 'direct'],
},
reblogCount: {
name: 'Boost Count',
description: 'More than 0 will show the counter',
},
quoteAllowed: {
name: 'Quotes allowed',
},
alreadyBoosted: {
name: 'Already boosted',
},
},
render: (args) => (
<StatusReblogButton
status={argsToStatus(args)}
counters={args.reblogCount > 0}
/>
),
} satisfies Meta<StoryProps>;
export default meta;
function argsToStatus({
reblogCount,
visibility,
quoteAllowed,
alreadyBoosted,
}: StoryProps) {
return statusFactoryState({
reblogs_count: reblogCount,
visibility,
reblogged: alreadyBoosted,
quote_approval: {
automatic: [],
manual: [],
current_user: quoteAllowed ? 'automatic' : 'denied',
},
});
}
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Mine: Story = {
parameters: {
state: {
meta: {
me: '1',
},
},
},
};
export const Legacy: Story = {
render: (args) => (
<LegacyReblogButton
status={argsToStatus(args)}
counters={args.reblogCount > 0}
/>
),
};

View File

@ -0,0 +1,425 @@
import { useCallback, useMemo } from 'react';
import type {
FC,
KeyboardEvent,
MouseEvent,
MouseEventHandler,
SVGProps,
} from 'react';
import type { MessageDescriptor } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
import classNames from 'classnames';
import { quoteComposeById } from '@/mastodon/actions/compose_typed';
import { toggleReblog } from '@/mastodon/actions/interactions';
import { openModal } from '@/mastodon/actions/modal';
import type { ActionMenuItem } from '@/mastodon/models/dropdown_menu';
import type { Status, StatusVisibility } from '@/mastodon/models/status';
import {
createAppSelector,
useAppDispatch,
useAppSelector,
} from '@/mastodon/store';
import { isFeatureEnabled } from '@/mastodon/utils/environment';
import FormatQuote from '@/material-icons/400-24px/format_quote.svg?react';
import FormatQuoteOff from '@/material-icons/400-24px/format_quote_off.svg?react';
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
import RepeatActiveIcon from '@/svg-icons/repeat_active.svg?react';
import RepeatDisabledIcon from '@/svg-icons/repeat_disabled.svg?react';
import RepeatPrivateIcon from '@/svg-icons/repeat_private.svg?react';
import RepeatPrivateActiveIcon from '@/svg-icons/repeat_private_active.svg?react';
import type { RenderItemFn, RenderItemFnHandlers } from '../dropdown_menu';
import { Dropdown } from '../dropdown_menu';
import { Icon } from '../icon';
import { IconButton } from '../icon_button';
const messages = defineMessages({
all_disabled: {
id: 'status.all_disabled',
defaultMessage: 'Boosts and quotes are disabled',
},
quote: { id: 'status.quote', defaultMessage: 'Quote' },
quote_cannot: {
id: 'status.cannot_quote',
defaultMessage: 'Author has disabled quoting on this post',
},
quote_followers_only: {
id: 'status.quote_followers_only',
defaultMessage: 'Only followers can quote this post',
},
quote_manual_review: {
id: 'status.quote_manual_review',
defaultMessage: 'Author will manually review',
},
quote_private: {
id: 'status.quote_private',
defaultMessage: 'Private posts cannot be quoted',
},
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
reblog_or_quote: {
id: 'status.reblog_or_quote',
defaultMessage: 'Boost or quote',
},
reblog_cancel: {
id: 'status.cancel_reblog_private',
defaultMessage: 'Unboost',
},
reblog_private: {
id: 'status.reblog_private',
defaultMessage: 'Boost with original visibility',
},
reblog_cannot: {
id: 'status.cannot_reblog',
defaultMessage: 'This post cannot be boosted',
},
request_quote: {
id: 'status.request_quote',
defaultMessage: 'Request to quote',
},
});
interface ReblogButtonProps {
status: Status;
counters?: boolean;
}
export const StatusReblogButton: FC<ReblogButtonProps> = ({
status,
counters,
}) => {
const intl = useIntl();
const statusState = useAppSelector((state) =>
selectStatusState(state, status),
);
const {
isLoggedIn,
isReblogged,
isReblogAllowed,
isQuoteAutomaticallyAccepted,
isQuoteManuallyAccepted,
} = statusState;
const { iconComponent } = useMemo(
() => reblogIconText(statusState),
[statusState],
);
const disabled =
!isQuoteAutomaticallyAccepted &&
!isQuoteManuallyAccepted &&
!isReblogAllowed;
const dispatch = useAppDispatch();
const statusId = status.get('id') as string;
const items: ActionMenuItem[] = useMemo(
() => [
{
text: 'reblog',
action: (event) => {
if (isLoggedIn) {
dispatch(toggleReblog(statusId, event.shiftKey));
}
},
},
{
text: 'quote',
action: () => {
if (isLoggedIn) {
dispatch(quoteComposeById(statusId));
}
},
},
],
[dispatch, isLoggedIn, statusId],
);
const handleDropdownOpen = useCallback(
(event: MouseEvent | KeyboardEvent) => {
if (!isLoggedIn) {
dispatch(
openModal({
modalType: 'INTERACTION',
modalProps: {
type: 'reblog',
accountId: status.getIn(['account', 'id']),
url: status.get('uri'),
},
}),
);
} else if (event.shiftKey) {
dispatch(toggleReblog(status.get('id'), true));
return false;
}
return true;
},
[dispatch, isLoggedIn, status],
);
const renderMenuItem: RenderItemFn<ActionMenuItem> = useCallback(
(item, index, handlers, focusRefCallback) => (
<ReblogMenuItem
status={status}
index={index}
item={item}
handlers={handlers}
key={`${item.text}-${index}`}
focusRefCallback={focusRefCallback}
/>
),
[status],
);
return (
<Dropdown
items={items}
renderItem={renderMenuItem}
onOpen={handleDropdownOpen}
disabled={disabled}
>
<IconButton
title={intl.formatMessage(
!disabled ? messages.reblog_or_quote : messages.all_disabled,
)}
icon='retweet'
iconComponent={iconComponent}
counter={
counters
? (status.get('reblogs_count') as number) +
(status.get('quotes_count') as number)
: undefined
}
active={isReblogged}
/>
</Dropdown>
);
};
interface ReblogMenuItemProps {
status: Status;
item: ActionMenuItem;
index: number;
handlers: RenderItemFnHandlers;
focusRefCallback?: (c: HTMLAnchorElement | HTMLButtonElement | null) => void;
}
const ReblogMenuItem: FC<ReblogMenuItemProps> = ({
status,
index,
item: { text },
handlers,
focusRefCallback,
}) => {
const intl = useIntl();
const statusState = useAppSelector((state) =>
selectStatusState(state, status),
);
const { title, meta, iconComponent, disabled } = useMemo(
() =>
text === 'quote'
? quoteIconText(statusState)
: reblogIconText(statusState),
[statusState, text],
);
const active = useMemo(
() => text === 'reblog' && !!status.get('reblogged'),
[status, text],
);
return (
<li
className={classNames('dropdown-menu__item reblog-button__item', {
disabled,
active,
})}
key={`${text}-${index}`}
>
<button
{...handlers}
title={intl.formatMessage(title)}
ref={focusRefCallback}
disabled={disabled}
data-index={index}
>
<Icon
id={text === 'quote' ? 'quote' : 'retweet'}
icon={iconComponent}
/>
<div>
{intl.formatMessage(title)}
{meta && (
<span className='reblog-button__meta'>
{intl.formatMessage(meta)}
</span>
)}
</div>
</button>
</li>
);
};
// Legacy helpers
// Switch between the legacy and new reblog button based on feature flag.
export const ReblogButton: FC<ReblogButtonProps> = (props) => {
if (isFeatureEnabled('outgoing_quotes')) {
return <StatusReblogButton {...props} />;
}
return <LegacyReblogButton {...props} />;
};
export const LegacyReblogButton: FC<ReblogButtonProps> = ({
status,
counters,
}) => {
const intl = useIntl();
const statusState = useAppSelector((state) =>
selectStatusState(state, status),
);
const { title, meta, iconComponent, disabled } = useMemo(
() => reblogIconText(statusState),
[statusState],
);
const dispatch = useAppDispatch();
const handleClick: MouseEventHandler = useCallback(
(event) => {
if (statusState.isLoggedIn) {
dispatch(toggleReblog(status.get('id') as string, event.shiftKey));
} else {
dispatch(
openModal({
modalType: 'INTERACTION',
modalProps: {
type: 'reblog',
accountId: status.getIn(['account', 'id']),
url: status.get('uri'),
},
}),
);
}
},
[dispatch, status, statusState.isLoggedIn],
);
return (
<IconButton
disabled={disabled}
active={!!status.get('reblogged')}
title={intl.formatMessage(meta ?? title)}
icon='retweet'
iconComponent={iconComponent}
onClick={!disabled ? handleClick : undefined}
counter={
counters
? (status.get('reblogs_count') as number) +
(status.get('quotes_count') as number)
: undefined
}
/>
);
};
// Helpers for copy and state for status.
const selectStatusState = createAppSelector(
[
(state) => state.meta.get('me') as string | undefined,
(_, status: Status) => status,
],
(userId, status) => {
const isPublic = ['public', 'unlisted'].includes(
status.get('visibility') as StatusVisibility,
);
const isMineAndPrivate =
userId === status.getIn(['account', 'id']) &&
status.get('visibility') === 'private';
return {
isLoggedIn: !!userId,
isPublic,
isMine: userId === status.getIn(['account', 'id']),
isPrivateReblog:
userId === status.getIn(['account', 'id']) &&
status.get('visibility') === 'private',
isReblogged: !!status.get('reblogged'),
isReblogAllowed: isPublic || isMineAndPrivate,
isQuoteAutomaticallyAccepted:
status.getIn(['quote_approval', 'current_user']) === 'automatic' &&
(isPublic || isMineAndPrivate),
isQuoteManuallyAccepted:
status.getIn(['quote_approval', 'current_user']) === 'manual' &&
(isPublic || isMineAndPrivate),
isQuoteFollowersOnly:
status.getIn(['quote_approval', 'automatic', 0]) === 'followers' ||
status.getIn(['quote_approval', 'manual', 0]) === 'followers',
};
},
);
type StatusState = ReturnType<typeof selectStatusState>;
interface IconText {
title: MessageDescriptor;
meta?: MessageDescriptor;
iconComponent: FC<SVGProps<SVGSVGElement>>;
disabled?: boolean;
}
function reblogIconText({
isPublic,
isPrivateReblog,
isReblogged,
}: StatusState): IconText {
if (isReblogged) {
return {
title: messages.reblog_cancel,
iconComponent: isPublic ? RepeatActiveIcon : RepeatPrivateActiveIcon,
};
}
const iconText: IconText = {
title: messages.reblog,
iconComponent: RepeatIcon,
};
if (isPrivateReblog) {
iconText.meta = messages.reblog_private;
iconText.iconComponent = RepeatPrivateIcon;
} else if (!isPublic) {
iconText.meta = messages.reblog_cannot;
iconText.iconComponent = RepeatDisabledIcon;
iconText.disabled = true;
}
return iconText;
}
function quoteIconText({
isMine,
isQuoteAutomaticallyAccepted,
isQuoteManuallyAccepted,
isQuoteFollowersOnly,
isPublic,
}: StatusState): IconText {
const iconText: IconText = {
title: messages.quote,
iconComponent: FormatQuote,
};
if (!isPublic && !isMine) {
iconText.disabled = true;
iconText.iconComponent = FormatQuoteOff;
iconText.meta = messages.quote_private;
} else if (isQuoteAutomaticallyAccepted) {
iconText.title = messages.quote;
} else if (isQuoteManuallyAccepted) {
iconText.title = messages.request_quote;
iconText.meta = messages.quote_manual_review;
} else {
iconText.disabled = true;
iconText.iconComponent = FormatQuoteOff;
iconText.meta = isQuoteFollowersOnly
? messages.quote_followers_only
: messages.quote_cannot;
}
return iconText;
}

View File

@ -2,7 +2,6 @@ import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import classNames from 'classnames';
import { withRouter } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
@ -12,15 +11,10 @@ import { connect } from 'react-redux';
import BookmarkIcon from '@/material-icons/400-24px/bookmark-fill.svg?react';
import BookmarkBorderIcon from '@/material-icons/400-24px/bookmark.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react';
import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
import StarBorderIcon from '@/material-icons/400-24px/star.svg?react';
import RepeatActiveIcon from '@/svg-icons/repeat_active.svg?react';
import RepeatDisabledIcon from '@/svg-icons/repeat_disabled.svg?react';
import RepeatPrivateIcon from '@/svg-icons/repeat_private.svg?react';
import RepeatPrivateActiveIcon from '@/svg-icons/repeat_private_active.svg?react';
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/permissions';
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
@ -29,6 +23,8 @@ import { Dropdown } from 'mastodon/components/dropdown_menu';
import { me } from '../initial_state';
import { IconButton } from './icon_button';
import { isFeatureEnabled } from '../utils/environment';
import { ReblogButton } from './status/reblog_button';
const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' },
@ -42,10 +38,6 @@ const messages = defineMessages({
share: { id: 'status.share', defaultMessage: 'Share' },
more: { id: 'status.more', defaultMessage: 'More' },
replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
favourite: { id: 'status.favourite', defaultMessage: 'Favorite' },
removeFavourite: { id: 'status.remove_favourite', defaultMessage: 'Remove from favorites' },
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
@ -67,21 +59,29 @@ const messages = defineMessages({
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
filter: { id: 'status.filter', defaultMessage: 'Filter this post' },
openOriginalPage: { id: 'account.open_original_page', defaultMessage: 'Open original page' },
revokeQuote: { id: 'status.revoke_quote', defaultMessage: 'Remove my post from @{name}s post' },
quotePolicyChange: { id: 'status.quote_policy_change', defaultMessage: 'Change who can quote' },
});
const mapStateToProps = (state, { status }) => ({
relationship: state.getIn(['relationships', status.getIn(['account', 'id'])]),
});
const mapStateToProps = (state, { status }) => {
const quotedStatusId = status.getIn(['quote', 'quoted_status']);
return ({
relationship: state.getIn(['relationships', status.getIn(['account', 'id'])]),
quotedAccountId: quotedStatusId ? state.getIn(['statuses', quotedStatusId, 'account']) : null,
});
};
class StatusActionBar extends ImmutablePureComponent {
static propTypes = {
identity: identityContextPropShape,
status: ImmutablePropTypes.map.isRequired,
relationship: ImmutablePropTypes.record,
quotedAccountId: PropTypes.string,
onReply: PropTypes.func,
onFavourite: PropTypes.func,
onReblog: PropTypes.func,
onDelete: PropTypes.func,
onRevokeQuote: PropTypes.func,
onQuotePolicyChange: PropTypes.func,
onDirect: PropTypes.func,
onMention: PropTypes.func,
onMute: PropTypes.func,
@ -110,6 +110,7 @@ class StatusActionBar extends ImmutablePureComponent {
updateOnProps = [
'status',
'relationship',
'quotedAccountId',
'withDismiss',
];
@ -141,16 +142,6 @@ class StatusActionBar extends ImmutablePureComponent {
}
};
handleReblogClick = e => {
const { signedIn } = this.props.identity;
if (signedIn) {
this.props.onReblog(this.props.status, e);
} else {
this.props.onInteractionModal('reblog', this.props.status);
}
};
handleBookmarkClick = () => {
this.props.onBookmark(this.props.status);
};
@ -190,6 +181,14 @@ class StatusActionBar extends ImmutablePureComponent {
}
};
handleRevokeQuoteClick = () => {
this.props.onRevokeQuote(this.props.status);
};
handleQuotePolicyChange = () => {
this.props.onQuotePolicyChange(this.props.status);
};
handleBlockClick = () => {
const { status, relationship, onBlock, onUnblock } = this.props;
const account = status.get('account');
@ -241,7 +240,7 @@ class StatusActionBar extends ImmutablePureComponent {
};
render () {
const { status, relationship, intl, withDismiss, withCounters, scrollKey } = this.props;
const { status, relationship, quotedAccountId, intl, withDismiss, withCounters, scrollKey } = this.props;
const { signedIn, permissions } = this.props.identity;
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
@ -279,6 +278,9 @@ class StatusActionBar extends ImmutablePureComponent {
if (writtenByMe || withDismiss) {
menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
if (writtenByMe && isFeatureEnabled('outgoing_quotes') && !['private', 'direct'].includes(status.get('visibility'))) {
menu.push({ text: intl.formatMessage(messages.quotePolicyChange), action: this.handleQuotePolicyChange });
}
menu.push(null);
}
@ -291,6 +293,10 @@ class StatusActionBar extends ImmutablePureComponent {
menu.push({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.handleDirectClick });
menu.push(null);
if (quotedAccountId === me) {
menu.push({ text: intl.formatMessage(messages.revokeQuote, { name: account.get('username') }), action: this.handleRevokeQuoteClick, dangerous: true });
}
if (relationship && relationship.get('muting')) {
menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.handleMuteClick });
} else {
@ -351,25 +357,6 @@ class StatusActionBar extends ImmutablePureComponent {
replyTitle = intl.formatMessage(messages.replyAll);
}
const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private';
let reblogTitle, reblogIconComponent;
if (status.get('reblogged')) {
reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
reblogIconComponent = publicStatus ? RepeatActiveIcon : RepeatPrivateActiveIcon;
} else if (publicStatus) {
reblogTitle = intl.formatMessage(messages.reblog);
reblogIconComponent = RepeatIcon;
} else if (reblogPrivate) {
reblogTitle = intl.formatMessage(messages.reblog_private);
reblogIconComponent = RepeatPrivateIcon;
} else {
reblogTitle = intl.formatMessage(messages.cannot_reblog);
reblogIconComponent = RepeatDisabledIcon;
}
const bookmarkTitle = intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark);
const favouriteTitle = intl.formatMessage(status.get('favourited') ? messages.removeFavourite : messages.favourite);
const isReply = status.get('in_reply_to_account_id') === status.getIn(['account', 'id']);
@ -380,7 +367,7 @@ class StatusActionBar extends ImmutablePureComponent {
<IconButton className='status__action-bar__button' title={replyTitle} icon={isReply ? 'reply' : replyIcon} iconComponent={isReply ? ReplyIcon : replyIconComponent} onClick={this.handleReplyClick} counter={status.get('replies_count')} />
</div>
<div className='status__action-bar__button-wrapper'>
<IconButton className={classNames('status__action-bar__button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' iconComponent={reblogIconComponent} onClick={this.handleReblogClick} counter={withCounters ? status.get('reblogs_count') : undefined} />
<ReblogButton status={status} counters={withCounters} />
</div>
<div className='status__action-bar__button-wrapper'>
<IconButton className='status__action-bar__button star-icon' animate active={status.get('favourited')} title={favouriteTitle} icon='star' iconComponent={status.get('favourited') ? StarIcon : StarBorderIcon} onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} />

View File

@ -138,6 +138,16 @@ class StatusContent extends PureComponent {
onCollapsedToggle(collapsed);
}
// Remove quote fallback link from the DOM so it doesn't
// mess with paragraph margins
if (!!status.get('quote')) {
const inlineQuote = node.querySelector('.quote-inline');
if (inlineQuote) {
inlineQuote.remove();
}
}
}
handleMouseEnter = ({ currentTarget }) => {

View File

@ -14,6 +14,7 @@ import { StatusQuoteManager } from '../components/status_quoted';
import { LoadGap } from './load_gap';
import ScrollableList from './scrollable_list';
export default class StatusList extends ImmutablePureComponent {
static propTypes = {
@ -40,84 +41,6 @@ export default class StatusList extends ImmutablePureComponent {
trackScroll: true,
};
componentDidMount() {
this.columnHeaderHeight = this.node?.node
? parseFloat(
getComputedStyle(this.node.node).getPropertyValue('--column-header-height')
) || 0
: 0;
}
getFeaturedStatusCount = () => {
return this.props.featuredStatusIds ? this.props.featuredStatusIds.size : 0;
};
getCurrentStatusIndex = (id, featured) => {
if (featured) {
return this.props.featuredStatusIds.indexOf(id);
} else {
return this.props.statusIds.indexOf(id) + this.getFeaturedStatusCount();
}
};
handleMoveUp = (id, featured) => {
const index = this.getCurrentStatusIndex(id, featured);
this._selectChild(id, index, -1);
};
handleMoveDown = (id, featured) => {
const index = this.getCurrentStatusIndex(id, featured);
this._selectChild(id, index, 1);
};
_selectChild = (id, index, direction) => {
const listContainer = this.node?.node;
let listItem = listContainer?.querySelector(
// :nth-child uses 1-based indexing
`.item-list > :nth-child(${index + 1 + direction})`
);
if (!listItem) {
return;
}
// If selected container element is empty, we skip it
if (listItem.matches(':empty')) {
this._selectChild(id, index + direction, direction);
return;
}
// Check if the list item is a post
let targetElement = listItem.querySelector('.focusable');
// Otherwise, check if the item contains follow suggestions or
// is a 'load more' button.
if (
!targetElement && (
listItem.querySelector('.inline-follow-suggestions') ||
listItem.matches('.load-more')
)
) {
targetElement = listItem;
}
if (targetElement) {
const elementRect = targetElement.getBoundingClientRect();
const isFullyVisible =
elementRect.top >= this.columnHeaderHeight &&
elementRect.bottom <= window.innerHeight;
if (!isFullyVisible) {
targetElement.scrollIntoView({
block: direction === 1 ? 'start' : 'center',
});
}
targetElement.focus();
}
}
handleLoadOlder = debounce(() => {
const { statusIds, lastId, onLoadMore } = this.props;
onLoadMore(lastId || (statusIds.size > 0 ? statusIds.last() : undefined));
@ -158,8 +81,6 @@ export default class StatusList extends ImmutablePureComponent {
<StatusQuoteManager
key={statusId}
id={statusId}
onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown}
contextType={timelineId}
scrollKey={this.props.scrollKey}
showThread
@ -176,8 +97,6 @@ export default class StatusList extends ImmutablePureComponent {
key={`f-${statusId}`}
id={statusId}
featured
onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown}
contextType={timelineId}
showThread
withCounters={this.props.withCounters}
@ -191,5 +110,4 @@ export default class StatusList extends ImmutablePureComponent {
</ScrollableList>
);
}
}

View File

@ -3,19 +3,15 @@ import { useEffect, useMemo } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import type { Map as ImmutableMap } from 'immutable';
import 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 { LearnMoreLink } from 'mastodon/components/learn_more_link';
import StatusContainer from 'mastodon/containers/status_container';
import type { Status } from 'mastodon/models/status';
import type { RootState } from 'mastodon/store';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import QuoteIcon from '../../images/quote.svg?react';
import { fetchStatus } from '../actions/statuses';
import { makeGetStatus } from '../selectors';
@ -31,41 +27,31 @@ const QuoteWrapper: React.FC<{
'status__quote--error': isError,
})}
>
<Icon id='quote' icon={QuoteIcon} className='status__quote-icon' />
{children}
</div>
);
};
const NestedQuoteLink: React.FC<{
status: Status;
}> = ({ status }) => {
const NestedQuoteLink: React.FC<{ status: Status }> = ({ status }) => {
const accountId = status.get('account') as string;
const account = useAppSelector((state) =>
accountId ? state.accounts.get(accountId) : undefined,
);
const quoteAuthorName = account?.display_name_html;
const quoteAuthorName = account?.acct;
if (!quoteAuthorName) {
return null;
}
const quoteAuthorElement = (
<span dangerouslySetInnerHTML={{ __html: quoteAuthorName }} />
);
const quoteUrl = `/@${account.get('acct')}/${status.get('id') as string}`;
return (
<Link to={quoteUrl} className='status__quote-author-button'>
<div className='status__quote-author-button'>
<FormattedMessage
id='status.quote_post_author'
defaultMessage='Post by {name}'
values={{ name: quoteAuthorElement }}
defaultMessage='Quoted a post by @{name}'
values={{ name: quoteAuthorName }}
/>
<Icon id='chevron_right' icon={ChevronRightIcon} />
<Icon id='article' icon={ArticleIcon} />
</Link>
</div>
);
};
@ -75,24 +61,47 @@ type GetStatusSelector = (
props: { id?: string | null; contextType?: string },
) => Status | null;
export const QuotedStatus: React.FC<{
interface QuotedStatusProps {
quote: QuoteMap;
contextType?: string;
parentQuotePostId?: string | null;
variant?: 'full' | 'link';
nestingLevel?: number;
}> = ({ quote, contextType, nestingLevel = 1, variant = 'full' }) => {
onQuoteCancel?: () => void; // Used for composer.
}
export const QuotedStatus: React.FC<QuotedStatusProps> = ({
quote,
contextType,
parentQuotePostId,
nestingLevel = 1,
variant = 'full',
onQuoteCancel,
}) => {
const dispatch = useAppDispatch();
const quoteState = useAppSelector((state) =>
parentQuotePostId
? state.statuses.getIn([parentQuotePostId, 'quote', 'state'])
: quote.get('state'),
);
const quotedStatusId = quote.get('quoted_status');
const quoteState = quote.get('state');
const status = useAppSelector((state) =>
quotedStatusId ? state.statuses.get(quotedStatusId) : undefined,
);
const shouldLoadQuote = !status?.get('isLoading') && quoteState !== 'deleted';
useEffect(() => {
if (!status && quotedStatusId) {
dispatch(fetchStatus(quotedStatusId));
if (shouldLoadQuote && quotedStatusId) {
dispatch(
fetchStatus(quotedStatusId, {
parentQuotePostId,
alsoFetchContext: false,
}),
);
}
}, [status, quotedStatusId, dispatch]);
}, [shouldLoadQuote, quotedStatusId, parentQuotePostId, dispatch]);
// In order to find out whether the quoted post should be completely hidden
// due to a matching filter, we run it through the selector used by `status_container`.
@ -112,39 +121,42 @@ export const QuotedStatus: React.FC<{
defaultMessage='Hidden due to one of your filters'
/>
);
} else if (quoteState === 'deleted') {
quoteError = (
<FormattedMessage
id='status.quote_error.removed'
defaultMessage='This post was removed by its author.'
/>
);
} else if (quoteState === 'unauthorized') {
quoteError = (
<FormattedMessage
id='status.quote_error.unauthorized'
defaultMessage='This post cannot be displayed as you are not authorized to view it.'
/>
);
} else if (quoteState === 'pending') {
quoteError = (
<FormattedMessage
id='status.quote_error.pending_approval'
defaultMessage='This post is pending approval from the original author.'
/>
<>
<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 (quoteState === 'rejected' || quoteState === 'revoked') {
} else if (
!status ||
!quotedStatusId ||
quoteState === 'deleted' ||
quoteState === 'rejected' ||
quoteState === 'revoked' ||
quoteState === 'unauthorized'
) {
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.'
id='status.quote_error.not_available'
defaultMessage='Post unavailable'
/>
);
}
@ -168,11 +180,13 @@ export const QuotedStatus: React.FC<{
isQuotedPost
id={quotedStatusId}
contextType={contextType}
avatarSize={40}
avatarSize={32}
onQuoteCancel={onQuoteCancel}
>
{canRenderChildQuote && (
<QuotedStatus
quote={childQuote}
parentQuotePostId={quotedStatusId}
contextType={contextType}
variant={
nestingLevel === MAX_QUOTE_POSTS_NESTING_LEVEL ? 'link' : 'full'
@ -208,7 +222,11 @@ export const StatusQuoteManager = (props: StatusQuoteManagerProps) => {
if (quote) {
return (
<StatusContainer {...props}>
<QuotedStatus quote={quote} contextType={props.contextType} />
<QuotedStatus
quote={quote}
parentQuotePostId={status?.get('id') as string}
contextType={props.contextType}
/>
</StatusContainer>
);
}

View File

@ -12,6 +12,7 @@ import {
mentionCompose,
directCompose,
} from '../actions/compose';
import { quoteComposeById } from '../actions/compose_typed';
import {
initDomainBlockModal,
unblockDomain,
@ -41,10 +42,13 @@ import {
translateStatus,
undoStatusTranslation,
} from '../actions/statuses';
import { setStatusQuotePolicy } from '../actions/statuses_typed';
import Status from '../components/status';
import { deleteModal } from '../initial_state';
import { makeGetStatus, makeGetPictureInPicture } from '../selectors';
import { isFeatureEnabled } from 'mastodon/utils/environment';
const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const getPictureInPicture = makeGetPictureInPicture();
@ -75,6 +79,12 @@ const mapDispatchToProps = (dispatch, { contextType }) => ({
onReblog (status, e) {
dispatch(toggleReblog(status.get('id'), e.shiftKey));
},
onQuote (status) {
if (isFeatureEnabled('outgoing_quotes')) {
dispatch(quoteComposeById(status.get('id')));
}
},
onFavourite (status) {
dispatch(toggleFavourite(status.get('id')));
@ -107,10 +117,30 @@ const mapDispatchToProps = (dispatch, { contextType }) => ({
if (!deleteModal) {
dispatch(deleteStatus(status.get('id'), withRedraft));
} else {
dispatch(openModal({ modalType: 'CONFIRM_DELETE_STATUS', modalProps: { statusId: status.get('id'), withRedraft } }));
dispatch(openModal({
modalType: 'CONFIRM_DELETE_STATUS',
modalProps: {
statusId: status.get('id'),
withRedraft
}
}));
}
},
onRevokeQuote (status) {
dispatch(openModal({ modalType: 'CONFIRM_REVOKE_QUOTE', modalProps: { statusId: status.get('id'), quotedStatusId: status.getIn(['quote', 'quoted_status']) }}));
},
onQuotePolicyChange(status) {
const statusId = status.get('id');
const handleChange = (_, quotePolicy) => {
dispatch(
setStatusQuotePolicy({ policy: quotePolicy, statusId }),
);
}
dispatch(openModal({ modalType: 'COMPOSE_PRIVACY', modalProps: { statusId, onChange: handleChange } }));
},
onEdit (status) {
dispatch((_, getState) => {
let state = getState();

View File

@ -15,10 +15,8 @@ import { missingAltTextModal } from 'mastodon/initial_state';
import AutosuggestInput from 'mastodon/components/autosuggest_input';
import AutosuggestTextarea from 'mastodon/components/autosuggest_textarea';
import { Button } from 'mastodon/components/button';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
import PollButtonContainer from '../containers/poll_button_container';
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
import SpoilerButtonContainer from '../containers/spoiler_button_container';
import UploadButtonContainer from '../containers/upload_button_container';
import { countableText } from '../util/counter';
@ -31,6 +29,8 @@ import { PollForm } from "./poll_form";
import { ReplyIndicator } from './reply_indicator';
import { UploadForm } from './upload_form';
import { Warning } from './warning';
import { ComposeQuotedStatus } from './quoted_post';
import { VisibilityButton } from './visibility_button';
const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d';
@ -73,6 +73,7 @@ class ComposeForm extends ImmutablePureComponent {
singleColumn: PropTypes.bool,
lang: PropTypes.string,
maxChars: PropTypes.number,
redirectOnSuccess: PropTypes.bool,
};
static defaultProps = {
@ -255,62 +256,62 @@ class ComposeForm extends ImmutablePureComponent {
<Warning />
<div className={classNames('compose-form__highlightable', { active: highlighted })} ref={this.setRef}>
<div className='compose-form__scrollable'>
<EditIndicator />
<EditIndicator />
{this.props.spoiler && (
<div className='spoiler-input'>
<div className='spoiler-input__border' />
<AutosuggestInput
placeholder={intl.formatMessage(messages.spoiler_placeholder)}
value={this.props.spoilerText}
disabled={isSubmitting}
onChange={this.handleChangeSpoilerText}
onKeyDown={this.handleKeyDownSpoiler}
ref={this.setSpoilerText}
suggestions={this.props.suggestions}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={this.onSpoilerSuggestionSelected}
searchTokens={[':']}
id='cw-spoiler-input'
className='spoiler-input__input'
lang={this.props.lang}
spellCheck
/>
<div className='spoiler-input__border' />
</div>
)}
<AutosuggestTextarea
ref={this.textareaRef}
placeholder={intl.formatMessage(messages.placeholder)}
disabled={isSubmitting}
value={this.props.text}
onChange={this.handleChange}
suggestions={this.props.suggestions}
onFocus={this.handleFocus}
onKeyDown={this.handleKeyDownPost}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={this.onSuggestionSelected}
onPaste={onPaste}
autoFocus={autoFocus}
lang={this.props.lang}
/>
<div className='compose-form__dropdowns'>
<VisibilityButton disabled={this.props.isEditing} />
<LanguageDropdown />
</div>
{this.props.spoiler && (
<div className='spoiler-input'>
<div className='spoiler-input__border' />
<AutosuggestInput
placeholder={intl.formatMessage(messages.spoiler_placeholder)}
value={this.props.spoilerText}
disabled={isSubmitting}
onChange={this.handleChangeSpoilerText}
onKeyDown={this.handleKeyDownSpoiler}
ref={this.setSpoilerText}
suggestions={this.props.suggestions}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={this.onSpoilerSuggestionSelected}
searchTokens={[':']}
id='cw-spoiler-input'
className='spoiler-input__input'
lang={this.props.lang}
spellCheck
/>
<div className='spoiler-input__border' />
</div>
)}
<AutosuggestTextarea
ref={this.textareaRef}
placeholder={intl.formatMessage(messages.placeholder)}
disabled={isSubmitting}
value={this.props.text}
onChange={this.handleChange}
suggestions={this.props.suggestions}
onFocus={this.handleFocus}
onKeyDown={this.handleKeyDownPost}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={this.onSuggestionSelected}
onPaste={onPaste}
autoFocus={autoFocus}
lang={this.props.lang}
className='compose-form__input'
/>
<UploadForm />
<PollForm />
<ComposeQuotedStatus />
<div className='compose-form__footer'>
<div className='compose-form__dropdowns'>
<PrivacyDropdownContainer disabled={this.props.isEditing} />
<LanguageDropdown />
</div>
<div className='compose-form__actions'>
<div className='compose-form__buttons'>
<UploadButtonContainer />
@ -329,7 +330,7 @@ class ComposeForm extends ImmutablePureComponent {
>
{intl.formatMessage(
this.props.isEditing ?
messages.saveChanges :
messages.saveChanges :
(this.props.isInReply ? messages.reply : messages.publish)
)}
</Button>

View File

@ -396,7 +396,7 @@ export const LanguageDropdown: React.FC = () => {
warning: guess !== '' && guess !== value,
})}
>
<Icon id='' icon={TranslateIcon} />
<Icon id='translate' icon={TranslateIcon} />
<span className='dropdown-button__label'>{current[2] ?? value}</span>
</button>

View File

@ -14,7 +14,7 @@ import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react';
import { DropdownSelector } from 'mastodon/components/dropdown_selector';
import { Icon } from 'mastodon/components/icon';
const messages = defineMessages({
export const messages = defineMessages({
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
public_long: { id: 'privacy.public.long', defaultMessage: 'Anyone on and off Mastodon' },
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Quiet public' },

View File

@ -0,0 +1,38 @@
import { useCallback, useMemo } from 'react';
import type { FC } from 'react';
import { Map } from 'immutable';
import { quoteComposeCancel } from '@/mastodon/actions/compose_typed';
import { QuotedStatus } from '@/mastodon/components/status_quoted';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
export const ComposeQuotedStatus: FC = () => {
const quotedStatusId = useAppSelector(
(state) => state.compose.get('quoted_status_id') as string | null,
);
const isEditing = useAppSelector((state) => !!state.compose.get('id'));
const quote = useMemo(
() =>
quotedStatusId
? Map<'state' | 'quoted_status', string>([
['state', 'accepted'],
['quoted_status', quotedStatusId],
])
: null,
[quotedStatusId],
);
const dispatch = useAppDispatch();
const handleQuoteCancel = useCallback(() => {
dispatch(quoteComposeCancel());
}, [dispatch]);
if (!quote) {
return null;
}
return (
<QuotedStatus
quote={quote}
onQuoteCancel={!isEditing ? handleQuoteCancel : undefined}
/>
);
};

View File

@ -25,7 +25,7 @@ export const ReplyIndicator = () => {
<div className='reply-indicator__line' />
<Link to={`/@${account.get('acct')}`} className='detailed-status__display-avatar'>
<Avatar account={account} size={46} />
<Avatar key={`avatar-${account.get('id')}`} account={account} size={46} />
</Link>
<div className='reply-indicator__main'>

View File

@ -0,0 +1,148 @@
import { useCallback, useMemo } from 'react';
import type { FC } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import classNames from 'classnames';
import { changeComposeVisibility } from '@/mastodon/actions/compose';
import { setComposeQuotePolicy } from '@/mastodon/actions/compose_typed';
import { openModal } from '@/mastodon/actions/modal';
import type { ApiQuotePolicy } from '@/mastodon/api_types/quotes';
import type { StatusVisibility } from '@/mastodon/api_types/statuses';
import { Icon } from '@/mastodon/components/icon';
import { useAppSelector, useAppDispatch } from '@/mastodon/store';
import { isFeatureEnabled } from '@/mastodon/utils/environment';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react';
import type { VisibilityModalCallback } from '../../ui/components/visibility_modal';
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
import { messages as privacyMessages } from './privacy_dropdown';
const messages = defineMessages({
anyone_quote: {
id: 'privacy.quote.anyone',
defaultMessage: '{visibility}, anyone can quote',
},
limited_quote: {
id: 'privacy.quote.limited',
defaultMessage: '{visibility}, quotes limited',
},
disabled_quote: {
id: 'privacy.quote.disabled',
defaultMessage: '{visibility}, quotes disabled',
},
});
interface PrivacyDropdownProps {
disabled?: boolean;
}
export const VisibilityButton: FC<PrivacyDropdownProps> = (props) => {
if (!isFeatureEnabled('outgoing_quotes')) {
return <PrivacyDropdownContainer {...props} />;
}
return <PrivacyModalButton {...props} />;
};
const visibilityOptions = {
public: {
icon: 'globe',
iconComponent: PublicIcon,
value: 'public',
text: privacyMessages.public_short,
},
unlisted: {
icon: 'unlock',
iconComponent: QuietTimeIcon,
value: 'unlisted',
text: privacyMessages.unlisted_short,
},
private: {
icon: 'lock',
iconComponent: LockIcon,
value: 'private',
text: privacyMessages.private_short,
},
direct: {
icon: 'at',
iconComponent: AlternateEmailIcon,
value: 'direct',
text: privacyMessages.direct_short,
},
};
const PrivacyModalButton: FC<PrivacyDropdownProps> = ({ disabled = false }) => {
const intl = useIntl();
const { visibility, quotePolicy } = useAppSelector((state) => ({
visibility: state.compose.get('privacy') as StatusVisibility,
quotePolicy: state.compose.get('quote_policy') as ApiQuotePolicy,
}));
const { icon, iconComponent } = useMemo(() => {
const option = visibilityOptions[visibility];
return { icon: option.icon, iconComponent: option.iconComponent };
}, [visibility]);
const text = useMemo(() => {
const visibilityText = intl.formatMessage(
visibilityOptions[visibility].text,
);
if (visibility === 'private' || visibility === 'direct') {
return visibilityText;
}
if (quotePolicy === 'nobody') {
return intl.formatMessage(messages.disabled_quote, {
visibility: visibilityText,
});
}
if (quotePolicy !== 'public') {
return intl.formatMessage(messages.limited_quote, {
visibility: visibilityText,
});
}
return intl.formatMessage(messages.anyone_quote, {
visibility: visibilityText,
});
}, [quotePolicy, visibility, intl]);
const dispatch = useAppDispatch();
const handleChange: VisibilityModalCallback = useCallback(
(newVisibility, newQuotePolicy) => {
if (newVisibility !== visibility) {
dispatch(changeComposeVisibility(newVisibility));
}
if (newQuotePolicy !== quotePolicy) {
dispatch(setComposeQuotePolicy(newQuotePolicy));
}
},
[dispatch, quotePolicy, visibility],
);
const handleOpen = useCallback(() => {
dispatch(
openModal({
modalType: 'COMPOSE_PRIVACY',
modalProps: { onChange: handleChange },
}),
);
}, [dispatch, handleChange]);
return (
<button
type='button'
title={intl.formatMessage(privacyMessages.change_privacy)}
onClick={handleOpen}
disabled={disabled}
className={classNames('dropdown-button')}
>
<Icon id={icon} icon={iconComponent} />
<span className='dropdown-button__label'>{text}</span>
</button>
);
};

View File

@ -34,7 +34,7 @@ const mapStateToProps = state => ({
maxChars: state.getIn(['server', 'server', 'configuration', 'statuses', 'max_characters'], 500),
});
const mapDispatchToProps = (dispatch) => ({
const mapDispatchToProps = (dispatch, props) => ({
onChange (text) {
dispatch(changeCompose(text));
@ -47,7 +47,11 @@ const mapDispatchToProps = (dispatch) => ({
modalProps: {},
}));
} else {
dispatch(submitCompose());
dispatch(submitCompose((status) => {
if (props.redirectOnSuccess) {
window.location.assign(status.url);
}
}));
}
},

View File

@ -3,10 +3,16 @@ import { connect } from 'react-redux';
import { addPoll, removePoll } from '../../../actions/compose';
import PollButton from '../components/poll_button';
const mapStateToProps = state => ({
disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 0),
active: state.getIn(['compose', 'poll']) !== null,
});
const mapStateToProps = state => {
const readyAttachmentsSize = state.compose.get('media_attachments').size ?? 0;
const hasAttachments = readyAttachmentsSize > 0 || !!state.compose.get('is_uploading');
const hasQuote = !!state.compose.get('quoted_status_id');
return ({
disabled: hasAttachments || hasQuote,
active: state.getIn(['compose', 'poll']) !== null,
});
};
const mapDispatchToProps = dispatch => ({

View File

@ -11,9 +11,10 @@ const mapStateToProps = state => {
const attachmentsSize = readyAttachmentsSize + pendingAttachmentsSize;
const isOverLimit = attachmentsSize > state.getIn(['server', 'server', 'configuration', 'statuses', 'max_media_attachments'])-1;
const hasVideoOrAudio = state.getIn(['compose', 'media_attachments']).some(m => ['video', 'audio'].includes(m.get('type')));
const hasQuote = !!state.compose.get('quoted_status_id');
return {
disabled: isPoll || isUploading || isOverLimit || hasVideoOrAudio,
disabled: isPoll || isUploading || isOverLimit || hasVideoOrAudio || hasQuote,
resetFileKey: state.getIn(['compose', 'resetFileKey']),
};
};

View File

@ -45,7 +45,7 @@ const getAccounts = createSelector(
const getStatus = makeGetStatus();
export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown }) => {
export const Conversation = ({ conversation, scrollKey }) => {
const id = conversation.get('id');
const unread = conversation.get('unread');
const lastStatusId = conversation.get('last_status');
@ -110,14 +110,6 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
dispatch(deleteConversation(id));
}, [dispatch, id]);
const handleHotkeyMoveUp = useCallback(() => {
onMoveUp(id);
}, [id, onMoveUp]);
const handleHotkeyMoveDown = useCallback(() => {
onMoveDown(id);
}, [id, onMoveDown]);
const handleConversationMute = useCallback(() => {
if (lastStatus.get('muted')) {
dispatch(unmuteStatus(lastStatus.get('id')));
@ -161,8 +153,6 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
const handlers = {
reply: handleReply,
open: handleClick,
moveUp: handleHotkeyMoveUp,
moveDown: handleHotkeyMoveDown,
toggleHidden: handleShowMore,
};
@ -224,6 +214,4 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
Conversation.propTypes = {
conversation: ImmutablePropTypes.map.isRequired,
scrollKey: PropTypes.string,
onMoveUp: PropTypes.func,
onMoveDown: PropTypes.func,
};

View File

@ -10,20 +10,6 @@ import ScrollableList from 'mastodon/components/scrollable_list';
import { Conversation } from './conversation';
const focusChild = (node, index, alignTop) => {
const element = node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
if (element) {
if (alignTop && node.scrollTop > element.offsetTop) {
element.scrollIntoView(true);
} else if (!alignTop && node.scrollTop + node.clientHeight < element.offsetTop + element.offsetHeight) {
element.scrollIntoView(false);
}
element.focus();
}
};
export const ConversationsList = ({ scrollKey, ...other }) => {
const listRef = useRef();
const conversations = useSelector(state => state.getIn(['conversations', 'items']));
@ -32,16 +18,6 @@ export const ConversationsList = ({ scrollKey, ...other }) => {
const dispatch = useDispatch();
const lastStatusId = conversations.last()?.get('last_status');
const handleMoveUp = useCallback(id => {
const elementIndex = conversations.findIndex(x => x.get('id') === id) - 1;
focusChild(listRef.current.node, elementIndex, true);
}, [listRef, conversations]);
const handleMoveDown = useCallback(id => {
const elementIndex = conversations.findIndex(x => x.get('id') === id) + 1;
focusChild(listRef.current.node, elementIndex, false);
}, [listRef, conversations]);
const debouncedLoadMore = useMemo(() => debounce(id => {
dispatch(expandConversations({ maxId: id }));
}, 300, { leading: true }), [dispatch]);
@ -58,8 +34,6 @@ export const ConversationsList = ({ scrollKey, ...other }) => {
<Conversation
key={item.get('id')}
conversation={item}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
scrollKey={scrollKey}
/>
))}

View File

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

View File

@ -9,6 +9,7 @@ import type {
UnicodeEmojiData,
LocaleOrCustom,
} from './types';
import { emojiLogger } from './utils';
interface EmojiDB extends LocaleTables, DBSchema {
custom: {
@ -36,40 +37,63 @@ interface LocaleTable {
}
type LocaleTables = Record<Locale, LocaleTable>;
type Database = IDBPDatabase<EmojiDB>;
const SCHEMA_VERSION = 1;
let db: IDBPDatabase<EmojiDB> | null = null;
const loadedLocales = new Set<Locale>();
async function loadDB() {
if (db) {
return db;
}
db = await openDB<EmojiDB>('mastodon-emoji', SCHEMA_VERSION, {
upgrade(database) {
const customTable = database.createObjectStore('custom', {
keyPath: 'shortcode',
autoIncrement: false,
});
customTable.createIndex('category', 'category');
const log = emojiLogger('database');
database.createObjectStore('etags');
// Loads the database in a way that ensures it's only loaded once.
const loadDB = (() => {
let dbPromise: Promise<Database> | null = null;
for (const locale of SUPPORTED_LOCALES) {
const localeTable = database.createObjectStore(locale, {
keyPath: 'hexcode',
// 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,
});
localeTable.createIndex('group', 'group');
localeTable.createIndex('label', 'label');
localeTable.createIndex('order', 'order');
localeTable.createIndex('tags', 'tags', { multiEntry: true });
}
},
});
return db;
}
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)));
@ -86,15 +110,15 @@ export async function putCustomEmojiData(emojis: CustomEmojiData[]) {
export async function putLatestEtag(etag: string, localeString: string) {
const locale = toSupportedLocaleOrCustom(localeString);
const db = await loadDB();
return db.put('etags', etag, locale);
await db.put('etags', etag, locale);
}
export async function searchEmojiByHexcode(
export async function loadEmojiByHexcode(
hexcode: string,
localeString: string,
) {
const locale = toSupportedLocale(localeString);
const db = await loadDB();
const locale = toLoadedLocale(localeString);
return db.get(locale, hexcode);
}
@ -102,45 +126,39 @@ export async function searchEmojisByHexcodes(
hexcodes: string[],
localeString: string,
) {
const locale = toSupportedLocale(localeString);
const db = await loadDB();
return db.getAll(
const locale = toLoadedLocale(localeString);
const sortedCodes = hexcodes.toSorted();
const results = await db.getAll(
locale,
IDBKeyRange.bound(hexcodes[0], hexcodes[hexcodes.length - 1]),
IDBKeyRange.bound(sortedCodes.at(0), sortedCodes.at(-1)),
);
return results.filter((emoji) => hexcodes.includes(emoji.hexcode));
}
export async function searchEmojiByTag(tag: string, localeString: string) {
const locale = toSupportedLocale(localeString);
const range = IDBKeyRange.only(tag.toLowerCase());
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 searchCustomEmojiByShortcode(shortcode: string) {
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();
return db.getAll(
const sortedCodes = shortcodes.toSorted();
const results = await db.getAll(
'custom',
IDBKeyRange.bound(shortcodes[0], shortcodes[shortcodes.length - 1]),
IDBKeyRange.bound(sortedCodes.at(0), sortedCodes.at(-1)),
);
}
export async function findMissingLocales(localeStrings: string[]) {
const locales = new Set(localeStrings.map(toSupportedLocale));
const missingLocales: Locale[] = [];
const db = await loadDB();
for (const locale of locales) {
const rowCount = await db.count(locale);
if (!rowCount) {
missingLocales.push(locale);
}
}
return missingLocales;
return results.filter((emoji) => shortcodes.includes(emoji.shortcode));
}
export async function loadLatestEtag(localeString: string) {
@ -153,3 +171,51 @@ export async function loadLatestEtag(localeString: string) {
const etag = await db.get('etags', locale);
return etag ?? null;
}
// Private functions
async function syncLocales(db: Database) {
const locales = await Promise.all(
SUPPORTED_LOCALES.map(
async (locale) =>
[locale, await hasLocale(locale, db)] satisfies [Locale, boolean],
),
);
for (const [locale, loaded] of locales) {
if (loaded) {
loadedLocales.add(locale);
} else {
loadedLocales.delete(locale);
}
}
log('Loaded %d locales: %o', loadedLocales.size, loadedLocales);
}
function toLoadedLocale(localeString: string) {
const locale = toSupportedLocale(localeString);
if (localeString !== locale) {
log(`Locale ${locale} is different from provided ${localeString}`);
}
if (!loadedLocales.has(locale)) {
throw new Error(`Locale ${locale} is not loaded in emoji database`);
}
return locale;
}
async function hasLocale(locale: Locale, db: Database): Promise<boolean> {
if (loadedLocales.has(locale)) {
return true;
}
const rowCount = await db.count(locale);
return !!rowCount;
}
// Testing helpers
export async function testGet() {
const db = await loadDB();
return { db, loadedLocales };
}
export function testClear() {
loadedLocales.clear();
loadDB.reset();
}

View File

@ -1,81 +1,44 @@
import type { HTMLAttributes } from 'react';
import { useEffect, useMemo, useState } from 'react';
import type { ComponentPropsWithoutRef, ElementType } from 'react';
import type { List as ImmutableList } from 'immutable';
import { isList } from 'immutable';
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
import type { CustomEmoji } from '@/mastodon/models/custom_emoji';
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
import { useEmojiAppState } from './hooks';
import { emojifyElement } from './render';
import type { ExtraCustomEmojiMap } from './types';
import { useEmojify } from './hooks';
import type { CustomEmojiMapArg } from './types';
type EmojiHTMLProps = Omit<
HTMLAttributes<HTMLDivElement>,
type EmojiHTMLProps<Element extends ElementType = 'div'> = Omit<
ComponentPropsWithoutRef<Element>,
'dangerouslySetInnerHTML'
> & {
htmlString: string;
extraEmojis?: ExtraCustomEmojiMap | ImmutableList<CustomEmoji>;
extraEmojis?: CustomEmojiMapArg;
as?: Element;
};
export const EmojiHTML: React.FC<EmojiHTMLProps> = ({
htmlString,
export const ModernEmojiHTML = <Element extends ElementType>({
extraEmojis,
htmlString,
as: asElement, // Rename for syntax highlighting
...props
}) => {
if (isModernEmojiEnabled()) {
return (
<ModernEmojiHTML
htmlString={htmlString}
extraEmojis={extraEmojis}
{...props}
/>
);
}
return <div dangerouslySetInnerHTML={{ __html: htmlString }} {...props} />;
};
}: EmojiHTMLProps<Element>) => {
const Wrapper = asElement ?? 'div';
const emojifiedHtml = useEmojify(htmlString, extraEmojis);
const ModernEmojiHTML: React.FC<EmojiHTMLProps> = ({
extraEmojis: rawEmojis,
htmlString: text,
...props
}) => {
const appState = useEmojiAppState();
const [innerHTML, setInnerHTML] = useState('');
const extraEmojis: ExtraCustomEmojiMap = useMemo(() => {
if (!rawEmojis) {
return {};
}
if (isList(rawEmojis)) {
return (
rawEmojis.toJS() as ApiCustomEmojiJSON[]
).reduce<ExtraCustomEmojiMap>(
(acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }),
{},
);
}
return rawEmojis;
}, [rawEmojis]);
useEffect(() => {
if (!text) {
return;
}
const cb = async () => {
const div = document.createElement('div');
div.innerHTML = text;
const ele = await emojifyElement(div, appState, extraEmojis);
setInnerHTML(ele.innerHTML);
};
void cb();
}, [text, appState, extraEmojis]);
if (!innerHTML) {
if (emojifiedHtml === null) {
return null;
}
return <div {...props} dangerouslySetInnerHTML={{ __html: innerHTML }} />;
return (
<Wrapper {...props} dangerouslySetInnerHTML={{ __html: emojifiedHtml }} />
);
};
export const EmojiHTML = <Element extends ElementType>(
props: EmojiHTMLProps<Element>,
) => {
if (isModernEmojiEnabled()) {
return <ModernEmojiHTML {...props} />;
}
const { as: asElement, htmlString, extraEmojis, ...rest } = props;
const Wrapper = asElement ?? 'div';
return <Wrapper {...rest} dangerouslySetInnerHTML={{ __html: htmlString }} />;
};

View File

@ -1,45 +0,0 @@
import { useEffect, useState } from 'react';
import { useEmojiAppState } from './hooks';
import { emojifyText } from './render';
interface EmojiTextProps {
text: string;
}
export const EmojiText: React.FC<EmojiTextProps> = ({ text }) => {
const appState = useEmojiAppState();
const [rendered, setRendered] = useState<(string | HTMLImageElement)[]>([]);
useEffect(() => {
const cb = async () => {
const rendered = await emojifyText(text, appState);
setRendered(rendered ?? []);
};
void cb();
}, [text, appState]);
if (rendered.length === 0) {
return null;
}
return (
<>
{rendered.map((fragment, index) => {
if (typeof fragment === 'string') {
return <span key={index}>{fragment}</span>;
}
return (
<img
key={index}
draggable='false'
src={fragment.src}
alt={fragment.alt}
title={fragment.title}
className={fragment.className}
/>
);
})}
</>
);
};

View File

@ -1,8 +1,64 @@
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 { EmojiAppState } from './types';
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) =>
@ -12,5 +68,10 @@ export function useEmojiAppState(): EmojiAppState {
determineEmojiMode(state.meta.get('emoji_style') as string),
);
return { currentLocale: locale, locales: [locale], mode };
return {
currentLocale: locale,
locales: [locale],
mode,
darkTheme: document.body.classList.contains('theme-default'),
};
}

View File

@ -2,12 +2,16 @@ 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;
export async function initializeEmoji() {
const log = emojiLogger('index');
export function initializeEmoji() {
log('initializing emojis');
if (!worker && 'Worker' in window) {
try {
worker = loadWorker(new URL('./worker', import.meta.url), {
@ -21,9 +25,16 @@ export async function initializeEmoji() {
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
@ -31,15 +42,22 @@ export async function initializeEmoji() {
if (userLocale !== 'en') {
void loadEmojiLocale('en');
}
} else {
log('got worker message: %s', message);
}
});
} else {
const { importCustomEmojiData } = await import('./loader');
await importCustomEmojiData();
await loadEmojiLocale(userLocale);
if (userLocale !== 'en') {
await loadEmojiLocale('en');
}
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');
}
}

View File

@ -2,7 +2,6 @@ import { flattenEmojiData } from 'emojibase';
import type { CompactEmoji, FlatCompactEmoji } from 'emojibase';
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
import { isDevelopment } from '@/mastodon/utils/environment';
import {
putEmojiData,
@ -12,6 +11,9 @@ import {
} 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);
@ -20,6 +22,7 @@ export async function importEmojiData(localeString: string) {
return;
}
const flattenedEmojis: FlatCompactEmoji[] = flattenEmojiData(emojis);
log('loaded %d for %s locale', flattenedEmojis.length, locale);
await putEmojiData(flattenedEmojis, locale);
}
@ -28,6 +31,7 @@ export async function importCustomEmojiData() {
if (!emojis) {
return;
}
log('loaded %d custom emojis', emojis.length);
await putCustomEmojiData(emojis);
}
@ -41,7 +45,9 @@ async function fetchAndCheckEtag<ResultType extends object[]>(
if (locale === 'custom') {
url.pathname = '/api/v1/custom_emojis';
} else {
url.pathname = `/packs${isDevelopment() ? '-dev' : ''}/emoji/${locale}.json`;
// 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);

View File

@ -1,94 +1,184 @@
import { customEmojiFactory, unicodeEmojiFactory } from '@/testing/factories';
import {
EMOJI_MODE_NATIVE,
EMOJI_MODE_NATIVE_WITH_FLAGS,
EMOJI_MODE_TWEMOJI,
} from './constants';
import { emojifyElement, tokenizeText } from './render';
import type { CustomEmojiData, UnicodeEmojiData } from './types';
import * as db from './database';
import {
emojifyElement,
emojifyText,
testCacheClear,
tokenizeText,
} from './render';
import type { EmojiAppState, ExtraCustomEmojiMap } from './types';
vitest.mock('./database', () => ({
searchCustomEmojisByShortcodes: vitest.fn(
() =>
[
{
shortcode: 'custom',
static_url: 'emoji/static',
url: 'emoji/custom',
category: 'test',
visible_in_picker: true,
},
] satisfies CustomEmojiData[],
),
searchEmojisByHexcodes: vitest.fn(
() =>
[
{
function mockDatabase() {
return {
searchCustomEmojisByShortcodes: vi
.spyOn(db, 'searchCustomEmojisByShortcodes')
.mockResolvedValue([customEmojiFactory()]),
searchEmojisByHexcodes: vi
.spyOn(db, 'searchEmojisByHexcodes')
.mockResolvedValue([
unicodeEmojiFactory({
hexcode: '1F60A',
group: 0,
label: 'smiling face with smiling eyes',
order: 0,
tags: ['smile', 'happy'],
unicode: '😊',
},
{
}),
unicodeEmojiFactory({
hexcode: '1F1EA-1F1FA',
group: 0,
label: 'flag-eu',
order: 0,
tags: ['flag', 'european union'],
unicode: '🇪🇺',
},
] satisfies UnicodeEmojiData[],
),
findMissingLocales: vitest.fn(() => []),
}));
}),
]),
};
}
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', () => {
const testElement = document.createElement('div');
testElement.innerHTML = '<p>Hello 😊🇪🇺!</p><p>:custom:</p>';
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/static" data-original="emoji/custom" data-static="emoji/static">';
function cloneTestElement() {
return testElement.cloneNode(true) as HTMLElement;
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 emojifiedElement = await emojifyElement(cloneTestElement(), {
locales: ['en'],
mode: EMOJI_MODE_NATIVE,
currentLocale: 'en',
});
expect(emojifiedElement.innerHTML).toBe(
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 emojifiedElement = await emojifyElement(cloneTestElement(), {
locales: ['en'],
mode: EMOJI_MODE_NATIVE_WITH_FLAGS,
currentLocale: 'en',
});
expect(emojifiedElement.innerHTML).toBe(
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 emojifiedElement = await emojifyElement(cloneTestElement(), {
locales: ['en'],
mode: EMOJI_MODE_TWEMOJI,
currentLocale: 'en',
});
expect(emojifiedElement.innerHTML).toBe(
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}`);
});
});

View File

@ -1,8 +1,7 @@
import type { Locale } from 'emojibase';
import EMOJI_REGEX from 'emojibase-regex/emoji-loose';
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,
@ -12,11 +11,9 @@ import {
EMOJI_STATE_MISSING,
} from './constants';
import {
findMissingLocales,
searchCustomEmojisByShortcodes,
searchEmojisByHexcodes,
} from './database';
import { loadEmojiLocale } from './index';
import {
emojiToUnicodeHex,
twemojiHasBorder,
@ -34,18 +31,38 @@ import type {
LocaleOrCustom,
UnicodeEmojiToken,
} from './types';
import { stringHasUnicodeFlags } from './utils';
import {
anyEmojiRegex,
emojiLogger,
stringHasAnyEmoji,
stringHasUnicodeFlags,
} from './utils';
const localeCacheMap = new Map<LocaleOrCustom, EmojiStateMap>([
[EMOJI_TYPE_CUSTOM, new Map()],
]);
const log = emojiLogger('render');
// Emojifies an element. This modifies the element in place, replacing text nodes with emojified versions.
/**
* 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> {
): 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();
@ -61,7 +78,7 @@ export async function emojifyElement<Element extends HTMLElement>(
current.textContent &&
(current instanceof Text || !current.hasChildNodes())
) {
const renderedContent = await emojifyText(
const renderedContent = await textToElementArray(
current.textContent,
appState,
extraEmojis,
@ -70,7 +87,7 @@ export async function emojifyElement<Element extends HTMLElement>(
if (!(current instanceof Text)) {
current.textContent = null; // Clear the text content if it's not a Text node.
}
current.replaceWith(renderedToHTMLFragment(renderedContent));
current.replaceWith(renderedToHTML(renderedContent));
}
continue;
}
@ -81,6 +98,8 @@ export async function emojifyElement<Element extends HTMLElement>(
}
}
}
updateCache(cacheKey, element.innerHTML);
perf.stop('emojifyElement()');
return element;
}
@ -88,7 +107,54 @@ 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;
@ -102,10 +168,9 @@ export async function emojifyText(
}
// Get all emoji from the state map, loading any missing ones.
await ensureLocalesAreLoaded(appState.locales);
await loadMissingEmojiIntoCache(tokens, appState.locales);
await loadMissingEmojiIntoCache(tokens, appState, extraEmojis);
const renderedFragments: (string | HTMLImageElement)[] = [];
const renderedFragments: EmojifiedTextArray = [];
for (const token of tokens) {
if (typeof token !== 'string' && shouldRenderImage(token, appState.mode)) {
let state: EmojiState | undefined;
@ -125,7 +190,7 @@ export async function emojifyText(
// If the state is valid, create an image element. Otherwise, just append as text.
if (state && typeof state !== 'string') {
const image = stateToImage(state);
const image = stateToImage(state, appState);
renderedFragments.push(image);
continue;
}
@ -137,21 +202,6 @@ export async function emojifyText(
return renderedFragments;
}
// Private functions
async function ensureLocalesAreLoaded(locales: Locale[]) {
const missingLocales = await findMissingLocales(locales);
for (const locale of missingLocales) {
await loadEmojiLocale(locale);
}
}
const CUSTOM_EMOJI_REGEX = /:([a-z0-9_]+):/i;
const TOKENIZE_REGEX = new RegExp(
`(${EMOJI_REGEX.source}|${CUSTOM_EMOJI_REGEX.source})`,
'g',
);
type TokenizedText = (string | EmojiToken)[];
export function tokenizeText(text: string): TokenizedText {
@ -161,7 +211,7 @@ export function tokenizeText(text: string): TokenizedText {
const tokens = [];
let lastIndex = 0;
for (const match of text.matchAll(TOKENIZE_REGEX)) {
for (const match of text.matchAll(anyEmojiRegex())) {
if (match.index > lastIndex) {
tokens.push(text.slice(lastIndex, match.index));
}
@ -189,8 +239,18 @@ export function tokenizeText(text: string): TokenizedText {
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) ?? (new Map() as EmojiStateMap);
return (
localeCacheMap.get(locale) ??
createLimitedCache<EmojiState>({ log: log.extend(locale) })
);
}
function emojiForLocale(
@ -203,7 +263,8 @@ function emojiForLocale(
async function loadMissingEmojiIntoCache(
tokens: TokenizedText,
locales: Locale[],
{ mode, currentLocale }: EmojiAppState,
extraEmojis: ExtraCustomEmojiMap,
) {
const missingUnicodeEmoji = new Set<string>();
const missingCustomEmoji = new Set<string>();
@ -217,42 +278,41 @@ async function loadMissingEmojiIntoCache(
// 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 {
} else if (shouldRenderImage(token, mode)) {
const code = emojiToUnicodeHex(token.code);
if (missingUnicodeEmoji.has(code)) {
continue; // Already marked as missing.
}
for (const locale of locales) {
const emojiState = emojiForLocale(code, locale);
if (!emojiState) {
// If it's missing in one locale, we consider it missing for all.
missingUnicodeEmoji.add(code);
}
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();
for (const locale of locales) {
const emojis = await searchEmojisByHexcodes(missingEmojis, locale);
const cache = cacheForLocale(locale);
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(locale, cache);
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) {
@ -288,22 +348,24 @@ function shouldRenderImage(token: EmojiToken, mode: EmojiMode): boolean {
return true;
}
function stateToImage(state: EmojiLoadedState) {
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));
if (emojiInfo.hasLightBorder) {
image.dataset.lightCode = `${emojiInfo.hexCode}_BORDER`;
} else if (emojiInfo.hasDarkBorder) {
image.dataset.darkCode = `${emojiInfo.hexCode}_BORDER`;
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/${emojiInfo.hexCode}.svg`;
image.src = `${assetHost}/emoji/${fileName}.svg`;
} else {
// Custom emoji
const shortCode = `:${state.data.shortcode}:`;
@ -318,8 +380,16 @@ function stateToImage(state: EmojiLoadedState) {
return image;
}
function renderedToHTMLFragment(renderedArray: (string | HTMLImageElement)[]) {
const fragment = document.createDocumentFragment();
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));
@ -329,3 +399,9 @@ function renderedToHTMLFragment(renderedArray: (string | HTMLImageElement)[]) {
}
return fragment;
}
// Testing helpers
export const testCacheClear = () => {
cacheClear();
localeCacheMap.clear();
};

View File

@ -1,6 +1,10 @@
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,
@ -22,6 +26,7 @@ export interface EmojiAppState {
locales: Locale[];
currentLocale: Locale;
mode: EmojiMode;
darkTheme: boolean;
}
export interface UnicodeEmojiToken {
@ -45,7 +50,7 @@ export interface EmojiStateUnicode {
}
export interface EmojiStateCustom {
type: typeof EMOJI_TYPE_CUSTOM;
data: CustomEmojiData;
data: CustomEmojiRenderFields;
}
export type EmojiState =
| EmojiStateMissing
@ -53,9 +58,16 @@ export type EmojiState =
| EmojiStateCustom;
export type EmojiLoadedState = EmojiStateUnicode | EmojiStateCustom;
export type EmojiStateMap = Map<string, EmojiState>;
export type EmojiStateMap = LimitedCache<string, EmojiState>;
export type ExtraCustomEmojiMap = Record<string, ApiCustomEmojiJSON>;
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;

View File

@ -1,8 +1,14 @@
import { stringHasUnicodeEmoji, stringHasUnicodeFlags } from './utils';
import {
stringHasAnyEmoji,
stringHasCustomEmoji,
stringHasUnicodeEmoji,
stringHasUnicodeFlags,
} from './utils';
describe('stringHasEmoji', () => {
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],
@ -19,14 +25,14 @@ describe('stringHasEmoji', () => {
['emoji with enclosing keycap #️⃣', true],
['emoji with no visible glyph \u200D', false],
] as const)(
'stringHasEmoji has emojis in "%s": %o',
'stringHasUnicodeEmoji has emojis in "%s": %o',
([text, expected], { expect }) => {
expect(stringHasUnicodeEmoji(text)).toBe(expected);
},
);
});
describe('stringHasFlags', () => {
describe('stringHasUnicodeFlags', () => {
test.concurrent.for([
['EU 🇪🇺', true],
['Germany 🇩🇪', true],
@ -45,3 +51,27 @@ describe('stringHasFlags', () => {
},
);
});
describe('stringHasCustomEmoji', () => {
test('string with custom emoji returns true', () => {
expect(stringHasCustomEmoji(':custom: :test:')).toBeTruthy();
});
test('string without custom emoji returns false', () => {
expect(stringHasCustomEmoji('🏳️‍🌈 :🏳️‍🌈: text ™')).toBeFalsy();
});
});
describe('stringHasAnyEmoji', () => {
test('string without any emoji or characters', () => {
expect(stringHasAnyEmoji('normal text. 12356?!')).toBeFalsy();
});
test('string with non-emoji characters', () => {
expect(stringHasAnyEmoji('™©')).toBeFalsy();
});
test('has unicode emoji', () => {
expect(stringHasAnyEmoji('🏳️‍🌈🔥🇸🇹 👩‍🔬')).toBeTruthy();
});
test('has custom emoji', () => {
expect(stringHasAnyEmoji(':test: :custom:')).toBeTruthy();
});
});

View File

@ -1,13 +1,56 @@
import EMOJI_REGEX from 'emojibase-regex/emoji-loose';
import debug from 'debug';
export function stringHasUnicodeEmoji(text: string): boolean {
return EMOJI_REGEX.test(text);
import { emojiRegexPolyfill } from '@/mastodon/polyfills';
export function emojiLogger(segment: string) {
return debug(`emojis:${segment}`);
}
// From https://github.com/talkjs/country-flag-emoji-polyfill/blob/master/src/index.ts#L49-L50
const EMOJIS_FLAGS_REGEX =
/[\u{1F1E6}-\u{1F1FF}|\u{E0062}-\u{E0063}|\u{E0065}|\u{E0067}|\u{E006C}|\u{E006E}|\u{E0073}-\u{E0074}|\u{E0077}|\u{E007F}]+/u;
export function stringHasUnicodeFlags(text: string): boolean {
return EMOJIS_FLAGS_REGEX.test(text);
export function stringHasUnicodeEmoji(input: string): boolean {
return new RegExp(EMOJI_REGEX, supportedFlags()).test(input);
}
export function stringHasUnicodeFlags(input: string): boolean {
if (supportsRegExpSets()) {
return new RegExp(
'\\p{RGI_Emoji_Flag_Sequence}|\\p{RGI_Emoji_Tag_Sequence}',
'v',
).test(input);
}
return new RegExp(
// First range is regional indicator symbols,
// Second is a black flag + 0-9|a-z tag chars + cancel tag.
// See: https://en.wikipedia.org/wiki/Regional_indicator_symbol
'(?:\uD83C[\uDDE6-\uDDFF]){2}|\uD83C\uDFF4(?:\uDB40[\uDC30-\uDC7A])+\uDB40\uDC7F',
).test(input);
}
// Constant as this is supported by all browsers.
const CUSTOM_EMOJI_REGEX = /:([a-z0-9_]+):/i;
export function stringHasCustomEmoji(input: string) {
return CUSTOM_EMOJI_REGEX.test(input);
}
export function stringHasAnyEmoji(input: string) {
return stringHasUnicodeEmoji(input) || stringHasCustomEmoji(input);
}
export function anyEmojiRegex() {
return new RegExp(
`${EMOJI_REGEX}|${CUSTOM_EMOJI_REGEX.source}`,
supportedFlags('gi'),
);
}
function supportsRegExpSets() {
return 'unicodeSets' in RegExp.prototype;
}
function supportedFlags(flags = '') {
if (supportsRegExpSets()) {
return `${flags}v`;
}
return flags;
}
const EMOJI_REGEX = emojiRegexPolyfill?.source ?? '\\p{RGI_Emoji}';

View File

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

View File

@ -1,4 +1,4 @@
import { useEffect, useCallback, useRef, useState } from 'react';
import { useEffect, useCallback, useRef, useState, useId } from 'react';
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
@ -19,6 +19,7 @@ import { DisplayName } from 'mastodon/components/display_name';
import { FollowButton } from 'mastodon/components/follow_button';
import { Icon } from 'mastodon/components/icon';
import { IconButton } from 'mastodon/components/icon_button';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { VerifiedBadge } from 'mastodon/components/verified_badge';
import { domain } from 'mastodon/initial_state';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
@ -56,9 +57,7 @@ const messages = defineMessages({
},
});
const Source: React.FC<{
id: ApiSuggestionSourceJSON;
}> = ({ id }) => {
const Source: React.FC<{ id: ApiSuggestionSourceJSON }> = ({ id }) => {
const intl = useIntl();
let label, hint;
@ -168,10 +167,11 @@ const Card: React.FC<{
const DISMISSIBLE_ID = 'home/follow-suggestions';
export const InlineFollowSuggestions: React.FC<{
hidden?: boolean;
}> = ({ hidden }) => {
export const InlineFollowSuggestions: React.FC<{ hidden?: boolean }> = ({
hidden,
}) => {
const intl = useIntl();
const uniqueId = useId();
const dispatch = useAppDispatch();
const suggestions = useAppSelector((state) => state.suggestions.items);
const isLoading = useAppSelector((state) => state.suggestions.isLoading);
@ -257,9 +257,14 @@ export const InlineFollowSuggestions: React.FC<{
}
return (
<div className='inline-follow-suggestions'>
<div
role='group'
aria-labelledby={uniqueId}
className='inline-follow-suggestions focusable'
tabIndex={-1}
>
<div className='inline-follow-suggestions__header'>
<h3>
<h3 id={uniqueId}>
<FormattedMessage
id='follow_suggestions.who_to_follow'
defaultMessage='Who to follow'
@ -288,13 +293,17 @@ export const InlineFollowSuggestions: React.FC<{
ref={bodyRef}
onScroll={handleScroll}
>
{suggestions.map((suggestion) => (
<Card
key={suggestion.account_id}
id={suggestion.account_id}
sources={suggestion.sources}
/>
))}
{isLoading ? (
<LoadingIndicator />
) : (
suggestions.map((suggestion) => (
<Card
key={suggestion.account_id}
id={suggestion.account_id}
sources={suggestion.sources}
/>
))
)}
</div>
{canScrollLeft && (

View File

@ -9,6 +9,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header';
import { isFeatureEnabled } from 'mastodon/utils/environment';
const messages = defineMessages({
heading: { id: 'keyboard_shortcuts.heading', defaultMessage: 'Keyboard Shortcuts' },
@ -62,6 +63,12 @@ class KeyboardShortcuts extends ImmutablePureComponent {
<td><kbd>b</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.boost' defaultMessage='to boost' /></td>
</tr>
{isFeatureEnabled('outgoing_quotes') && (
<tr>
<td><kbd>q</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.quote' defaultMessage='Quote post' /></td>
</tr>
)}
<tr>
<td><kbd>enter</kbd>, <kbd>o</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.enter' defaultMessage='to open status' /></td>
@ -83,13 +90,17 @@ class KeyboardShortcuts extends ImmutablePureComponent {
<td><FormattedMessage id='keyboard_shortcuts.toggle_sensitivity' defaultMessage='to show/hide media' /></td>
</tr>
<tr>
<td><kbd>up</kbd>, <kbd>k</kbd></td>
<td><kbd>k</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.up' defaultMessage='to move up in the list' /></td>
</tr>
<tr>
<td><kbd>down</kbd>, <kbd>j</kbd></td>
<td><kbd>j</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.down' defaultMessage='to move down in the list' /></td>
</tr>
<tr>
<td><kbd>l</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.load_more' defaultMessage='Focus "Load more" button' /></td>
</tr>
<tr>
<td><kbd>1</kbd>-<kbd>9</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.column' defaultMessage='to focus a status in one of the columns' /></td>

View File

@ -143,6 +143,17 @@ class ColumnSettings extends PureComponent {
</div>
</section>
<section role='group' aria-labelledby='notifications-quote'>
<h3 id='notifications-quote'><FormattedMessage id='notifications.column_settings.quote' defaultMessage='Quotes:' /></h3>
<div className='column-settings__row'>
<SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'quote']} onChange={onChange} label={alertStr} />
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'quote']} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'quote']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'quote']} onChange={onChange} label={soundStr} />
</div>
</section>
<section role='group' aria-labelledby='notifications-poll'>
<h3 id='notifications-poll'><FormattedMessage id='notifications.column_settings.poll' defaultMessage='Poll results:' /></h3>

View File

@ -8,9 +8,9 @@ import { Link, withRouter } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
import FlagIcon from '@/material-icons/400-24px/flag-fill.svg?react';
import FormatQuoteIcon from '@/material-icons/400-24px/format_quote.svg?react';
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react';
import PersonIcon from '@/material-icons/400-24px/person-fill.svg?react';
@ -38,10 +38,12 @@ const messages = defineMessages({
reblog: { id: 'notification.reblog', defaultMessage: '{name} boosted your post' },
status: { id: 'notification.status', defaultMessage: '{name} just posted' },
update: { id: 'notification.update', defaultMessage: '{name} edited a post' },
quoted_update: { id: 'notification.quoted_update', defaultMessage: '{name} edited a post you have quoted' },
adminSignUp: { id: 'notification.admin.sign_up', defaultMessage: '{name} signed up' },
adminReport: { id: 'notification.admin.report', defaultMessage: '{name} reported {target}' },
relationshipsSevered: { id: 'notification.relationships_severance_event', defaultMessage: 'Lost connections with {name}' },
moderationWarning: { id: 'notification.moderation_warning', defaultMessage: 'You have received a moderation warning' },
quote: { id: 'notification.label.quote', defaultMessage: '{name} quoted your post'}
});
const notificationForScreenReader = (intl, message, timestamp) => {
@ -56,8 +58,6 @@ class Notification extends ImmutablePureComponent {
static propTypes = {
notification: ImmutablePropTypes.map.isRequired,
hidden: PropTypes.bool,
onMoveUp: PropTypes.func.isRequired,
onMoveDown: PropTypes.func.isRequired,
onMention: PropTypes.func.isRequired,
onFavourite: PropTypes.func.isRequired,
onReblog: PropTypes.func.isRequired,
@ -72,16 +72,6 @@ class Notification extends ImmutablePureComponent {
...WithRouterPropTypes,
};
handleMoveUp = () => {
const { notification, onMoveUp } = this.props;
onMoveUp(notification.get('id'));
};
handleMoveDown = () => {
const { notification, onMoveDown } = this.props;
onMoveDown(notification.get('id'));
};
handleOpen = () => {
const { notification } = this.props;
@ -127,8 +117,6 @@ class Notification extends ImmutablePureComponent {
mention: this.handleMention,
open: this.handleOpen,
openProfile: this.handleOpenProfile,
moveUp: this.handleMoveUp,
moveDown: this.handleMoveDown,
toggleHidden: this.handleHotkeyToggleHidden,
};
}
@ -179,8 +167,6 @@ class Notification extends ImmutablePureComponent {
id={notification.get('status')}
withDismiss
hidden={this.props.hidden}
onMoveDown={this.handleMoveDown}
onMoveUp={this.handleMoveUp}
contextType='notifications'
getScrollPosition={this.props.getScrollPosition}
updateScrollBottom={this.props.updateScrollBottom}
@ -251,6 +237,36 @@ class Notification extends ImmutablePureComponent {
);
}
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>
);
}
renderStatus (notification, link) {
const { intl, unread, status } = this.props;
@ -321,6 +337,41 @@ class Notification extends ImmutablePureComponent {
);
}
renderQuotedUpdate (notification, link) {
const { intl, unread, status } = this.props;
if (!status) {
return null;
}
return (
<Hotkeys handlers={this.getHandlers()}>
<div className={classNames('notification notification-update focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.update, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
<div className='notification__message'>
<Icon id='pencil' icon={EditIcon} />
<span title={notification.get('created_at')}>
<FormattedMessage id='notification.quoted_update' defaultMessage='{name} edited a post you have quoted' values={{ name: link }} />
</span>
</div>
<StatusQuoteManager
id={notification.get('status')}
account={notification.get('account')}
contextType='notifications'
muted
withDismiss
hidden={this.props.hidden}
getScrollPosition={this.props.getScrollPosition}
updateScrollBottom={this.props.updateScrollBottom}
cachedMediaWidth={this.props.cachedMediaWidth}
cacheMediaWidth={this.props.cacheMediaWidth}
/>
</div>
</Hotkeys>
);
}
renderPoll (notification, account) {
const { intl, unread, status } = this.props;
const ownPoll = me === account.get('id');
@ -467,6 +518,8 @@ class Notification extends ImmutablePureComponent {
return this.renderFollowRequest(notification, account, link);
case 'mention':
return this.renderMention(notification);
case 'quote':
return this.renderQuote(notification);
case 'favourite':
return this.renderFavourite(notification, link);
case 'reblog':
@ -475,6 +528,8 @@ class Notification extends ImmutablePureComponent {
return this.renderStatus(notification, link);
case 'update':
return this.renderUpdate(notification, link);
case 'quoted_update':
return this.renderQuotedUpdate(notification, link);
case 'poll':
return this.renderPoll(notification, account);
case 'severed_relationships':

View File

@ -31,21 +31,6 @@ const messages = defineMessages({
dismiss: { id: 'notification_requests.dismiss', defaultMessage: 'Dismiss' },
});
const selectChild = (ref, index, alignTop) => {
const container = ref.current.node;
const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
if (element) {
if (alignTop && container.scrollTop > element.offsetTop) {
element.scrollIntoView(true);
} else if (!alignTop && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
element.scrollIntoView(false);
}
element.focus();
}
};
export const NotificationRequest = ({ multiColumn, params: { id } }) => {
const columnRef = useRef();
const intl = useIntl();
@ -74,16 +59,6 @@ export const NotificationRequest = ({ multiColumn, params: { id } }) => {
dispatch(acceptNotificationRequest({ id }));
}, [dispatch, id]);
const handleMoveUp = useCallback(id => {
const elementIndex = notifications.findIndex(item => item !== null && item.get('id') === id) - 1;
selectChild(columnRef, elementIndex, true);
}, [columnRef, notifications]);
const handleMoveDown = useCallback(id => {
const elementIndex = notifications.findIndex(item => item !== null && item.get('id') === id) + 1;
selectChild(columnRef, elementIndex, false);
}, [columnRef, notifications]);
useEffect(() => {
dispatch(fetchNotificationRequest({ id }));
}, [dispatch, id]);
@ -146,8 +121,6 @@ export const NotificationRequest = ({ multiColumn, params: { id } }) => {
key={item.get('id')}
notification={item}
accountId={item.get('account')}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
/>
))}
</ScrollableList>

View File

@ -15,6 +15,8 @@ import { NotificationFollowRequest } from './notification_follow_request';
import { NotificationMention } from './notification_mention';
import { NotificationModerationWarning } from './notification_moderation_warning';
import { NotificationPoll } from './notification_poll';
import { NotificationQuote } from './notification_quote';
import { NotificationQuotedUpdate } from './notification_quoted_update';
import { NotificationReblog } from './notification_reblog';
import { NotificationSeveredRelationships } from './notification_severed_relationships';
import { NotificationStatus } from './notification_status';
@ -23,9 +25,7 @@ import { NotificationUpdate } from './notification_update';
export const NotificationGroup: React.FC<{
notificationGroupId: NotificationGroupModel['group_key'];
unread: boolean;
onMoveUp: (groupId: string) => void;
onMoveDown: (groupId: string) => void;
}> = ({ notificationGroupId, unread, onMoveUp, onMoveDown }) => {
}> = ({ notificationGroupId, unread }) => {
const notificationGroup = useAppSelector((state) =>
state.notificationGroups.groups.find(
(item) => item.type !== 'gap' && item.group_key === notificationGroupId,
@ -41,14 +41,6 @@ export const NotificationGroup: React.FC<{
const handlers = useMemo(
() => ({
moveUp: () => {
onMoveUp(notificationGroupId);
},
moveDown: () => {
onMoveDown(notificationGroupId);
},
openProfile: () => {
if (accountId) dispatch(navigateToProfile(accountId));
},
@ -57,7 +49,7 @@ export const NotificationGroup: React.FC<{
if (accountId) dispatch(mentionComposeById(accountId));
},
}),
[dispatch, notificationGroupId, accountId, onMoveUp, onMoveDown],
[dispatch, accountId],
);
if (!notificationGroup || notificationGroup.type === 'gap') return null;
@ -91,6 +83,11 @@ export const NotificationGroup: React.FC<{
<NotificationMention unread={unread} notification={notificationGroup} />
);
break;
case 'quote':
content = (
<NotificationQuote unread={unread} notification={notificationGroup} />
);
break;
case 'follow':
content = (
<NotificationFollow unread={unread} notification={notificationGroup} />
@ -119,6 +116,14 @@ export const NotificationGroup: React.FC<{
<NotificationUpdate unread={unread} notification={notificationGroup} />
);
break;
case 'quoted_update':
content = (
<NotificationQuotedUpdate
unread={unread}
notification={notificationGroup}
/>
);
break;
case 'admin.sign_up':
content = (
<NotificationAdminSignUp

View File

@ -0,0 +1,33 @@
import { FormattedMessage } from 'react-intl';
import FormatQuoteIcon from '@/material-icons/400-24px/format_quote.svg?react';
import type { NotificationGroupQuote } from 'mastodon/models/notification_group';
import type { LabelRenderer } from './notification_group_with_status';
import { NotificationWithStatus } from './notification_with_status';
const quoteLabelRenderer: LabelRenderer = (displayName) => (
<FormattedMessage
id='notification.label.quote'
defaultMessage='{name} quoted your post'
values={{ name: displayName }}
/>
);
export const NotificationQuote: React.FC<{
notification: NotificationGroupQuote;
unread: boolean;
}> = ({ notification, unread }) => {
return (
<NotificationWithStatus
type='quote'
icon={FormatQuoteIcon}
iconId='quote'
accountIds={notification.sampleAccountIds}
count={notification.notifications_count}
statusId={notification.statusId}
labelRenderer={quoteLabelRenderer}
unread={unread}
/>
);
};

View File

@ -0,0 +1,31 @@
import { FormattedMessage } from 'react-intl';
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
import type { NotificationGroupQuotedUpdate } from 'mastodon/models/notification_group';
import type { LabelRenderer } from './notification_group_with_status';
import { NotificationWithStatus } from './notification_with_status';
const labelRenderer: LabelRenderer = (displayedName) => (
<FormattedMessage
id='notification.quoted_update'
defaultMessage='{name} edited a post you have quoted'
values={{ name: displayedName }}
/>
);
export const NotificationQuotedUpdate: React.FC<{
notification: NotificationGroupQuotedUpdate;
unread: boolean;
}> = ({ notification, unread }) => (
<NotificationWithStatus
type='update'
icon={EditIcon}
iconId='edit'
accountIds={notification.sampleAccountIds}
count={notification.notifications_count}
statusId={notification.statusId}
labelRenderer={labelRenderer}
unread={unread}
/>
);

View File

@ -99,29 +99,6 @@ export const Notifications: React.FC<{
const columnRef = useRef<ColumnRef>(null);
const selectChild = useCallback((index: number, alignTop: boolean) => {
const container = columnRef.current?.node as HTMLElement | undefined;
if (!container) return;
const element = container.querySelector<HTMLElement>(
`article:nth-of-type(${index + 1}) .focusable`,
);
if (element) {
if (alignTop && container.scrollTop > element.offsetTop) {
element.scrollIntoView(true);
} else if (
!alignTop &&
container.scrollTop + container.clientHeight <
element.offsetTop + element.offsetHeight
) {
element.scrollIntoView(false);
}
element.focus();
}
}, []);
// Keep track of mounted components for unread notification handling
useEffect(() => {
void dispatch(mountNotifications());
@ -187,28 +164,6 @@ export const Notifications: React.FC<{
columnRef.current?.scrollTop();
}, []);
const handleMoveUp = useCallback(
(id: string) => {
const elementIndex =
notifications.findIndex(
(item) => item.type !== 'gap' && item.group_key === id,
) - 1;
selectChild(elementIndex, true);
},
[notifications, selectChild],
);
const handleMoveDown = useCallback(
(id: string) => {
const elementIndex =
notifications.findIndex(
(item) => item.type !== 'gap' && item.group_key === id,
) + 1;
selectChild(elementIndex, false);
},
[notifications, selectChild],
);
const handleMarkAsRead = useCallback(() => {
dispatch(markNotificationsAsRead());
void dispatch(submitMarkers({ immediate: true }));
@ -241,8 +196,6 @@ export const Notifications: React.FC<{
<NotificationGroup
key={item.group_key}
notificationGroupId={item.group_key}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
unread={
lastReadId !== '0' &&
!!item.page_max_id &&
@ -251,15 +204,7 @@ export const Notifications: React.FC<{
/>
),
);
}, [
notifications,
isLoading,
hasMore,
lastReadId,
handleLoadGap,
handleMoveUp,
handleMoveDown,
]);
}, [notifications, isLoading, hasMore, lastReadId, handleLoadGap]);
const prepend = (
<>

View File

@ -233,7 +233,10 @@ export const Footer: React.FC<{
icon='retweet'
iconComponent={reblogIconComponent}
onClick={handleReblogClick}
counter={status.get('reblogs_count') as number}
counter={
(status.get('reblogs_count') as number) +
(status.get('quotes_count') as number)
}
/>
<IconButton

View File

@ -0,0 +1,113 @@
import { useCallback, useEffect } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Helmet } from 'react-helmet';
import { List as ImmutableList } from 'immutable';
import RefreshIcon from '@/material-icons/400-24px/refresh.svg?react';
import { fetchQuotes } from 'mastodon/actions/interactions_typed';
import { ColumnHeader } from 'mastodon/components/column_header';
import { Icon } from 'mastodon/components/icon';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import StatusList from 'mastodon/components/status_list';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import Column from '../ui/components/column';
const messages = defineMessages({
refresh: { id: 'refresh', defaultMessage: 'Refresh' },
});
const emptyList = ImmutableList();
export const Quotes: React.FC<{
multiColumn?: boolean;
params?: { statusId: string };
}> = ({ multiColumn, params }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const statusId = params?.statusId;
const isCorrectStatusId: boolean = useAppSelector(
(state) => state.status_lists.getIn(['quotes', 'statusId']) === statusId,
);
const statusIds = useAppSelector((state) =>
state.status_lists.getIn(['quotes', 'items'], emptyList),
);
const nextUrl = useAppSelector(
(state) =>
state.status_lists.getIn(['quotes', 'next']) as string | undefined,
);
const isLoading = useAppSelector((state) =>
state.status_lists.getIn(['quotes', 'isLoading'], true),
);
const hasMore = !!nextUrl;
useEffect(() => {
if (statusId) void dispatch(fetchQuotes({ statusId }));
}, [dispatch, statusId]);
const handleLoadMore = useCallback(() => {
if (statusId && isCorrectStatusId && nextUrl)
void dispatch(fetchQuotes({ statusId, next: nextUrl }));
}, [dispatch, statusId, isCorrectStatusId, nextUrl]);
const handleRefresh = useCallback(() => {
if (statusId) void dispatch(fetchQuotes({ statusId }));
}, [dispatch, statusId]);
if (!statusIds || !isCorrectStatusId) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
const emptyMessage = (
<FormattedMessage
id='status.quotes.empty'
defaultMessage='No one has quoted this post yet. When someone does, it will show up here.'
/>
);
return (
<Column bindToDocument={!multiColumn}>
<ColumnHeader
showBackButton
multiColumn={multiColumn}
extraButton={
<button
type='button'
className='column-header__button'
title={intl.formatMessage(messages.refresh)}
aria-label={intl.formatMessage(messages.refresh)}
onClick={handleRefresh}
>
<Icon id='refresh' icon={RefreshIcon} />
</button>
}
/>
<StatusList
scrollKey='quotes_timeline'
statusIds={statusIds}
onLoadMore={handleLoadMore}
hasMore={hasMore}
isLoading={isLoading}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
/>
<Helmet>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default Quotes;

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