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

View File

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

View File

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

2
.nvmrc
View File

@ -1 +1 @@
22.17 22.19

View File

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

View File

@ -1,28 +1,11 @@
# This configuration was generated by # This configuration was generated by
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp` # `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp`
# using RuboCop version 1.79.0. # using RuboCop version 1.79.2.
# The point is for the user to remove these configuration records # The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base. # one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new # Note that changes in the inspected code, or installation of new
# versions of RuboCop, may require this file to be generated again. # versions of RuboCop, may require this file to be generated again.
# 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). # This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: AllowedVars, DefaultToNil. # Configuration parameters: AllowedVars, DefaultToNil.
Style/FetchEnvVar: Style/FetchEnvVar:

View File

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

View File

@ -2,6 +2,28 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [4.4.3] - 2025-08-05
### Security
- Update dependencies
- Fix incorrect rate-limit handling [GHSA-84ch-6436-c7mg](https://github.com/mastodon/mastodon/security/advisories/GHSA-84ch-6436-c7mg)
### Fixed
- Fix race condition caused by ActiveRecord query cache in `Create` critical path (#35662 by @ClearlyClaire)
- Fix race condition caused by quote post processing (#35657 by @ClearlyClaire)
- Fix WebUI crashing for accounts with `null` URL (#35651 by @ClearlyClaire)
- Fix friends-of-friends recommendations suggesting already-requested accounts (#35604 by @ClearlyClaire)
- Fix synchronous recursive fetching of deeply-nested quoted posts (#35600 by @ClearlyClaire)
- Fix “Expand this post” link including user `@undefined` (#35478 by @ClearlyClaire)
### Changed
- Change `StatusReachFinder` to consider quotes as well as reblogs (#35601 by @ClearlyClaire)
- Add restrictions on which quote posts can trend (#35507 by @ClearlyClaire)
- Change quote verification to not bypass authorization flow for mentions (#35528 by @ClearlyClaire)
## [4.4.2] - 2025-07-23 ## [4.4.2] - 2025-07-23
### Security ### Security
@ -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.\ 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.\ 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: This adds the following REST API endpoints:
- `GET /api/v2/notifications/policy`: https://docs.joinmastodon.org/methods/notifications/#get-policy - `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 - `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 - `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 - `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: 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 - `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)\ - **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"] # # Node.js version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"]
# renovate: datasource=node-version depName=node # renovate: datasource=node-version depName=node
ARG NODE_MAJOR_VERSION="22" ARG NODE_MAJOR_VERSION="22"
# Debian image to use for base image, change with [--build-arg DEBIAN_VERSION="bookworm"] # Debian image to use for base image, change with [--build-arg DEBIAN_VERSION="trixie"]
ARG DEBIAN_VERSION="bookworm" ARG DEBIAN_VERSION="trixie"
# Node.js image to use for base image based on combined variables (ex: 20-bookworm-slim) # 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 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 FROM ${BASE_REGISTRY}/ruby:${RUBY_VERSION}-slim-${DEBIAN_VERSION} AS ruby
# Resulting version string is vX.X.X-MASTODON_VERSION_PRERELEASE+MASTODON_VERSION_METADATA # Resulting version string is vX.X.X-MASTODON_VERSION_PRERELEASE+MASTODON_VERSION_METADATA
@ -96,9 +96,6 @@ RUN \
# Set /opt/mastodon as working directory # Set /opt/mastodon as working directory
WORKDIR /opt/mastodon 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 # hadolint ignore=DL3008,DL3005
RUN \ RUN \
# Mount Apt cache and lib directories from Docker buildx caches # Mount Apt cache and lib directories from Docker buildx caches
@ -161,11 +158,11 @@ RUN \
libexif-dev \ libexif-dev \
libexpat1-dev \ libexpat1-dev \
libgirepository1.0-dev \ libgirepository1.0-dev \
libheif-dev/bookworm-backports \ libheif-dev \
libhwy-dev \
libimagequant-dev \ libimagequant-dev \
libjpeg62-turbo-dev \ libjpeg62-turbo-dev \
liblcms2-dev \ liblcms2-dev \
liborc-dev \
libspng-dev \ libspng-dev \
libtiff-dev \ libtiff-dev \
libwebp-dev \ libwebp-dev \
@ -209,7 +206,7 @@ FROM build AS ffmpeg
# ffmpeg version to compile, change with [--build-arg FFMPEG_VERSION="7.0.x"] # ffmpeg version to compile, change with [--build-arg FFMPEG_VERSION="7.0.x"]
# renovate: datasource=repology depName=ffmpeg packageName=openpkg_current/ffmpeg # 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"] # ffmpeg download URL, change with [--build-arg FFMPEG_URL="https://ffmpeg.org/releases"]
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 update install non-dev versions of necessary components
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
libexpat1 \ libexpat1 \
libglib2.0-0 \ libglib2.0-0t64 \
libicu72 \ libicu76 \
libidn12 \ libidn12 \
libpq5 \ libpq5 \
libreadline8 \ libreadline8t64 \
libssl3 \ libssl3t64 \
libyaml-0-2 \ libyaml-0-2 \
# libvips components # libvips components
libcgif0 \ libcgif0 \
libexif12 \ libexif12 \
libheif1/bookworm-backports \ libheif1 \
libhwy1t64 \
libimagequant0 \ libimagequant0 \
libjpeg62-turbo \ libjpeg62-turbo \
liblcms2-2 \ liblcms2-2 \
liborc-0.4-0 \
libspng0 \ libspng0 \
libtiff6 \ libtiff6 \
libwebp7 \ libwebp7 \
libwebpdemux2 \ libwebpdemux2 \
libwebpmux3 \ libwebpmux3 \
# ffmpeg components # ffmpeg components
libdav1d6 \ libdav1d7 \
libmp3lame0 \ libmp3lame0 \
libopencore-amrnb0 \ libopencore-amrnb0 \
libopencore-amrwb0 \ libopencore-amrwb0 \
@ -358,9 +355,9 @@ RUN \
libvorbis0a \ libvorbis0a \
libvorbisenc2 \ libvorbisenc2 \
libvorbisfile3 \ libvorbisfile3 \
libvpx7 \ libvpx9 \
libx264-164 \ libx264-164 \
libx265-199 \ libx265-215 \
; ;
# Copy Mastodon sources into final layer # Copy Mastodon sources into final layer

18
Gemfile
View File

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

View File

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

View File

@ -36,7 +36,7 @@ module Admin
end end
def edit def edit
authorize :domain_block, :create? authorize :domain_block, :update?
end end
def create def create
@ -129,7 +129,7 @@ module Admin
end end
def requires_confirmation? def requires_confirmation?
@domain_block.valid? && (@domain_block.new_record? || @domain_block.severity_changed?) && @domain_block.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 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_privacy: source_params.fetch(:privacy, @account.user.setting_default_privacy),
default_sensitive: source_params.fetch(:sensitive, @account.user.setting_default_sensitive), default_sensitive: source_params.fetch(:sensitive, @account.user.setting_default_sensitive),
default_language: source_params.fetch(:language, @account.user.setting_default_language), 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 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 class Api::V1::StatusesController < Api::BaseController
include Authorization include Authorization
include AsyncRefreshesConcern include AsyncRefreshesConcern
include Api::InteractionPoliciesConcern
before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [:create, :update, :destroy] before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [:create, :update, :destroy]
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:create, :update, :destroy] before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:create, :update, :destroy]
@ -66,7 +67,11 @@ class Api::V1::StatusesController < Api::BaseController
add_async_refresh_header(async_refresh) add_async_refresh_header(async_refresh)
elsif !current_account.nil? && @status.should_fetch_replies? elsif !current_account.nil? && @status.should_fetch_replies?
add_async_refresh_header(AsyncRefresh.create(refresh_key)) 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 end
render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id) render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id)
@ -78,6 +83,7 @@ class Api::V1::StatusesController < Api::BaseController
text: status_params[:status], text: status_params[:status],
thread: @thread, thread: @thread,
quoted_status: @quoted_status, quoted_status: @quoted_status,
quote_approval_policy: quote_approval_policy,
media_ids: status_params[:media_ids], media_ids: status_params[:media_ids],
sensitive: status_params[:sensitive], sensitive: status_params[:sensitive],
spoiler_text: status_params[:spoiler_text], spoiler_text: status_params[:spoiler_text],
@ -109,7 +115,8 @@ class Api::V1::StatusesController < Api::BaseController
sensitive: status_params[:sensitive], sensitive: status_params[:sensitive],
language: status_params[:language], language: status_params[:language],
spoiler_text: status_params[:spoiler_text], spoiler_text: status_params[:spoiler_text],
poll: status_params[:poll] poll: status_params[:poll],
quote_approval_policy: quote_approval_policy
) )
render json: @status, serializer: REST::StatusSerializer render json: @status, serializer: REST::StatusSerializer
@ -176,6 +183,7 @@ class Api::V1::StatusesController < Api::BaseController
:status, :status,
:in_reply_to_id, :in_reply_to_id,
:quoted_status_id, :quoted_status_id,
:quote_approval_policy,
:sensitive, :sensitive,
:spoiler_text, :spoiler_text,
:visibility, :visibility,

View File

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

View File

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

View File

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

View File

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

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 EXPIRATION_WINDOW_LIMIT = 12.hours
CLOCK_SKEW_MARGIN = 1.hour CLOCK_SKEW_MARGIN = 1.hour
STOPLIGHT_COOL_OFF_TIME = 5.minutes.seconds
STOPLIGHT_THRESHOLD = 1
def require_account_signature! def require_account_signature!
render json: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_account render json: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_account
@ -107,10 +109,12 @@ module SignatureVerification
end end
def stoplight_wrapper def stoplight_wrapper
Stoplight("source:#{request.remote_ip}") Stoplight(
.with_threshold(1) "source:#{request.remote_ip}",
.with_cool_off_time(5.minutes.seconds) cool_off_time: STOPLIGHT_COOL_OFF_TIME,
.with_error_handler { |error, handle| error.is_a?(HTTP::Error) || error.is_a?(OpenSSL::SSL::SSLError) ? handle.call(error) : raise(error) } threshold: STOPLIGHT_THRESHOLD,
tracked_errors: [HTTP::Error, OpenSSL::SSL::SSLError]
)
end end
def actor_refresh_key!(actor) 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 end
else else
flash[:error] = I18n.t('webauthn_credentials.create.error') flash[:error] = I18n.t('webauthn_credentials.create.error')
status = :unprocessable_entity status = :unprocessable_content
end end
else else
flash[:error] = t('webauthn_credentials.create.error') 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 :require_account_signature!, only: [:show, :activity], if: -> { request.format == :json && authorized_fetch_mode? }
before_action :set_status before_action :set_status
before_action :redirect_to_original, only: :show before_action :redirect_to_original, only: :show
before_action :verify_embed_allowed, only: :embed
after_action :set_link_headers after_action :set_link_headers
@ -40,8 +41,6 @@ class StatusesController < ApplicationController
end end
def embed def embed
return not_found if @status.hidden? || @status.reblog?
expires_in 180, public: true expires_in 180, public: true
response.headers.delete('X-Frame-Options') response.headers.delete('X-Frame-Options')
@ -50,6 +49,10 @@ class StatusesController < ApplicationController
private private
def verify_embed_allowed
not_found if @status.hidden? || @status.reblog?
end
def set_link_headers def set_link_headers
response.headers['Link'] = LinkHeader.new( response.headers['Link'] = LinkHeader.new(
[[ActivityPub::TagManager.instance.uri_for(@status), [%w(rel alternate), %w(type application/activity+json)]]] [[ActivityPub::TagManager.instance.uri_for(@status), [%w(rel alternate), %w(type application/activity+json)]]]

View File

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

View File

@ -102,6 +102,16 @@ module ApplicationHelper
policy(record).public_send(:"#{action}?") policy(record).public_send(:"#{action}?")
end 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 = {}) def material_symbol(icon, attributes = {})
safe_join( safe_join(
[ [
@ -233,6 +243,10 @@ module ApplicationHelper
tag.input(type: :text, maxlength: 999, spellcheck: false, readonly: true, **options) tag.input(type: :text, maxlength: 999, spellcheck: false, readonly: true, **options)
end 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) def recent_tag_usage(tag)
people = tag.history.aggregate(2.days.ago.to_date..Time.zone.today).accounts 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 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' }, 'automaticApproval' => { '@id' => 'gts:automaticApproval', '@type' => '@id' },
'manualApproval' => { '@id' => 'gts:manualApproval', '@type' => '@id' }, 'manualApproval' => { '@id' => 'gts:manualApproval', '@type' => '@id' },
}, },
quote_authorizations: {
'gts' => 'https://gotosocial.org/ns#',
'quoteAuthorization' => { '@id' => 'https://w3id.org/fep/044f#quoteAuthorization', '@type' => '@id' },
'interactingObject' => { '@id' => 'gts:interactingObject' },
'interactionTarget' => { '@id' => 'gts:interactionTarget' },
},
}.freeze }.freeze
def full_context def full_context

View File

@ -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
end end
def obscured_counter(count)
if count <= 0
'0'
elsif count == 1
'1'
else
'1+'
end
end
def field_verified_class(verified) def field_verified_class(verified)
if verified if verified
'verified' 'verified'

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -84,6 +84,7 @@ export const COMPOSE_FOCUS = 'COMPOSE_FOCUS';
const messages = defineMessages({ const messages = defineMessages({
uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' }, uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' },
uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' }, 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' }, open: { id: 'compose.published.open', defaultMessage: 'Open' },
published: { id: 'compose.published.body', defaultMessage: 'Post published.' }, published: { id: 'compose.published.body', defaultMessage: 'Post published.' },
saved: { id: 'compose.saved.body', defaultMessage: 'Post saved.' }, saved: { id: 'compose.saved.body', defaultMessage: 'Post saved.' },
@ -96,12 +97,17 @@ export const ensureComposeIsVisible = (getState) => {
}; };
export function setComposeToStatus(status, text, spoiler_text) { export function setComposeToStatus(status, text, spoiler_text) {
return{ return (dispatch, getState) => {
type: COMPOSE_SET_STATUS, const maxOptions = getState().server.getIn(['server', 'configuration', 'polls', 'max_options']);
status,
text, dispatch({
spoiler_text, type: COMPOSE_SET_STATUS,
}; status,
text,
spoiler_text,
maxOptions,
});
}
} }
export function changeCompose(text) { export function changeCompose(text) {
@ -146,7 +152,7 @@ export function resetCompose() {
}; };
} }
export const focusCompose = (defaultText) => (dispatch, getState) => { export const focusCompose = (defaultText = '') => (dispatch, getState) => {
dispatch({ dispatch({
type: COMPOSE_FOCUS, type: COMPOSE_FOCUS,
defaultText, defaultText,
@ -183,7 +189,7 @@ export function directCompose(account) {
}; };
} }
export function submitCompose() { export function submitCompose(successCallback) {
return function (dispatch, getState) { return function (dispatch, getState) {
const status = getState().getIn(['compose', 'text'], ''); const status = getState().getIn(['compose', 'text'], '');
const media = getState().getIn(['compose', 'media_attachments']); const media = getState().getIn(['compose', 'media_attachments']);
@ -215,6 +221,7 @@ export function submitCompose() {
}); });
} }
const visibility = getState().getIn(['compose', 'privacy']);
api().request({ api().request({
url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`, url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`,
method: statusId === null ? 'post' : 'put', method: statusId === null ? 'post' : 'put',
@ -225,9 +232,11 @@ export function submitCompose() {
media_attributes, media_attributes,
sensitive: getState().getIn(['compose', 'sensitive']), sensitive: getState().getIn(['compose', 'sensitive']),
spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '', spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '',
visibility: getState().getIn(['compose', 'privacy']), visibility: visibility,
poll: getState().getIn(['compose', 'poll'], null), poll: getState().getIn(['compose', 'poll'], null),
language: getState().getIn(['compose', 'language']), language: getState().getIn(['compose', 'language']),
quoted_status_id: getState().getIn(['compose', 'quoted_status_id']),
quote_approval_policy: visibility === 'private' || visibility === 'direct' ? 'nobody' : getState().getIn(['compose', 'quote_policy']),
}, },
headers: { headers: {
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']), 'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
@ -239,6 +248,9 @@ export function submitCompose() {
dispatch(insertIntoTagHistory(response.data.tags, status)); dispatch(insertIntoTagHistory(response.data.tags, status));
dispatch(submitComposeSuccess({ ...response.data })); dispatch(submitComposeSuccess({ ...response.data }));
if (typeof successCallback === 'function') {
successCallback(response.data);
}
// To make the app more responsive, immediately push the status // To make the app more responsive, immediately push the status
// into the columns // into the columns
@ -298,6 +310,11 @@ export function submitComposeFail(error) {
export function uploadCompose(files) { export function uploadCompose(files) {
return function (dispatch, getState) { 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 uploadLimit = getState().getIn(['server', 'server', 'configuration', 'statuses', 'max_media_attachments']);
const media = getState().getIn(['compose', 'media_attachments']); const media = getState().getIn(['compose', 'media_attachments']);
const pending = getState().getIn(['compose', 'pending_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 type { List as ImmutableList, Map as ImmutableMap } from 'immutable';
import { apiUpdateMedia } from 'mastodon/api/compose'; import { apiUpdateMedia } from 'mastodon/api/compose';
import type { ApiMediaAttachmentJSON } from 'mastodon/api_types/media_attachments'; import type { ApiMediaAttachmentJSON } from 'mastodon/api_types/media_attachments';
import type { MediaAttachment } from 'mastodon/models/media_attachment'; import type { MediaAttachment } from 'mastodon/models/media_attachment';
import { 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 & { type SimulatedMediaAttachmentJSON = ApiMediaAttachmentJSON & {
unattached?: boolean; unattached?: boolean;
@ -68,3 +99,55 @@ export const changeUploadCompose = createDataLoadingThunk(
useLoadingBar: false, 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 type { StatusVisibility } from 'mastodon/models/status';
import { createDataLoadingThunk } from 'mastodon/store/typed_functions'; import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
import { importFetchedStatus } from './importer'; import { importFetchedStatus, importFetchedStatuses } from './importer';
export const reblog = createDataLoadingThunk( export const reblog = createDataLoadingThunk(
'status/reblog', 'status/reblog',
@ -33,3 +38,35 @@ export const unreblog = createDataLoadingThunk(
return discardLoadData; return discardLoadData;
}, },
); );
export const revokeQuote = createDataLoadingThunk(
'status/revoke_quote',
({
statusId,
quotedStatusId,
}: {
statusId: string;
quotedStatusId: string;
}) => apiRevokeQuote(quotedStatusId, statusId),
(data, { dispatch, discardLoadData }) => {
dispatch(importFetchedStatus(data));
return discardLoadData;
},
);
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 { NOTIFICATIONS_FILTER_SET } from './notifications';
import { saveSettings } from './settings'; 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) { function excludeAllTypesExcept(filter: string) {
return allNotificationTypes.filter((item) => item !== filter); return allNotificationTypes.filter(
(item) => notificationTypeForQuickFilter(item) !== filter,
);
} }
function getExcludedTypes(state: RootState) { function getExcludedTypes(state: RootState) {
@ -155,13 +168,17 @@ export const processNewNotificationForGroups = createAppAsyncThunk(
const showInColumn = const showInColumn =
activeFilter === 'all' activeFilter === 'all'
? notificationShows[notification.type] !== false ? notificationShows[notificationTypeForFilter(notification.type)] !==
: activeFilter === notification.type; false
: activeFilter === notificationTypeForQuickFilter(notification.type);
if (!showInColumn) return; if (!showInColumn) return;
if ( if (
(notification.type === 'mention' || notification.type === 'update') && (notification.type === 'mention' ||
notification.type === 'quote' ||
notification.type === 'update' ||
notification.type === 'quoted_update') &&
notification.status?.filtered notification.status?.filtered
) { ) {
const filters = notification.status.filtered.filter((result) => const filters = notification.status.filtered.filter((result) =>

View File

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

View File

@ -1,9 +1,12 @@
import { defineMessages } from 'react-intl';
import { browserHistory } from 'mastodon/components/router'; import { browserHistory } from 'mastodon/components/router';
import api from '../api'; import api from '../api';
import { showAlert } from './alerts';
import { ensureComposeIsVisible, setComposeToStatus } from './compose'; import { ensureComposeIsVisible, setComposeToStatus } from './compose';
import { importFetchedStatus, importFetchedStatuses, importFetchedAccount } from './importer'; import { importFetchedStatus, importFetchedAccount } from './importer';
import { fetchContext } from './statuses_typed'; import { fetchContext } from './statuses_typed';
import { deleteFromTimelines } from './timelines'; 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_FAIL = 'STATUS_TRANSLATE_FAIL';
export const STATUS_TRANSLATE_UNDO = 'STATUS_TRANSLATE_UNDO'; export const STATUS_TRANSLATE_UNDO = 'STATUS_TRANSLATE_UNDO';
const messages = defineMessages({
deleteSuccess: { id: 'status.delete.success', defaultMessage: 'Post deleted' },
});
export function fetchStatusRequest(id, skipLoading) { export function fetchStatusRequest(id, skipLoading) {
return { return {
type: STATUS_FETCH_REQUEST, 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) => { return (dispatch, getState) => {
const skipLoading = !forceFetch && getState().getIn(['statuses', id], null) !== null; 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(importFetchedStatus(response.data));
dispatch(fetchStatusSuccess(skipLoading)); dispatch(fetchStatusSuccess(skipLoading));
}).catch(error => { }).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 { return {
type: STATUS_FETCH_FAIL, type: STATUS_FETCH_FAIL,
id, id,
error, error,
parentQuotePostId,
skipLoading, skipLoading,
skipAlert: true, skipAlert: true,
}; };
} }
export function redraft(status, raw_text) { export function redraft(status, raw_text) {
return { return (dispatch, getState) => {
type: REDRAFT, const maxOptions = getState().server.getIn(['server', 'configuration', 'polls', 'max_options']);
status,
raw_text, dispatch({
type: REDRAFT,
status,
raw_text,
maxOptions,
});
}; };
} }
@ -137,7 +161,7 @@ export function deleteStatus(id, withRedraft = false) {
dispatch(deleteStatusRequest(id)); 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(deleteStatusSuccess(id));
dispatch(deleteFromTimelines(id)); dispatch(deleteFromTimelines(id));
dispatch(importFetchedAccount(response.data.account)); dispatch(importFetchedAccount(response.data.account));
@ -145,9 +169,14 @@ export function deleteStatus(id, withRedraft = false) {
if (withRedraft) { if (withRedraft) {
dispatch(redraft(status, response.data.text)); dispatch(redraft(status, response.data.text));
ensureComposeIsVisible(getState); ensureComposeIsVisible(getState);
} else {
dispatch(showAlert({ message: messages.deleteSuccess }));
} }
return response;
}).catch(error => { }).catch(error => {
dispatch(deleteStatusFail(id, error)); dispatch(deleteStatusFail(id, error));
throw error;
}); });
}; };
} }

View File

@ -1,8 +1,10 @@
import { createAction } from '@reduxjs/toolkit'; 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 { createDataLoadingThunk } from 'mastodon/store/typed_functions';
import type { ApiQuotePolicy } from '../api_types/quotes';
import { importFetchedStatuses } from './importer'; import { importFetchedStatuses } from './importer';
export const fetchContext = createDataLoadingThunk( export const fetchContext = createDataLoadingThunk(
@ -23,3 +25,10 @@ export const fetchContext = createDataLoadingThunk(
export const completeContextRefresh = createAction<{ statusId: string }>( export const completeContextRefresh = createAction<{ statusId: string }>(
'status/context/complete', '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 api, { apiRequestPost, getLinks } from 'mastodon/api';
import type { Status, StatusVisibility } from 'mastodon/models/status'; import type { ApiStatusJSON } from 'mastodon/api_types/statuses';
import type { StatusVisibility } from 'mastodon/models/status';
export const apiReblog = (statusId: string, visibility: StatusVisibility) => export const apiReblog = (statusId: string, visibility: StatusVisibility) =>
apiRequestPost<{ reblog: Status }>(`v1/statuses/${statusId}/reblog`, { apiRequestPost<{ reblog: ApiStatusJSON }>(`v1/statuses/${statusId}/reblog`, {
visibility, visibility,
}); });
export const apiUnreblog = (statusId: string) => 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 api, { apiRequestPut, getAsyncRefreshHeader } from 'mastodon/api';
import type { ApiContextJSON } from 'mastodon/api_types/statuses'; import type {
ApiContextJSON,
ApiStatusJSON,
} from 'mastodon/api_types/statuses';
import type { ApiQuotePolicy } from '../api_types/quotes';
export const apiGetContext = async (statusId: string) => { export const apiGetContext = async (statusId: string) => {
const response = await api().request<ApiContextJSON>({ const response = await api().request<ApiContextJSON>({
@ -12,3 +17,15 @@ export const apiGetContext = async (statusId: string) => {
refresh: getAsyncRefreshHeader(response), 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[]; roles?: ApiAccountJSON[];
statuses_count: number; statuses_count: number;
uri: string; uri: string;
url: string; url?: string;
username: string; username: string;
moved?: ApiAccountJSON; moved?: ApiAccountJSON;
suspended?: boolean; suspended?: boolean;

View File

@ -7,12 +7,13 @@ import type { ApiReportJSON } from './reports';
import type { ApiStatusJSON } from './statuses'; import type { ApiStatusJSON } from './statuses';
// See app/model/notification.rb // See app/model/notification.rb
export const allNotificationTypes = [ export const allNotificationTypes: NotificationType[] = [
'follow', 'follow',
'follow_request', 'follow_request',
'favourite', 'favourite',
'reblog', 'reblog',
'mention', 'mention',
'quote',
'poll', 'poll',
'status', 'status',
'update', 'update',
@ -28,8 +29,10 @@ export type NotificationWithStatusType =
| 'reblog' | 'reblog'
| 'status' | 'status'
| 'mention' | 'mention'
| 'quote'
| 'poll' | 'poll'
| 'update'; | 'update'
| 'quoted_update';
export type NotificationType = export type NotificationType =
| NotificationWithStatusType | 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 { ApiCustomEmojiJSON } from './custom_emoji';
import type { ApiMediaAttachmentJSON } from './media_attachments'; import type { ApiMediaAttachmentJSON } from './media_attachments';
import type { ApiPollJSON } from './polls'; import type { ApiPollJSON } from './polls';
import type { ApiQuoteJSON, ApiQuotePolicyJSON } from './quotes';
// See app/modals/status.rb // See app/modals/status.rb
export type StatusVisibility = export type StatusVisibility =
@ -95,6 +96,7 @@ export interface ApiStatusJSON {
replies_count: number; replies_count: number;
reblogs_count: number; reblogs_count: number;
favorites_count: number; favorites_count: number;
quotes_count: number;
edited_at?: string; edited_at?: string;
favorited?: boolean; favorited?: boolean;
@ -118,9 +120,23 @@ export interface ApiStatusJSON {
card?: ApiPreviewCardJSON; card?: ApiPreviewCardJSON;
poll?: ApiPollJSON; poll?: ApiPollJSON;
quote?: ApiQuoteJSON;
quote_approval?: ApiQuotePolicyJSON;
} }
export interface ApiContextJSON { export interface ApiContextJSON {
ancestors: ApiStatusJSON[]; ancestors: ApiStatusJSON[];
descendants: 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, onFocus,
autoFocus = true, autoFocus = true,
lang, lang,
className,
}, textareaRef) => { }, textareaRef) => {
const [suggestionsHidden, setSuggestionsHidden] = useState(true); const [suggestionsHidden, setSuggestionsHidden] = useState(true);
@ -192,7 +193,7 @@ const AutosuggestTextarea = forwardRef(({
}; };
return ( return (
<div className='autosuggest-textarea'> <div className={classNames('autosuggest-textarea', className)}>
<Textarea <Textarea
ref={textareaRef} ref={textareaRef}
className='autosuggest-textarea__textarea' 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; let id = 0;
type RenderItemFn<Item = MenuItem> = ( export interface RenderItemFnHandlers {
onClick: React.MouseEventHandler;
onKeyUp: React.KeyboardEventHandler;
}
export type RenderItemFn<Item = MenuItem> = (
item: Item, item: Item,
index: number, index: number,
handlers: { handlers: RenderItemFnHandlers,
onClick: (e: React.MouseEvent) => void; focusRefCallback?: (c: HTMLAnchorElement | HTMLButtonElement | null) => void,
onKeyUp: (e: React.KeyboardEvent) => void;
},
) => React.ReactNode; ) => React.ReactNode;
type ItemClickFn<Item = MenuItem> = (item: Item, index: number) => void; type ItemClickFn<Item = MenuItem> = (item: Item, index: number) => void;
@ -173,7 +176,7 @@ export const DropdownMenu = <Item = MenuItem,>({
onItemClick(item, i); onItemClick(item, i);
} else if (isActionItem(item)) { } else if (isActionItem(item)) {
e.preventDefault(); e.preventDefault();
item.action(); item.action(e);
} }
}, },
[onClose, onItemClick, items], [onClose, onItemClick, items],
@ -277,10 +280,15 @@ export const DropdownMenu = <Item = MenuItem,>({
})} })}
> >
{items.map((option, i) => {items.map((option, i) =>
renderItemMethod(option, i, { renderItemMethod(
onClick: handleItemClick, option,
onKeyUp: handleItemKeyUp, i,
}), {
onClick: handleItemClick,
onKeyUp: handleItemKeyUp,
},
i === 0 ? handleFocusedItemRef : undefined,
),
)} )}
</ul> </ul>
)} )}
@ -307,7 +315,9 @@ interface DropdownProps<Item = MenuItem> {
forceDropdown?: boolean; forceDropdown?: boolean;
renderItem?: RenderItemFn<Item>; renderItem?: RenderItemFn<Item>;
renderHeader?: RenderHeaderFn<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>; onItemClick?: ItemClickFn<Item>;
} }
@ -376,7 +386,7 @@ export const Dropdown = <Item = MenuItem,>({
onItemClick(item, i); onItemClick(item, i);
} else if (isActionItem(item)) { } else if (isActionItem(item)) {
e.preventDefault(); e.preventDefault();
item.action(); item.action(e);
} }
}, },
[handleClose, onItemClick, items], [handleClose, onItemClick, items],
@ -389,7 +399,10 @@ export const Dropdown = <Item = MenuItem,>({
if (open) { if (open) {
handleClose(); handleClose();
} else { } else {
onOpen?.(); const allow = onOpen?.(e);
if (allow === false) {
return;
}
if (prefetchAccountId) { if (prefetchAccountId) {
dispatch(fetchRelationships([prefetchAccountId])); dispatch(fetchRelationships([prefetchAccountId]));

View File

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

View File

@ -7,11 +7,7 @@ import { normalizeKey, isKeyboardEvent } from './utils';
* the hotkey with a higher priority is selected. All others * the hotkey with a higher priority is selected. All others
* are ignored. * are ignored.
*/ */
const hotkeyPriority = { const hotkeyPriority = { singleKey: 0, combo: 1, sequence: 2 } as const;
singleKey: 0,
combo: 1,
sequence: 2,
} as const;
/** /**
* This type of function receives a keyboard event and an array of * This type of function receives a keyboard event and an array of
@ -105,14 +101,16 @@ const hotkeyMatcherMap = {
new: just('n'), new: just('n'),
forceNew: optionPlus('n'), forceNew: optionPlus('n'),
focusColumn: any('1', '2', '3', '4', '5', '6', '7', '8', '9'), focusColumn: any('1', '2', '3', '4', '5', '6', '7', '8', '9'),
focusLoadMore: just('l'),
reply: just('r'), reply: just('r'),
favourite: just('f'), favourite: just('f'),
boost: just('b'), boost: just('b'),
quote: just('q'),
mention: just('m'), mention: just('m'),
open: any('enter', 'o'), open: any('enter', 'o'),
openProfile: just('p'), openProfile: just('p'),
moveDown: any('down', 'j'), moveDown: just('j'),
moveUp: any('up', 'k'), moveUp: just('k'),
toggleHidden: just('x'), toggleHidden: just('x'),
toggleSensitive: just('h'), toggleSensitive: just('h'),
toggleComposeSpoilers: optionPlus('x'), 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 AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react'; import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
import CancelFillIcon from '@/material-icons/400-24px/cancel-fill.svg?react';
import { Hotkeys } from 'mastodon/components/hotkeys'; import { Hotkeys } from 'mastodon/components/hotkeys';
import { ContentWarning } from 'mastodon/components/content_warning'; import { ContentWarning } from 'mastodon/components/content_warning';
import { FilterWarning } from 'mastodon/components/filter_warning'; import { FilterWarning } from 'mastodon/components/filter_warning';
@ -34,6 +35,8 @@ import StatusActionBar from './status_action_bar';
import StatusContent from './status_content'; import StatusContent from './status_content';
import { StatusThreadLabel } from './status_thread_label'; import { StatusThreadLabel } from './status_thread_label';
import { VisibilityIcon } from './visibility_icon'; import { VisibilityIcon } from './visibility_icon';
import { IconButton } from './icon_button';
const domParser = new DOMParser(); const domParser = new DOMParser();
export const textForScreenReader = (intl, status, rebloggedByText = false) => { export const textForScreenReader = (intl, status, rebloggedByText = false) => {
@ -75,6 +78,7 @@ const messages = defineMessages({
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers' }, private_short: { id: 'privacy.private.short', defaultMessage: 'Followers' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Specific people' }, direct_short: { id: 'privacy.direct.short', defaultMessage: 'Specific people' },
edited: { id: 'status.edited', defaultMessage: 'Edited {date}' }, edited: { id: 'status.edited', defaultMessage: 'Edited {date}' },
quote_cancel: { id: 'status.quote.cancel', defaultMessage: 'Cancel quote' },
}); });
class Status extends ImmutablePureComponent { class Status extends ImmutablePureComponent {
@ -92,6 +96,7 @@ class Status extends ImmutablePureComponent {
onReply: PropTypes.func, onReply: PropTypes.func,
onFavourite: PropTypes.func, onFavourite: PropTypes.func,
onReblog: PropTypes.func, onReblog: PropTypes.func,
onQuote: PropTypes.func,
onDelete: PropTypes.func, onDelete: PropTypes.func,
onDirect: PropTypes.func, onDirect: PropTypes.func,
onMention: PropTypes.func, onMention: PropTypes.func,
@ -106,11 +111,10 @@ class Status extends ImmutablePureComponent {
onToggleCollapsed: PropTypes.func, onToggleCollapsed: PropTypes.func,
onTranslate: PropTypes.func, onTranslate: PropTypes.func,
onInteractionModal: PropTypes.func, onInteractionModal: PropTypes.func,
onQuoteCancel: PropTypes.func,
muted: PropTypes.bool, muted: PropTypes.bool,
hidden: PropTypes.bool, hidden: PropTypes.bool,
unread: PropTypes.bool, unread: PropTypes.bool,
onMoveUp: PropTypes.func,
onMoveDown: PropTypes.func,
showThread: PropTypes.bool, showThread: PropTypes.bool,
isQuotedPost: PropTypes.bool, isQuotedPost: PropTypes.bool,
getScrollPosition: PropTypes.func, getScrollPosition: PropTypes.func,
@ -126,6 +130,7 @@ class Status extends ImmutablePureComponent {
inUse: PropTypes.bool, inUse: PropTypes.bool,
available: PropTypes.bool, available: PropTypes.bool,
}), }),
contextType: PropTypes.string,
...WithOptionalRouterPropTypes, ...WithOptionalRouterPropTypes,
}; };
@ -272,6 +277,10 @@ class Status extends ImmutablePureComponent {
this.props.onReblog(this._properStatus(), e); this.props.onReblog(this._properStatus(), e);
}; };
handleHotkeyQuote = () => {
this.props.onQuote(this._properStatus());
};
handleHotkeyMention = e => { handleHotkeyMention = e => {
e.preventDefault(); e.preventDefault();
this.props.onMention(this._properStatus().get('account')); this.props.onMention(this._properStatus().get('account'));
@ -322,14 +331,6 @@ class Status extends ImmutablePureComponent {
history.push(`/@${status.getIn(['account', 'acct'])}`); 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 = () => { handleHotkeyToggleHidden = () => {
const { onToggleHidden } = this.props; const { onToggleHidden } = this.props;
const status = this._properStatus(); const status = this._properStatus();
@ -359,6 +360,10 @@ class Status extends ImmutablePureComponent {
this.setState(state => ({ ...state, showDespiteFilter: !state.showDespiteFilter })); this.setState(state => ({ ...state, showDespiteFilter: !state.showDespiteFilter }));
}; };
handleQuoteCancel = () => {
this.props.onQuoteCancel?.();
}
_properStatus () { _properStatus () {
const { status } = this.props; const { status } = this.props;
@ -386,11 +391,10 @@ class Status extends ImmutablePureComponent {
reply: this.handleHotkeyReply, reply: this.handleHotkeyReply,
favourite: this.handleHotkeyFavourite, favourite: this.handleHotkeyFavourite,
boost: this.handleHotkeyBoost, boost: this.handleHotkeyBoost,
quote: this.handleHotkeyQuote,
mention: this.handleHotkeyMention, mention: this.handleHotkeyMention,
open: this.handleHotkeyOpen, open: this.handleHotkeyOpen,
openProfile: this.handleHotkeyOpenProfile, openProfile: this.handleHotkeyOpenProfile,
moveUp: this.handleHotkeyMoveUp,
moveDown: this.handleHotkeyMoveDown,
toggleHidden: this.handleHotkeyToggleHidden, toggleHidden: this.handleHotkeyToggleHidden,
toggleSensitive: this.handleHotkeyToggleSensitive, toggleSensitive: this.handleHotkeyToggleSensitive,
openMedia: this.handleHotkeyOpenMedia, openMedia: this.handleHotkeyOpenMedia,
@ -573,6 +577,16 @@ class Status extends ImmutablePureComponent {
<DisplayName account={status.get('account')} /> <DisplayName account={status.get('account')} />
</Link> </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> </div>
{matchedFilters && <FilterWarning title={matchedFilters.join(', ')} expanded={this.state.showDespiteFilter} onClick={this.handleFilterToggle} />} {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 { defineMessages, injectIntl } from 'react-intl';
import classNames from 'classnames';
import { withRouter } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes'; 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 BookmarkIcon from '@/material-icons/400-24px/bookmark-fill.svg?react';
import BookmarkBorderIcon from '@/material-icons/400-24px/bookmark.svg?react'; import BookmarkBorderIcon from '@/material-icons/400-24px/bookmark.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.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 ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
import ReplyAllIcon from '@/material-icons/400-24px/reply_all.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 StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
import StarBorderIcon from '@/material-icons/400-24px/star.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 { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/permissions'; import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/permissions';
import { WithRouterPropTypes } from 'mastodon/utils/react_router'; import { WithRouterPropTypes } from 'mastodon/utils/react_router';
@ -29,6 +23,8 @@ import { Dropdown } from 'mastodon/components/dropdown_menu';
import { me } from '../initial_state'; import { me } from '../initial_state';
import { IconButton } from './icon_button'; import { IconButton } from './icon_button';
import { isFeatureEnabled } from '../utils/environment';
import { ReblogButton } from './status/reblog_button';
const messages = defineMessages({ const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' }, delete: { id: 'status.delete', defaultMessage: 'Delete' },
@ -42,10 +38,6 @@ const messages = defineMessages({
share: { id: 'status.share', defaultMessage: 'Share' }, share: { id: 'status.share', defaultMessage: 'Share' },
more: { id: 'status.more', defaultMessage: 'More' }, more: { id: 'status.more', defaultMessage: 'More' },
replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' }, 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' }, favourite: { id: 'status.favourite', defaultMessage: 'Favorite' },
removeFavourite: { id: 'status.remove_favourite', defaultMessage: 'Remove from favorites' }, removeFavourite: { id: 'status.remove_favourite', defaultMessage: 'Remove from favorites' },
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' }, bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
@ -67,21 +59,29 @@ const messages = defineMessages({
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
filter: { id: 'status.filter', defaultMessage: 'Filter this post' }, filter: { id: 'status.filter', defaultMessage: 'Filter this post' },
openOriginalPage: { id: 'account.open_original_page', defaultMessage: 'Open original page' }, openOriginalPage: { id: 'account.open_original_page', defaultMessage: 'Open original page' },
revokeQuote: { id: 'status.revoke_quote', defaultMessage: 'Remove my post from @{name}s post' },
quotePolicyChange: { id: 'status.quote_policy_change', defaultMessage: 'Change who can quote' },
}); });
const mapStateToProps = (state, { status }) => ({ const mapStateToProps = (state, { status }) => {
relationship: state.getIn(['relationships', status.getIn(['account', 'id'])]), 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 { class StatusActionBar extends ImmutablePureComponent {
static propTypes = { static propTypes = {
identity: identityContextPropShape, identity: identityContextPropShape,
status: ImmutablePropTypes.map.isRequired, status: ImmutablePropTypes.map.isRequired,
relationship: ImmutablePropTypes.record, relationship: ImmutablePropTypes.record,
quotedAccountId: PropTypes.string,
onReply: PropTypes.func, onReply: PropTypes.func,
onFavourite: PropTypes.func, onFavourite: PropTypes.func,
onReblog: PropTypes.func,
onDelete: PropTypes.func, onDelete: PropTypes.func,
onRevokeQuote: PropTypes.func,
onQuotePolicyChange: PropTypes.func,
onDirect: PropTypes.func, onDirect: PropTypes.func,
onMention: PropTypes.func, onMention: PropTypes.func,
onMute: PropTypes.func, onMute: PropTypes.func,
@ -110,6 +110,7 @@ class StatusActionBar extends ImmutablePureComponent {
updateOnProps = [ updateOnProps = [
'status', 'status',
'relationship', 'relationship',
'quotedAccountId',
'withDismiss', '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 = () => { handleBookmarkClick = () => {
this.props.onBookmark(this.props.status); 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 = () => { handleBlockClick = () => {
const { status, relationship, onBlock, onUnblock } = this.props; const { status, relationship, onBlock, onUnblock } = this.props;
const account = status.get('account'); const account = status.get('account');
@ -241,7 +240,7 @@ class StatusActionBar extends ImmutablePureComponent {
}; };
render () { 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 { signedIn, permissions } = this.props.identity;
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
@ -279,6 +278,9 @@ class StatusActionBar extends ImmutablePureComponent {
if (writtenByMe || withDismiss) { if (writtenByMe || withDismiss) {
menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); 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); 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({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.handleDirectClick });
menu.push(null); menu.push(null);
if (quotedAccountId === me) {
menu.push({ text: intl.formatMessage(messages.revokeQuote, { name: account.get('username') }), action: this.handleRevokeQuoteClick, dangerous: true });
}
if (relationship && relationship.get('muting')) { if (relationship && relationship.get('muting')) {
menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.handleMuteClick }); menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.handleMuteClick });
} else { } else {
@ -351,25 +357,6 @@ class StatusActionBar extends ImmutablePureComponent {
replyTitle = intl.formatMessage(messages.replyAll); 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 bookmarkTitle = intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark);
const favouriteTitle = intl.formatMessage(status.get('favourited') ? messages.removeFavourite : messages.favourite); const favouriteTitle = intl.formatMessage(status.get('favourited') ? messages.removeFavourite : messages.favourite);
const isReply = status.get('in_reply_to_account_id') === status.getIn(['account', 'id']); 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')} /> <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>
<div className='status__action-bar__button-wrapper'> <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>
<div className='status__action-bar__button-wrapper'> <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} /> <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); onCollapsedToggle(collapsed);
} }
// Remove quote fallback link from the DOM so it doesn't
// mess with paragraph margins
if (!!status.get('quote')) {
const inlineQuote = node.querySelector('.quote-inline');
if (inlineQuote) {
inlineQuote.remove();
}
}
} }
handleMouseEnter = ({ currentTarget }) => { handleMouseEnter = ({ currentTarget }) => {

View File

@ -14,6 +14,7 @@ import { StatusQuoteManager } from '../components/status_quoted';
import { LoadGap } from './load_gap'; import { LoadGap } from './load_gap';
import ScrollableList from './scrollable_list'; import ScrollableList from './scrollable_list';
export default class StatusList extends ImmutablePureComponent { export default class StatusList extends ImmutablePureComponent {
static propTypes = { static propTypes = {
@ -40,84 +41,6 @@ export default class StatusList extends ImmutablePureComponent {
trackScroll: true, trackScroll: true,
}; };
componentDidMount() {
this.columnHeaderHeight = this.node?.node
? parseFloat(
getComputedStyle(this.node.node).getPropertyValue('--column-header-height')
) || 0
: 0;
}
getFeaturedStatusCount = () => {
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(() => { handleLoadOlder = debounce(() => {
const { statusIds, lastId, onLoadMore } = this.props; const { statusIds, lastId, onLoadMore } = this.props;
onLoadMore(lastId || (statusIds.size > 0 ? statusIds.last() : undefined)); onLoadMore(lastId || (statusIds.size > 0 ? statusIds.last() : undefined));
@ -158,8 +81,6 @@ export default class StatusList extends ImmutablePureComponent {
<StatusQuoteManager <StatusQuoteManager
key={statusId} key={statusId}
id={statusId} id={statusId}
onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown}
contextType={timelineId} contextType={timelineId}
scrollKey={this.props.scrollKey} scrollKey={this.props.scrollKey}
showThread showThread
@ -176,8 +97,6 @@ export default class StatusList extends ImmutablePureComponent {
key={`f-${statusId}`} key={`f-${statusId}`}
id={statusId} id={statusId}
featured featured
onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown}
contextType={timelineId} contextType={timelineId}
showThread showThread
withCounters={this.props.withCounters} withCounters={this.props.withCounters}
@ -191,5 +110,4 @@ export default class StatusList extends ImmutablePureComponent {
</ScrollableList> </ScrollableList>
); );
} }
} }

View File

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

View File

@ -12,6 +12,7 @@ import {
mentionCompose, mentionCompose,
directCompose, directCompose,
} from '../actions/compose'; } from '../actions/compose';
import { quoteComposeById } from '../actions/compose_typed';
import { import {
initDomainBlockModal, initDomainBlockModal,
unblockDomain, unblockDomain,
@ -41,10 +42,13 @@ import {
translateStatus, translateStatus,
undoStatusTranslation, undoStatusTranslation,
} from '../actions/statuses'; } from '../actions/statuses';
import { setStatusQuotePolicy } from '../actions/statuses_typed';
import Status from '../components/status'; import Status from '../components/status';
import { deleteModal } from '../initial_state'; import { deleteModal } from '../initial_state';
import { makeGetStatus, makeGetPictureInPicture } from '../selectors'; import { makeGetStatus, makeGetPictureInPicture } from '../selectors';
import { isFeatureEnabled } from 'mastodon/utils/environment';
const makeMapStateToProps = () => { const makeMapStateToProps = () => {
const getStatus = makeGetStatus(); const getStatus = makeGetStatus();
const getPictureInPicture = makeGetPictureInPicture(); const getPictureInPicture = makeGetPictureInPicture();
@ -75,6 +79,12 @@ const mapDispatchToProps = (dispatch, { contextType }) => ({
onReblog (status, e) { onReblog (status, e) {
dispatch(toggleReblog(status.get('id'), e.shiftKey)); dispatch(toggleReblog(status.get('id'), e.shiftKey));
}, },
onQuote (status) {
if (isFeatureEnabled('outgoing_quotes')) {
dispatch(quoteComposeById(status.get('id')));
}
},
onFavourite (status) { onFavourite (status) {
dispatch(toggleFavourite(status.get('id'))); dispatch(toggleFavourite(status.get('id')));
@ -107,10 +117,30 @@ const mapDispatchToProps = (dispatch, { contextType }) => ({
if (!deleteModal) { if (!deleteModal) {
dispatch(deleteStatus(status.get('id'), withRedraft)); dispatch(deleteStatus(status.get('id'), withRedraft));
} else { } 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) { onEdit (status) {
dispatch((_, getState) => { dispatch((_, getState) => {
let state = getState(); let state = getState();

View File

@ -15,10 +15,8 @@ import { missingAltTextModal } from 'mastodon/initial_state';
import AutosuggestInput from 'mastodon/components/autosuggest_input'; import AutosuggestInput from 'mastodon/components/autosuggest_input';
import AutosuggestTextarea from 'mastodon/components/autosuggest_textarea'; import AutosuggestTextarea from 'mastodon/components/autosuggest_textarea';
import { Button } from 'mastodon/components/button'; import { Button } from 'mastodon/components/button';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container'; import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
import PollButtonContainer from '../containers/poll_button_container'; import PollButtonContainer from '../containers/poll_button_container';
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
import SpoilerButtonContainer from '../containers/spoiler_button_container'; import SpoilerButtonContainer from '../containers/spoiler_button_container';
import UploadButtonContainer from '../containers/upload_button_container'; import UploadButtonContainer from '../containers/upload_button_container';
import { countableText } from '../util/counter'; import { countableText } from '../util/counter';
@ -31,6 +29,8 @@ import { PollForm } from "./poll_form";
import { ReplyIndicator } from './reply_indicator'; import { ReplyIndicator } from './reply_indicator';
import { UploadForm } from './upload_form'; import { UploadForm } from './upload_form';
import { Warning } from './warning'; 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'; 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, singleColumn: PropTypes.bool,
lang: PropTypes.string, lang: PropTypes.string,
maxChars: PropTypes.number, maxChars: PropTypes.number,
redirectOnSuccess: PropTypes.bool,
}; };
static defaultProps = { static defaultProps = {
@ -255,62 +256,62 @@ class ComposeForm extends ImmutablePureComponent {
<Warning /> <Warning />
<div className={classNames('compose-form__highlightable', { active: highlighted })} ref={this.setRef}> <div className={classNames('compose-form__highlightable', { active: highlighted })} ref={this.setRef}>
<div className='compose-form__scrollable'> <EditIndicator />
<EditIndicator />
{this.props.spoiler && ( <div className='compose-form__dropdowns'>
<div className='spoiler-input'> <VisibilityButton disabled={this.props.isEditing} />
<div className='spoiler-input__border' /> <LanguageDropdown />
<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> </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 /> <UploadForm />
<PollForm /> <PollForm />
<ComposeQuotedStatus />
<div className='compose-form__footer'> <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__actions'>
<div className='compose-form__buttons'> <div className='compose-form__buttons'>
<UploadButtonContainer /> <UploadButtonContainer />
@ -329,7 +330,7 @@ class ComposeForm extends ImmutablePureComponent {
> >
{intl.formatMessage( {intl.formatMessage(
this.props.isEditing ? this.props.isEditing ?
messages.saveChanges : messages.saveChanges :
(this.props.isInReply ? messages.reply : messages.publish) (this.props.isInReply ? messages.reply : messages.publish)
)} )}
</Button> </Button>

View File

@ -396,7 +396,7 @@ export const LanguageDropdown: React.FC = () => {
warning: guess !== '' && guess !== value, warning: guess !== '' && guess !== value,
})} })}
> >
<Icon id='' icon={TranslateIcon} /> <Icon id='translate' icon={TranslateIcon} />
<span className='dropdown-button__label'>{current[2] ?? value}</span> <span className='dropdown-button__label'>{current[2] ?? value}</span>
</button> </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 { DropdownSelector } from 'mastodon/components/dropdown_selector';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
const messages = defineMessages({ export const messages = defineMessages({
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' }, public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
public_long: { id: 'privacy.public.long', defaultMessage: 'Anyone on and off Mastodon' }, public_long: { id: 'privacy.public.long', defaultMessage: 'Anyone on and off Mastodon' },
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Quiet public' }, 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' /> <div className='reply-indicator__line' />
<Link to={`/@${account.get('acct')}`} className='detailed-status__display-avatar'> <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> </Link>
<div className='reply-indicator__main'> <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), maxChars: state.getIn(['server', 'server', 'configuration', 'statuses', 'max_characters'], 500),
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch, props) => ({
onChange (text) { onChange (text) {
dispatch(changeCompose(text)); dispatch(changeCompose(text));
@ -47,7 +47,11 @@ const mapDispatchToProps = (dispatch) => ({
modalProps: {}, modalProps: {},
})); }));
} else { } 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 { addPoll, removePoll } from '../../../actions/compose';
import PollButton from '../components/poll_button'; import PollButton from '../components/poll_button';
const mapStateToProps = state => ({ const mapStateToProps = state => {
disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 0), const readyAttachmentsSize = state.compose.get('media_attachments').size ?? 0;
active: state.getIn(['compose', 'poll']) !== null, 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 => ({ const mapDispatchToProps = dispatch => ({

View File

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

View File

@ -45,7 +45,7 @@ const getAccounts = createSelector(
const getStatus = makeGetStatus(); const getStatus = makeGetStatus();
export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown }) => { export const Conversation = ({ conversation, scrollKey }) => {
const id = conversation.get('id'); const id = conversation.get('id');
const unread = conversation.get('unread'); const unread = conversation.get('unread');
const lastStatusId = conversation.get('last_status'); const lastStatusId = conversation.get('last_status');
@ -110,14 +110,6 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
dispatch(deleteConversation(id)); dispatch(deleteConversation(id));
}, [dispatch, id]); }, [dispatch, id]);
const handleHotkeyMoveUp = useCallback(() => {
onMoveUp(id);
}, [id, onMoveUp]);
const handleHotkeyMoveDown = useCallback(() => {
onMoveDown(id);
}, [id, onMoveDown]);
const handleConversationMute = useCallback(() => { const handleConversationMute = useCallback(() => {
if (lastStatus.get('muted')) { if (lastStatus.get('muted')) {
dispatch(unmuteStatus(lastStatus.get('id'))); dispatch(unmuteStatus(lastStatus.get('id')));
@ -161,8 +153,6 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
const handlers = { const handlers = {
reply: handleReply, reply: handleReply,
open: handleClick, open: handleClick,
moveUp: handleHotkeyMoveUp,
moveDown: handleHotkeyMoveDown,
toggleHidden: handleShowMore, toggleHidden: handleShowMore,
}; };
@ -224,6 +214,4 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
Conversation.propTypes = { Conversation.propTypes = {
conversation: ImmutablePropTypes.map.isRequired, conversation: ImmutablePropTypes.map.isRequired,
scrollKey: PropTypes.string, 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'; 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 }) => { export const ConversationsList = ({ scrollKey, ...other }) => {
const listRef = useRef(); const listRef = useRef();
const conversations = useSelector(state => state.getIn(['conversations', 'items'])); const conversations = useSelector(state => state.getIn(['conversations', 'items']));
@ -32,16 +18,6 @@ export const ConversationsList = ({ scrollKey, ...other }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const lastStatusId = conversations.last()?.get('last_status'); 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 => { const debouncedLoadMore = useMemo(() => debounce(id => {
dispatch(expandConversations({ maxId: id })); dispatch(expandConversations({ maxId: id }));
}, 300, { leading: true }), [dispatch]); }, 300, { leading: true }), [dispatch]);
@ -58,8 +34,6 @@ export const ConversationsList = ({ scrollKey, ...other }) => {
<Conversation <Conversation
key={item.get('id')} key={item.get('id')}
conversation={item} conversation={item}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
scrollKey={scrollKey} 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, UnicodeEmojiData,
LocaleOrCustom, LocaleOrCustom,
} from './types'; } from './types';
import { emojiLogger } from './utils';
interface EmojiDB extends LocaleTables, DBSchema { interface EmojiDB extends LocaleTables, DBSchema {
custom: { custom: {
@ -36,40 +37,63 @@ interface LocaleTable {
} }
type LocaleTables = Record<Locale, LocaleTable>; type LocaleTables = Record<Locale, LocaleTable>;
type Database = IDBPDatabase<EmojiDB>;
const SCHEMA_VERSION = 1; const SCHEMA_VERSION = 1;
let db: IDBPDatabase<EmojiDB> | null = null; const loadedLocales = new Set<Locale>();
async function loadDB() { const log = emojiLogger('database');
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');
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) { // Actually load the DB.
const localeTable = database.createObjectStore(locale, { async function initDB() {
keyPath: 'hexcode', const db = await openDB<EmojiDB>('mastodon-emoji', SCHEMA_VERSION, {
upgrade(database) {
const customTable = database.createObjectStore('custom', {
keyPath: 'shortcode',
autoIncrement: false, autoIncrement: false,
}); });
localeTable.createIndex('group', 'group'); customTable.createIndex('category', 'category');
localeTable.createIndex('label', 'label');
localeTable.createIndex('order', 'order'); database.createObjectStore('etags');
localeTable.createIndex('tags', 'tags', { multiEntry: true });
} for (const locale of SUPPORTED_LOCALES) {
}, const localeTable = database.createObjectStore(locale, {
}); keyPath: 'hexcode',
return db; 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) { export async function putEmojiData(emojis: UnicodeEmojiData[], locale: Locale) {
loadedLocales.add(locale);
const db = await loadDB(); const db = await loadDB();
const trx = db.transaction(locale, 'readwrite'); const trx = db.transaction(locale, 'readwrite');
await Promise.all(emojis.map((emoji) => trx.store.put(emoji))); 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) { export async function putLatestEtag(etag: string, localeString: string) {
const locale = toSupportedLocaleOrCustom(localeString); const locale = toSupportedLocaleOrCustom(localeString);
const db = await loadDB(); 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, hexcode: string,
localeString: string, localeString: string,
) { ) {
const locale = toSupportedLocale(localeString);
const db = await loadDB(); const db = await loadDB();
const locale = toLoadedLocale(localeString);
return db.get(locale, hexcode); return db.get(locale, hexcode);
} }
@ -102,45 +126,39 @@ export async function searchEmojisByHexcodes(
hexcodes: string[], hexcodes: string[],
localeString: string, localeString: string,
) { ) {
const locale = toSupportedLocale(localeString);
const db = await loadDB(); const db = await loadDB();
return db.getAll( const locale = toLoadedLocale(localeString);
const sortedCodes = hexcodes.toSorted();
const results = await db.getAll(
locale, 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) { export async function searchEmojisByTag(tag: string, localeString: string) {
const locale = toSupportedLocale(localeString);
const range = IDBKeyRange.only(tag.toLowerCase());
const db = await loadDB(); const db = await loadDB();
const locale = toLoadedLocale(localeString);
const range = IDBKeyRange.bound(
tag.toLowerCase(),
`${tag.toLowerCase()}\uffff`,
);
return db.getAllFromIndex(locale, 'tags', range); return db.getAllFromIndex(locale, 'tags', range);
} }
export async function searchCustomEmojiByShortcode(shortcode: string) { export async function loadCustomEmojiByShortcode(shortcode: string) {
const db = await loadDB(); const db = await loadDB();
return db.get('custom', shortcode); return db.get('custom', shortcode);
} }
export async function searchCustomEmojisByShortcodes(shortcodes: string[]) { export async function searchCustomEmojisByShortcodes(shortcodes: string[]) {
const db = await loadDB(); const db = await loadDB();
return db.getAll( const sortedCodes = shortcodes.toSorted();
const results = await db.getAll(
'custom', 'custom',
IDBKeyRange.bound(shortcodes[0], shortcodes[shortcodes.length - 1]), IDBKeyRange.bound(sortedCodes.at(0), sortedCodes.at(-1)),
); );
} return results.filter((emoji) => shortcodes.includes(emoji.shortcode));
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;
} }
export async function loadLatestEtag(localeString: string) { export async function loadLatestEtag(localeString: string) {
@ -153,3 +171,51 @@ export async function loadLatestEtag(localeString: string) {
const etag = await db.get('etags', locale); const etag = await db.get('etags', locale);
return etag ?? null; 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 type { ComponentPropsWithoutRef, ElementType } from 'react';
import { useEffect, useMemo, useState } 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 { isModernEmojiEnabled } from '@/mastodon/utils/environment';
import { useEmojiAppState } from './hooks'; import { useEmojify } from './hooks';
import { emojifyElement } from './render'; import type { CustomEmojiMapArg } from './types';
import type { ExtraCustomEmojiMap } from './types';
type EmojiHTMLProps = Omit< type EmojiHTMLProps<Element extends ElementType = 'div'> = Omit<
HTMLAttributes<HTMLDivElement>, ComponentPropsWithoutRef<Element>,
'dangerouslySetInnerHTML' 'dangerouslySetInnerHTML'
> & { > & {
htmlString: string; htmlString: string;
extraEmojis?: ExtraCustomEmojiMap | ImmutableList<CustomEmoji>; extraEmojis?: CustomEmojiMapArg;
as?: Element;
}; };
export const EmojiHTML: React.FC<EmojiHTMLProps> = ({ export const ModernEmojiHTML = <Element extends ElementType>({
htmlString,
extraEmojis, extraEmojis,
htmlString,
as: asElement, // Rename for syntax highlighting
...props ...props
}) => { }: EmojiHTMLProps<Element>) => {
if (isModernEmojiEnabled()) { const Wrapper = asElement ?? 'div';
return ( const emojifiedHtml = useEmojify(htmlString, extraEmojis);
<ModernEmojiHTML
htmlString={htmlString}
extraEmojis={extraEmojis}
{...props}
/>
);
}
return <div dangerouslySetInnerHTML={{ __html: htmlString }} {...props} />;
};
const ModernEmojiHTML: React.FC<EmojiHTMLProps> = ({ if (emojifiedHtml === null) {
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) {
return 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 { useAppSelector } from '@/mastodon/store';
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
import { toSupportedLocale } from './locale'; import { toSupportedLocale } from './locale';
import { determineEmojiMode } from './mode'; 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 { export function useEmojiAppState(): EmojiAppState {
const locale = useAppSelector((state) => const locale = useAppSelector((state) =>
@ -12,5 +68,10 @@ export function useEmojiAppState(): EmojiAppState {
determineEmojiMode(state.meta.get('emoji_style') as string), 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 { loadWorker } from '@/mastodon/utils/workers';
import { toSupportedLocale } from './locale'; import { toSupportedLocale } from './locale';
import { emojiLogger } from './utils';
const userLocale = toSupportedLocale(initialState?.meta.locale ?? 'en'); const userLocale = toSupportedLocale(initialState?.meta.locale ?? 'en');
let worker: Worker | null = null; let worker: Worker | null = null;
export async function initializeEmoji() { const log = emojiLogger('index');
export function initializeEmoji() {
log('initializing emojis');
if (!worker && 'Worker' in window) { if (!worker && 'Worker' in window) {
try { try {
worker = loadWorker(new URL('./worker', import.meta.url), { worker = loadWorker(new URL('./worker', import.meta.url), {
@ -21,9 +25,16 @@ export async function initializeEmoji() {
if (worker) { if (worker) {
// Assign worker to const to make TS happy inside the event listener. // Assign worker to const to make TS happy inside the event listener.
const thisWorker = worker; const thisWorker = worker;
const timeoutId = setTimeout(() => {
log('worker is not ready after timeout');
worker = null;
void fallbackLoad();
}, 500);
thisWorker.addEventListener('message', (event: MessageEvent<string>) => { thisWorker.addEventListener('message', (event: MessageEvent<string>) => {
const { data: message } = event; const { data: message } = event;
if (message === 'ready') { if (message === 'ready') {
log('worker ready, loading data');
clearTimeout(timeoutId);
thisWorker.postMessage('custom'); thisWorker.postMessage('custom');
void loadEmojiLocale(userLocale); void loadEmojiLocale(userLocale);
// Load English locale as well, because people are still used to // Load English locale as well, because people are still used to
@ -31,15 +42,22 @@ export async function initializeEmoji() {
if (userLocale !== 'en') { if (userLocale !== 'en') {
void loadEmojiLocale('en'); void loadEmojiLocale('en');
} }
} else {
log('got worker message: %s', message);
} }
}); });
} else { } else {
const { importCustomEmojiData } = await import('./loader'); void fallbackLoad();
await importCustomEmojiData(); }
await loadEmojiLocale(userLocale); }
if (userLocale !== 'en') {
await loadEmojiLocale('en'); 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 { CompactEmoji, FlatCompactEmoji } from 'emojibase';
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji'; import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
import { isDevelopment } from '@/mastodon/utils/environment';
import { import {
putEmojiData, putEmojiData,
@ -12,6 +11,9 @@ import {
} from './database'; } from './database';
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale'; import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale';
import type { LocaleOrCustom } from './types'; import type { LocaleOrCustom } from './types';
import { emojiLogger } from './utils';
const log = emojiLogger('loader');
export async function importEmojiData(localeString: string) { export async function importEmojiData(localeString: string) {
const locale = toSupportedLocale(localeString); const locale = toSupportedLocale(localeString);
@ -20,6 +22,7 @@ export async function importEmojiData(localeString: string) {
return; return;
} }
const flattenedEmojis: FlatCompactEmoji[] = flattenEmojiData(emojis); const flattenedEmojis: FlatCompactEmoji[] = flattenEmojiData(emojis);
log('loaded %d for %s locale', flattenedEmojis.length, locale);
await putEmojiData(flattenedEmojis, locale); await putEmojiData(flattenedEmojis, locale);
} }
@ -28,6 +31,7 @@ export async function importCustomEmojiData() {
if (!emojis) { if (!emojis) {
return; return;
} }
log('loaded %d custom emojis', emojis.length);
await putCustomEmojiData(emojis); await putCustomEmojiData(emojis);
} }
@ -41,7 +45,9 @@ async function fetchAndCheckEtag<ResultType extends object[]>(
if (locale === 'custom') { if (locale === 'custom') {
url.pathname = '/api/v1/custom_emojis'; url.pathname = '/api/v1/custom_emojis';
} else { } 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); const oldEtag = await loadLatestEtag(locale);

View File

@ -1,94 +1,184 @@
import { customEmojiFactory, unicodeEmojiFactory } from '@/testing/factories';
import { import {
EMOJI_MODE_NATIVE, EMOJI_MODE_NATIVE,
EMOJI_MODE_NATIVE_WITH_FLAGS, EMOJI_MODE_NATIVE_WITH_FLAGS,
EMOJI_MODE_TWEMOJI, EMOJI_MODE_TWEMOJI,
} from './constants'; } from './constants';
import { emojifyElement, tokenizeText } from './render'; import * as db from './database';
import type { CustomEmojiData, UnicodeEmojiData } from './types'; import {
emojifyElement,
emojifyText,
testCacheClear,
tokenizeText,
} from './render';
import type { EmojiAppState, ExtraCustomEmojiMap } from './types';
vitest.mock('./database', () => ({ function mockDatabase() {
searchCustomEmojisByShortcodes: vitest.fn( return {
() => searchCustomEmojisByShortcodes: vi
[ .spyOn(db, 'searchCustomEmojisByShortcodes')
{ .mockResolvedValue([customEmojiFactory()]),
shortcode: 'custom', searchEmojisByHexcodes: vi
static_url: 'emoji/static', .spyOn(db, 'searchEmojisByHexcodes')
url: 'emoji/custom', .mockResolvedValue([
category: 'test', unicodeEmojiFactory({
visible_in_picker: true,
},
] satisfies CustomEmojiData[],
),
searchEmojisByHexcodes: vitest.fn(
() =>
[
{
hexcode: '1F60A', hexcode: '1F60A',
group: 0,
label: 'smiling face with smiling eyes', label: 'smiling face with smiling eyes',
order: 0,
tags: ['smile', 'happy'],
unicode: '😊', unicode: '😊',
}, }),
{ unicodeEmojiFactory({
hexcode: '1F1EA-1F1FA', hexcode: '1F1EA-1F1FA',
group: 0,
label: 'flag-eu', label: 'flag-eu',
order: 0,
tags: ['flag', 'european union'],
unicode: '🇪🇺', 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', () => { describe('emojifyElement', () => {
const testElement = document.createElement('div'); function testElement(text = '<p>Hello 😊🇪🇺!</p><p>:custom:</p>') {
testElement.innerHTML = '<p>Hello 😊🇪🇺!</p><p>:custom:</p>'; const testElement = document.createElement('div');
testElement.innerHTML = text;
const expectedSmileImage = return testElement;
'<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;
} }
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 () => { test('emojifies custom emoji in native mode', async () => {
const emojifiedElement = await emojifyElement(cloneTestElement(), { const { searchEmojisByHexcodes } = mockDatabase();
locales: ['en'], const actual = await emojifyElement(
mode: EMOJI_MODE_NATIVE, testElement(),
currentLocale: 'en', testAppState({ mode: EMOJI_MODE_NATIVE }),
}); );
expect(emojifiedElement.innerHTML).toBe( assert(actual);
expect(actual.innerHTML).toBe(
`<p>Hello 😊🇪🇺!</p><p>${expectedCustomEmojiImage}</p>`, `<p>Hello 😊🇪🇺!</p><p>${expectedCustomEmojiImage}</p>`,
); );
expect(searchEmojisByHexcodes).not.toHaveBeenCalled();
}); });
test('emojifies flag emoji in native-with-flags mode', async () => { test('emojifies flag emoji in native-with-flags mode', async () => {
const emojifiedElement = await emojifyElement(cloneTestElement(), { const { searchEmojisByHexcodes } = mockDatabase();
locales: ['en'], const actual = await emojifyElement(
mode: EMOJI_MODE_NATIVE_WITH_FLAGS, testElement(),
currentLocale: 'en', testAppState({ mode: EMOJI_MODE_NATIVE_WITH_FLAGS }),
}); );
expect(emojifiedElement.innerHTML).toBe( assert(actual);
expect(actual.innerHTML).toBe(
`<p>Hello 😊${expectedFlagImage}!</p><p>${expectedCustomEmojiImage}</p>`, `<p>Hello 😊${expectedFlagImage}!</p><p>${expectedCustomEmojiImage}</p>`,
); );
expect(searchEmojisByHexcodes).toHaveBeenCalledOnce();
}); });
test('emojifies everything in twemoji mode', async () => { test('emojifies everything in twemoji mode', async () => {
const emojifiedElement = await emojifyElement(cloneTestElement(), { const { searchCustomEmojisByShortcodes, searchEmojisByHexcodes } =
locales: ['en'], mockDatabase();
mode: EMOJI_MODE_TWEMOJI, const actual = await emojifyElement(testElement(), testAppState());
currentLocale: 'en', assert(actual);
}); expect(actual.innerHTML).toBe(
expect(emojifiedElement.innerHTML).toBe(
`<p>Hello ${expectedSmileImage}${expectedFlagImage}!</p><p>${expectedCustomEmojiImage}</p>`, `<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 { autoPlayGif } from '@/mastodon/initial_state';
import { createLimitedCache } from '@/mastodon/utils/cache';
import { assetHost } from '@/mastodon/utils/config'; import { assetHost } from '@/mastodon/utils/config';
import * as perf from '@/mastodon/utils/performance';
import { import {
EMOJI_MODE_NATIVE, EMOJI_MODE_NATIVE,
@ -12,11 +11,9 @@ import {
EMOJI_STATE_MISSING, EMOJI_STATE_MISSING,
} from './constants'; } from './constants';
import { import {
findMissingLocales,
searchCustomEmojisByShortcodes, searchCustomEmojisByShortcodes,
searchEmojisByHexcodes, searchEmojisByHexcodes,
} from './database'; } from './database';
import { loadEmojiLocale } from './index';
import { import {
emojiToUnicodeHex, emojiToUnicodeHex,
twemojiHasBorder, twemojiHasBorder,
@ -34,18 +31,38 @@ import type {
LocaleOrCustom, LocaleOrCustom,
UnicodeEmojiToken, UnicodeEmojiToken,
} from './types'; } from './types';
import { stringHasUnicodeFlags } from './utils'; import {
anyEmojiRegex,
emojiLogger,
stringHasAnyEmoji,
stringHasUnicodeFlags,
} from './utils';
const localeCacheMap = new Map<LocaleOrCustom, EmojiStateMap>([ const log = emojiLogger('render');
[EMOJI_TYPE_CUSTOM, new Map()],
]);
// 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>( export async function emojifyElement<Element extends HTMLElement>(
element: Element, element: Element,
appState: EmojiAppState, appState: EmojiAppState,
extraEmojis: ExtraCustomEmojiMap = {}, 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]; const queue: (HTMLElement | Text)[] = [element];
while (queue.length > 0) { while (queue.length > 0) {
const current = queue.shift(); const current = queue.shift();
@ -61,7 +78,7 @@ export async function emojifyElement<Element extends HTMLElement>(
current.textContent && current.textContent &&
(current instanceof Text || !current.hasChildNodes()) (current instanceof Text || !current.hasChildNodes())
) { ) {
const renderedContent = await emojifyText( const renderedContent = await textToElementArray(
current.textContent, current.textContent,
appState, appState,
extraEmojis, extraEmojis,
@ -70,7 +87,7 @@ export async function emojifyElement<Element extends HTMLElement>(
if (!(current instanceof Text)) { if (!(current instanceof Text)) {
current.textContent = null; // Clear the text content if it's not a Text node. current.textContent = null; // Clear the text content if it's not a Text node.
} }
current.replaceWith(renderedToHTMLFragment(renderedContent)); current.replaceWith(renderedToHTML(renderedContent));
} }
continue; continue;
} }
@ -81,6 +98,8 @@ export async function emojifyElement<Element extends HTMLElement>(
} }
} }
} }
updateCache(cacheKey, element.innerHTML);
perf.stop('emojifyElement()');
return element; return element;
} }
@ -88,7 +107,54 @@ export async function emojifyText(
text: string, text: string,
appState: EmojiAppState, appState: EmojiAppState,
extraEmojis: ExtraCustomEmojiMap = {}, 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. // Exit if no text to convert.
if (!text.trim()) { if (!text.trim()) {
return null; return null;
@ -102,10 +168,9 @@ export async function emojifyText(
} }
// Get all emoji from the state map, loading any missing ones. // Get all emoji from the state map, loading any missing ones.
await ensureLocalesAreLoaded(appState.locales); await loadMissingEmojiIntoCache(tokens, appState, extraEmojis);
await loadMissingEmojiIntoCache(tokens, appState.locales);
const renderedFragments: (string | HTMLImageElement)[] = []; const renderedFragments: EmojifiedTextArray = [];
for (const token of tokens) { for (const token of tokens) {
if (typeof token !== 'string' && shouldRenderImage(token, appState.mode)) { if (typeof token !== 'string' && shouldRenderImage(token, appState.mode)) {
let state: EmojiState | undefined; 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 the state is valid, create an image element. Otherwise, just append as text.
if (state && typeof state !== 'string') { if (state && typeof state !== 'string') {
const image = stateToImage(state); const image = stateToImage(state, appState);
renderedFragments.push(image); renderedFragments.push(image);
continue; continue;
} }
@ -137,21 +202,6 @@ export async function emojifyText(
return renderedFragments; 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)[]; type TokenizedText = (string | EmojiToken)[];
export function tokenizeText(text: string): TokenizedText { export function tokenizeText(text: string): TokenizedText {
@ -161,7 +211,7 @@ export function tokenizeText(text: string): TokenizedText {
const tokens = []; const tokens = [];
let lastIndex = 0; let lastIndex = 0;
for (const match of text.matchAll(TOKENIZE_REGEX)) { for (const match of text.matchAll(anyEmojiRegex())) {
if (match.index > lastIndex) { if (match.index > lastIndex) {
tokens.push(text.slice(lastIndex, match.index)); tokens.push(text.slice(lastIndex, match.index));
} }
@ -189,8 +239,18 @@ export function tokenizeText(text: string): TokenizedText {
return tokens; return tokens;
} }
const localeCacheMap = new Map<LocaleOrCustom, EmojiStateMap>([
[
EMOJI_TYPE_CUSTOM,
createLimitedCache<EmojiState>({ log: log.extend('custom') }),
],
]);
function cacheForLocale(locale: LocaleOrCustom): EmojiStateMap { 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( function emojiForLocale(
@ -203,7 +263,8 @@ function emojiForLocale(
async function loadMissingEmojiIntoCache( async function loadMissingEmojiIntoCache(
tokens: TokenizedText, tokens: TokenizedText,
locales: Locale[], { mode, currentLocale }: EmojiAppState,
extraEmojis: ExtraCustomEmojiMap,
) { ) {
const missingUnicodeEmoji = new Set<string>(); const missingUnicodeEmoji = new Set<string>();
const missingCustomEmoji = 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 this is a custom emoji, check it separately.
if (token.type === EMOJI_TYPE_CUSTOM) { if (token.type === EMOJI_TYPE_CUSTOM) {
const code = token.code; const code = token.code;
if (code in extraEmojis) {
continue; // We don't care about extra emoji.
}
const emojiState = emojiForLocale(code, EMOJI_TYPE_CUSTOM); const emojiState = emojiForLocale(code, EMOJI_TYPE_CUSTOM);
if (!emojiState) { if (!emojiState) {
missingCustomEmoji.add(code); missingCustomEmoji.add(code);
} }
// Otherwise this is a unicode emoji, so check it against all locales. // Otherwise this is a unicode emoji, so check it against all locales.
} else { } else if (shouldRenderImage(token, mode)) {
const code = emojiToUnicodeHex(token.code); const code = emojiToUnicodeHex(token.code);
if (missingUnicodeEmoji.has(code)) { if (missingUnicodeEmoji.has(code)) {
continue; // Already marked as missing. continue; // Already marked as missing.
} }
for (const locale of locales) { const emojiState = emojiForLocale(code, currentLocale);
const emojiState = emojiForLocale(code, locale); if (!emojiState) {
if (!emojiState) { // If it's missing in one locale, we consider it missing for all.
// If it's missing in one locale, we consider it missing for all. missingUnicodeEmoji.add(code);
missingUnicodeEmoji.add(code);
}
} }
} }
} }
if (missingUnicodeEmoji.size > 0) { if (missingUnicodeEmoji.size > 0) {
const missingEmojis = Array.from(missingUnicodeEmoji).toSorted(); const missingEmojis = Array.from(missingUnicodeEmoji).toSorted();
for (const locale of locales) { const emojis = await searchEmojisByHexcodes(missingEmojis, currentLocale);
const emojis = await searchEmojisByHexcodes(missingEmojis, locale); const cache = cacheForLocale(currentLocale);
const cache = cacheForLocale(locale); for (const emoji of emojis) {
for (const emoji of emojis) { cache.set(emoji.hexcode, { type: EMOJI_TYPE_UNICODE, data: emoji });
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 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) { if (missingCustomEmoji.size > 0) {
@ -288,22 +348,24 @@ function shouldRenderImage(token: EmojiToken, mode: EmojiMode): boolean {
return true; return true;
} }
function stateToImage(state: EmojiLoadedState) { function stateToImage(state: EmojiLoadedState, appState: EmojiAppState) {
const image = document.createElement('img'); const image = document.createElement('img');
image.draggable = false; image.draggable = false;
image.classList.add('emojione'); image.classList.add('emojione');
if (state.type === EMOJI_TYPE_UNICODE) { if (state.type === EMOJI_TYPE_UNICODE) {
const emojiInfo = twemojiHasBorder(unicodeToTwemojiHex(state.data.hexcode)); const emojiInfo = twemojiHasBorder(unicodeToTwemojiHex(state.data.hexcode));
if (emojiInfo.hasLightBorder) { let fileName = emojiInfo.hexCode;
image.dataset.lightCode = `${emojiInfo.hexCode}_BORDER`; if (
} else if (emojiInfo.hasDarkBorder) { (appState.darkTheme && emojiInfo.hasDarkBorder) ||
image.dataset.darkCode = `${emojiInfo.hexCode}_BORDER`; (!appState.darkTheme && emojiInfo.hasLightBorder)
) {
fileName = `${emojiInfo.hexCode}_border`;
} }
image.alt = state.data.unicode; image.alt = state.data.unicode;
image.title = state.data.label; image.title = state.data.label;
image.src = `${assetHost}/emoji/${emojiInfo.hexCode}.svg`; image.src = `${assetHost}/emoji/${fileName}.svg`;
} else { } else {
// Custom emoji // Custom emoji
const shortCode = `:${state.data.shortcode}:`; const shortCode = `:${state.data.shortcode}:`;
@ -318,8 +380,16 @@ function stateToImage(state: EmojiLoadedState) {
return image; return image;
} }
function renderedToHTMLFragment(renderedArray: (string | HTMLImageElement)[]) { function renderedToHTML(renderedArray: EmojifiedTextArray): DocumentFragment;
const fragment = document.createDocumentFragment(); 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) { for (const fragmentItem of renderedArray) {
if (typeof fragmentItem === 'string') { if (typeof fragmentItem === 'string') {
fragment.appendChild(document.createTextNode(fragmentItem)); fragment.appendChild(document.createTextNode(fragmentItem));
@ -329,3 +399,9 @@ function renderedToHTMLFragment(renderedArray: (string | HTMLImageElement)[]) {
} }
return fragment; 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 { FlatCompactEmoji, Locale } from 'emojibase';
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji'; 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 { import type {
EMOJI_MODE_NATIVE, EMOJI_MODE_NATIVE,
@ -22,6 +26,7 @@ export interface EmojiAppState {
locales: Locale[]; locales: Locale[];
currentLocale: Locale; currentLocale: Locale;
mode: EmojiMode; mode: EmojiMode;
darkTheme: boolean;
} }
export interface UnicodeEmojiToken { export interface UnicodeEmojiToken {
@ -45,7 +50,7 @@ export interface EmojiStateUnicode {
} }
export interface EmojiStateCustom { export interface EmojiStateCustom {
type: typeof EMOJI_TYPE_CUSTOM; type: typeof EMOJI_TYPE_CUSTOM;
data: CustomEmojiData; data: CustomEmojiRenderFields;
} }
export type EmojiState = export type EmojiState =
| EmojiStateMissing | EmojiStateMissing
@ -53,9 +58,16 @@ export type EmojiState =
| EmojiStateCustom; | EmojiStateCustom;
export type EmojiLoadedState = EmojiStateUnicode | 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 { export interface TwemojiBorderInfo {
hexCode: string; 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([ test.concurrent.for([
['only text', false], ['only text', false],
['text with non-emoji symbols ™©', false],
['text with emoji 😀', true], ['text with emoji 😀', true],
['multiple emojis 😀😃😄', true], ['multiple emojis 😀😃😄', true],
['emoji with skin tone 👍🏽', true], ['emoji with skin tone 👍🏽', true],
@ -19,14 +25,14 @@ describe('stringHasEmoji', () => {
['emoji with enclosing keycap #️⃣', true], ['emoji with enclosing keycap #️⃣', true],
['emoji with no visible glyph \u200D', false], ['emoji with no visible glyph \u200D', false],
] as const)( ] as const)(
'stringHasEmoji has emojis in "%s": %o', 'stringHasUnicodeEmoji has emojis in "%s": %o',
([text, expected], { expect }) => { ([text, expected], { expect }) => {
expect(stringHasUnicodeEmoji(text)).toBe(expected); expect(stringHasUnicodeEmoji(text)).toBe(expected);
}, },
); );
}); });
describe('stringHasFlags', () => { describe('stringHasUnicodeFlags', () => {
test.concurrent.for([ test.concurrent.for([
['EU 🇪🇺', true], ['EU 🇪🇺', true],
['Germany 🇩🇪', 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 { import { emojiRegexPolyfill } from '@/mastodon/polyfills';
return EMOJI_REGEX.test(text);
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 export function stringHasUnicodeEmoji(input: string): boolean {
const EMOJIS_FLAGS_REGEX = return new RegExp(EMOJI_REGEX, supportedFlags()).test(input);
/[\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 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>) { function handleMessage(event: MessageEvent<string>) {
const { data: locale } = event; const { data: locale } = event;
if (locale !== 'custom') { void loadData(locale);
void importEmojiData(locale); }
} else {
void importCustomEmojiData(); 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'; 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 { FollowButton } from 'mastodon/components/follow_button';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import { IconButton } from 'mastodon/components/icon_button'; import { IconButton } from 'mastodon/components/icon_button';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { VerifiedBadge } from 'mastodon/components/verified_badge'; import { VerifiedBadge } from 'mastodon/components/verified_badge';
import { domain } from 'mastodon/initial_state'; import { domain } from 'mastodon/initial_state';
import { useAppDispatch, useAppSelector } from 'mastodon/store'; import { useAppDispatch, useAppSelector } from 'mastodon/store';
@ -56,9 +57,7 @@ const messages = defineMessages({
}, },
}); });
const Source: React.FC<{ const Source: React.FC<{ id: ApiSuggestionSourceJSON }> = ({ id }) => {
id: ApiSuggestionSourceJSON;
}> = ({ id }) => {
const intl = useIntl(); const intl = useIntl();
let label, hint; let label, hint;
@ -168,10 +167,11 @@ const Card: React.FC<{
const DISMISSIBLE_ID = 'home/follow-suggestions'; const DISMISSIBLE_ID = 'home/follow-suggestions';
export const InlineFollowSuggestions: React.FC<{ export const InlineFollowSuggestions: React.FC<{ hidden?: boolean }> = ({
hidden?: boolean; hidden,
}> = ({ hidden }) => { }) => {
const intl = useIntl(); const intl = useIntl();
const uniqueId = useId();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const suggestions = useAppSelector((state) => state.suggestions.items); const suggestions = useAppSelector((state) => state.suggestions.items);
const isLoading = useAppSelector((state) => state.suggestions.isLoading); const isLoading = useAppSelector((state) => state.suggestions.isLoading);
@ -257,9 +257,14 @@ export const InlineFollowSuggestions: React.FC<{
} }
return ( 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'> <div className='inline-follow-suggestions__header'>
<h3> <h3 id={uniqueId}>
<FormattedMessage <FormattedMessage
id='follow_suggestions.who_to_follow' id='follow_suggestions.who_to_follow'
defaultMessage='Who to follow' defaultMessage='Who to follow'
@ -288,13 +293,17 @@ export const InlineFollowSuggestions: React.FC<{
ref={bodyRef} ref={bodyRef}
onScroll={handleScroll} onScroll={handleScroll}
> >
{suggestions.map((suggestion) => ( {isLoading ? (
<Card <LoadingIndicator />
key={suggestion.account_id} ) : (
id={suggestion.account_id} suggestions.map((suggestion) => (
sources={suggestion.sources} <Card
/> key={suggestion.account_id}
))} id={suggestion.account_id}
sources={suggestion.sources}
/>
))
)}
</div> </div>
{canScrollLeft && ( {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 InfoIcon from '@/material-icons/400-24px/info.svg?react';
import Column from 'mastodon/components/column'; import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header'; import ColumnHeader from 'mastodon/components/column_header';
import { isFeatureEnabled } from 'mastodon/utils/environment';
const messages = defineMessages({ const messages = defineMessages({
heading: { id: 'keyboard_shortcuts.heading', defaultMessage: 'Keyboard Shortcuts' }, heading: { id: 'keyboard_shortcuts.heading', defaultMessage: 'Keyboard Shortcuts' },
@ -62,6 +63,12 @@ class KeyboardShortcuts extends ImmutablePureComponent {
<td><kbd>b</kbd></td> <td><kbd>b</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.boost' defaultMessage='to boost' /></td> <td><FormattedMessage id='keyboard_shortcuts.boost' defaultMessage='to boost' /></td>
</tr> </tr>
{isFeatureEnabled('outgoing_quotes') && (
<tr>
<td><kbd>q</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.quote' defaultMessage='Quote post' /></td>
</tr>
)}
<tr> <tr>
<td><kbd>enter</kbd>, <kbd>o</kbd></td> <td><kbd>enter</kbd>, <kbd>o</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.enter' defaultMessage='to open status' /></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> <td><FormattedMessage id='keyboard_shortcuts.toggle_sensitivity' defaultMessage='to show/hide media' /></td>
</tr> </tr>
<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> <td><FormattedMessage id='keyboard_shortcuts.up' defaultMessage='to move up in the list' /></td>
</tr> </tr>
<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> <td><FormattedMessage id='keyboard_shortcuts.down' defaultMessage='to move down in the list' /></td>
</tr> </tr>
<tr>
<td><kbd>l</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.load_more' defaultMessage='Focus "Load more" button' /></td>
</tr>
<tr> <tr>
<td><kbd>1</kbd>-<kbd>9</kbd></td> <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> <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> </div>
</section> </section>
<section role='group' aria-labelledby='notifications-quote'>
<h3 id='notifications-quote'><FormattedMessage id='notifications.column_settings.quote' defaultMessage='Quotes:' /></h3>
<div className='column-settings__row'>
<SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'quote']} onChange={onChange} label={alertStr} />
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'quote']} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'quote']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'quote']} onChange={onChange} label={soundStr} />
</div>
</section>
<section role='group' aria-labelledby='notifications-poll'> <section role='group' aria-labelledby='notifications-poll'>
<h3 id='notifications-poll'><FormattedMessage id='notifications.column_settings.poll' defaultMessage='Poll results:' /></h3> <h3 id='notifications-poll'><FormattedMessage id='notifications.column_settings.poll' defaultMessage='Poll results:' /></h3>

View File

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

View File

@ -31,21 +31,6 @@ const messages = defineMessages({
dismiss: { id: 'notification_requests.dismiss', defaultMessage: 'Dismiss' }, 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 } }) => { export const NotificationRequest = ({ multiColumn, params: { id } }) => {
const columnRef = useRef(); const columnRef = useRef();
const intl = useIntl(); const intl = useIntl();
@ -74,16 +59,6 @@ export const NotificationRequest = ({ multiColumn, params: { id } }) => {
dispatch(acceptNotificationRequest({ id })); dispatch(acceptNotificationRequest({ id }));
}, [dispatch, 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(() => { useEffect(() => {
dispatch(fetchNotificationRequest({ id })); dispatch(fetchNotificationRequest({ id }));
}, [dispatch, id]); }, [dispatch, id]);
@ -146,8 +121,6 @@ export const NotificationRequest = ({ multiColumn, params: { id } }) => {
key={item.get('id')} key={item.get('id')}
notification={item} notification={item}
accountId={item.get('account')} accountId={item.get('account')}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
/> />
))} ))}
</ScrollableList> </ScrollableList>

View File

@ -15,6 +15,8 @@ import { NotificationFollowRequest } from './notification_follow_request';
import { NotificationMention } from './notification_mention'; import { NotificationMention } from './notification_mention';
import { NotificationModerationWarning } from './notification_moderation_warning'; import { NotificationModerationWarning } from './notification_moderation_warning';
import { NotificationPoll } from './notification_poll'; import { NotificationPoll } from './notification_poll';
import { NotificationQuote } from './notification_quote';
import { NotificationQuotedUpdate } from './notification_quoted_update';
import { NotificationReblog } from './notification_reblog'; import { NotificationReblog } from './notification_reblog';
import { NotificationSeveredRelationships } from './notification_severed_relationships'; import { NotificationSeveredRelationships } from './notification_severed_relationships';
import { NotificationStatus } from './notification_status'; import { NotificationStatus } from './notification_status';
@ -23,9 +25,7 @@ import { NotificationUpdate } from './notification_update';
export const NotificationGroup: React.FC<{ export const NotificationGroup: React.FC<{
notificationGroupId: NotificationGroupModel['group_key']; notificationGroupId: NotificationGroupModel['group_key'];
unread: boolean; unread: boolean;
onMoveUp: (groupId: string) => void; }> = ({ notificationGroupId, unread }) => {
onMoveDown: (groupId: string) => void;
}> = ({ notificationGroupId, unread, onMoveUp, onMoveDown }) => {
const notificationGroup = useAppSelector((state) => const notificationGroup = useAppSelector((state) =>
state.notificationGroups.groups.find( state.notificationGroups.groups.find(
(item) => item.type !== 'gap' && item.group_key === notificationGroupId, (item) => item.type !== 'gap' && item.group_key === notificationGroupId,
@ -41,14 +41,6 @@ export const NotificationGroup: React.FC<{
const handlers = useMemo( const handlers = useMemo(
() => ({ () => ({
moveUp: () => {
onMoveUp(notificationGroupId);
},
moveDown: () => {
onMoveDown(notificationGroupId);
},
openProfile: () => { openProfile: () => {
if (accountId) dispatch(navigateToProfile(accountId)); if (accountId) dispatch(navigateToProfile(accountId));
}, },
@ -57,7 +49,7 @@ export const NotificationGroup: React.FC<{
if (accountId) dispatch(mentionComposeById(accountId)); if (accountId) dispatch(mentionComposeById(accountId));
}, },
}), }),
[dispatch, notificationGroupId, accountId, onMoveUp, onMoveDown], [dispatch, accountId],
); );
if (!notificationGroup || notificationGroup.type === 'gap') return null; if (!notificationGroup || notificationGroup.type === 'gap') return null;
@ -91,6 +83,11 @@ export const NotificationGroup: React.FC<{
<NotificationMention unread={unread} notification={notificationGroup} /> <NotificationMention unread={unread} notification={notificationGroup} />
); );
break; break;
case 'quote':
content = (
<NotificationQuote unread={unread} notification={notificationGroup} />
);
break;
case 'follow': case 'follow':
content = ( content = (
<NotificationFollow unread={unread} notification={notificationGroup} /> <NotificationFollow unread={unread} notification={notificationGroup} />
@ -119,6 +116,14 @@ export const NotificationGroup: React.FC<{
<NotificationUpdate unread={unread} notification={notificationGroup} /> <NotificationUpdate unread={unread} notification={notificationGroup} />
); );
break; break;
case 'quoted_update':
content = (
<NotificationQuotedUpdate
unread={unread}
notification={notificationGroup}
/>
);
break;
case 'admin.sign_up': case 'admin.sign_up':
content = ( content = (
<NotificationAdminSignUp <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 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 // Keep track of mounted components for unread notification handling
useEffect(() => { useEffect(() => {
void dispatch(mountNotifications()); void dispatch(mountNotifications());
@ -187,28 +164,6 @@ export const Notifications: React.FC<{
columnRef.current?.scrollTop(); 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(() => { const handleMarkAsRead = useCallback(() => {
dispatch(markNotificationsAsRead()); dispatch(markNotificationsAsRead());
void dispatch(submitMarkers({ immediate: true })); void dispatch(submitMarkers({ immediate: true }));
@ -241,8 +196,6 @@ export const Notifications: React.FC<{
<NotificationGroup <NotificationGroup
key={item.group_key} key={item.group_key}
notificationGroupId={item.group_key} notificationGroupId={item.group_key}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
unread={ unread={
lastReadId !== '0' && lastReadId !== '0' &&
!!item.page_max_id && !!item.page_max_id &&
@ -251,15 +204,7 @@ export const Notifications: React.FC<{
/> />
), ),
); );
}, [ }, [notifications, isLoading, hasMore, lastReadId, handleLoadGap]);
notifications,
isLoading,
hasMore,
lastReadId,
handleLoadGap,
handleMoveUp,
handleMoveDown,
]);
const prepend = ( const prepend = (
<> <>

View File

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