mirror of
https://github.com/mastodon/mastodon.git
synced 2025-09-05 17:31:12 +00:00
Merge branch 'main' into fix/rendering-of-polls-in-history-modal
This commit is contained in:
commit
7f5ea3c8f8
|
@ -5,6 +5,7 @@
|
|||
.gitattributes
|
||||
.gitignore
|
||||
.github
|
||||
.vscode
|
||||
public/system
|
||||
public/assets
|
||||
public/packs
|
||||
|
@ -20,6 +21,7 @@ postgres14
|
|||
redis
|
||||
elasticsearch
|
||||
chart
|
||||
storybook-static
|
||||
.yarn/
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
|
|
1
.github/workflows/build-security.yml
vendored
1
.github/workflows/build-security.yml
vendored
|
@ -9,7 +9,6 @@ permissions:
|
|||
jobs:
|
||||
compute-suffix:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'mastodon/mastodon'
|
||||
steps:
|
||||
- id: version_vars
|
||||
env:
|
||||
|
|
|
@ -50,7 +50,7 @@ jobs:
|
|||
|
||||
# Create or update the pull request
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v7.0.6
|
||||
uses: peter-evans/create-pull-request@v7.0.8
|
||||
with:
|
||||
commit-message: 'New Crowdin translations'
|
||||
title: 'New Crowdin Translations for ${{ github.base_ref || github.ref_name }} (automated)'
|
||||
|
|
|
@ -1,17 +1,21 @@
|
|||
---
|
||||
Metrics/AbcSize:
|
||||
Exclude:
|
||||
- lib/mastodon/cli/*.rb
|
||||
Enabled: false
|
||||
|
||||
Metrics/BlockLength:
|
||||
Enabled: false
|
||||
|
||||
Metrics/BlockNesting:
|
||||
Enabled: false
|
||||
|
||||
Metrics/ClassLength:
|
||||
Enabled: false
|
||||
|
||||
Metrics/CollectionLiteralLength:
|
||||
Enabled: false
|
||||
|
||||
Metrics/CyclomaticComplexity:
|
||||
Exclude:
|
||||
- lib/mastodon/cli/*.rb
|
||||
Enabled: false
|
||||
|
||||
Metrics/MethodLength:
|
||||
Enabled: false
|
||||
|
@ -20,4 +24,7 @@ Metrics/ModuleLength:
|
|||
Enabled: false
|
||||
|
||||
Metrics/ParameterLists:
|
||||
CountKeywordArgs: false
|
||||
Enabled: false
|
||||
|
||||
Metrics/PerceivedComplexity:
|
||||
Enabled: false
|
||||
|
|
|
@ -1,28 +1,11 @@
|
|||
# This configuration was generated by
|
||||
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp`
|
||||
# using RuboCop version 1.79.0.
|
||||
# using RuboCop version 1.79.2.
|
||||
# The point is for the user to remove these configuration records
|
||||
# one by one as the offenses are removed from the code base.
|
||||
# Note that changes in the inspected code, or installation of new
|
||||
# versions of RuboCop, may require this file to be generated again.
|
||||
|
||||
# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes.
|
||||
Metrics/AbcSize:
|
||||
Max: 82
|
||||
|
||||
# Configuration parameters: CountBlocks, CountModifierForms, Max.
|
||||
Metrics/BlockNesting:
|
||||
Exclude:
|
||||
- 'lib/tasks/mastodon.rake'
|
||||
|
||||
# Configuration parameters: AllowedMethods, AllowedPatterns.
|
||||
Metrics/CyclomaticComplexity:
|
||||
Max: 25
|
||||
|
||||
# Configuration parameters: AllowedMethods, AllowedPatterns.
|
||||
Metrics/PerceivedComplexity:
|
||||
Max: 27
|
||||
|
||||
# This cop supports safe autocorrection (--autocorrect).
|
||||
# Configuration parameters: AllowedVars, DefaultToNil.
|
||||
Style/FetchEnvVar:
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
* - Please do NOT modify this file.
|
||||
*/
|
||||
|
||||
const PACKAGE_VERSION = '2.10.2'
|
||||
const PACKAGE_VERSION = '2.10.4'
|
||||
const INTEGRITY_CHECKSUM = 'f5825c521429caf22a4dd13b66e243af'
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||
const activeClientIds = new Set()
|
||||
|
|
24
CHANGELOG.md
24
CHANGELOG.md
|
@ -2,6 +2,28 @@
|
|||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [4.4.3] - 2025-08-05
|
||||
|
||||
### Security
|
||||
|
||||
- Update dependencies
|
||||
- Fix incorrect rate-limit handling [GHSA-84ch-6436-c7mg](https://github.com/mastodon/mastodon/security/advisories/GHSA-84ch-6436-c7mg)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix race condition caused by ActiveRecord query cache in `Create` critical path (#35662 by @ClearlyClaire)
|
||||
- Fix race condition caused by quote post processing (#35657 by @ClearlyClaire)
|
||||
- Fix WebUI crashing for accounts with `null` URL (#35651 by @ClearlyClaire)
|
||||
- Fix friends-of-friends recommendations suggesting already-requested accounts (#35604 by @ClearlyClaire)
|
||||
- Fix synchronous recursive fetching of deeply-nested quoted posts (#35600 by @ClearlyClaire)
|
||||
- Fix “Expand this post” link including user `@undefined` (#35478 by @ClearlyClaire)
|
||||
|
||||
### Changed
|
||||
|
||||
- Change `StatusReachFinder` to consider quotes as well as reblogs (#35601 by @ClearlyClaire)
|
||||
- Add restrictions on which quote posts can trend (#35507 by @ClearlyClaire)
|
||||
- Change quote verification to not bypass authorization flow for mentions (#35528 by @ClearlyClaire)
|
||||
|
||||
## [4.4.2] - 2025-07-23
|
||||
|
||||
### Security
|
||||
|
@ -561,7 +583,6 @@ The following changelog entries focus on changes visible to users, administrator
|
|||
You can now separately filter or drop notifications from people you don't follow, people who don't follow you, accounts created within the past 30 days, as well as unsolicited private mentions, and accounts limited by the moderation.\
|
||||
Instead of being outright dropped, notifications that you chose to filter are put in a separate “Filtered notifications” box that you can review separately without it clogging your main notifications.\
|
||||
This adds the following REST API endpoints:
|
||||
|
||||
- `GET /api/v2/notifications/policy`: https://docs.joinmastodon.org/methods/notifications/#get-policy
|
||||
- `PATCH /api/v2/notifications/policy`: https://docs.joinmastodon.org/methods/notifications/#update-the-filtering-policy-for-notifications
|
||||
- `GET /api/v1/notifications/requests`: https://docs.joinmastodon.org/methods/notifications/#get-requests
|
||||
|
@ -573,7 +594,6 @@ The following changelog entries focus on changes visible to users, administrator
|
|||
- `GET /api/v1/notifications/requests/merged`: https://docs.joinmastodon.org/methods/notifications/#requests-merged
|
||||
|
||||
In addition, accepting one or more notification requests generates a new streaming event:
|
||||
|
||||
- `notifications_merged`: an event of this type indicates accepted notification requests have finished merging, and the notifications list should be refreshed
|
||||
|
||||
- **Add notifications of severed relationships** (#27511, #29665, #29668, #29670, #29700, #29714, #29712, and #29731 by @ClearlyClaire and @Gargron)\
|
||||
|
|
35
Dockerfile
35
Dockerfile
|
@ -17,11 +17,11 @@ ARG RUBY_VERSION="3.4.5"
|
|||
# # Node.js version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"]
|
||||
# renovate: datasource=node-version depName=node
|
||||
ARG NODE_MAJOR_VERSION="22"
|
||||
# Debian image to use for base image, change with [--build-arg DEBIAN_VERSION="bookworm"]
|
||||
ARG DEBIAN_VERSION="bookworm"
|
||||
# Node.js image to use for base image based on combined variables (ex: 20-bookworm-slim)
|
||||
# Debian image to use for base image, change with [--build-arg DEBIAN_VERSION="trixie"]
|
||||
ARG DEBIAN_VERSION="trixie"
|
||||
# Node.js image to use for base image based on combined variables (ex: 20-trixie-slim)
|
||||
FROM ${BASE_REGISTRY}/node:${NODE_MAJOR_VERSION}-${DEBIAN_VERSION}-slim AS node
|
||||
# Ruby image to use for base image based on combined variables (ex: 3.4.x-slim-bookworm)
|
||||
# Ruby image to use for base image based on combined variables (ex: 3.4.x-slim-trixie)
|
||||
FROM ${BASE_REGISTRY}/ruby:${RUBY_VERSION}-slim-${DEBIAN_VERSION} AS ruby
|
||||
|
||||
# Resulting version string is vX.X.X-MASTODON_VERSION_PRERELEASE+MASTODON_VERSION_METADATA
|
||||
|
@ -96,9 +96,6 @@ RUN \
|
|||
# Set /opt/mastodon as working directory
|
||||
WORKDIR /opt/mastodon
|
||||
|
||||
# Add backport repository for some specific packages where we need the latest version
|
||||
RUN echo 'deb http://deb.debian.org/debian bookworm-backports main' >> /etc/apt/sources.list
|
||||
|
||||
# hadolint ignore=DL3008,DL3005
|
||||
RUN \
|
||||
# Mount Apt cache and lib directories from Docker buildx caches
|
||||
|
@ -161,11 +158,11 @@ RUN \
|
|||
libexif-dev \
|
||||
libexpat1-dev \
|
||||
libgirepository1.0-dev \
|
||||
libheif-dev/bookworm-backports \
|
||||
libheif-dev \
|
||||
libhwy-dev \
|
||||
libimagequant-dev \
|
||||
libjpeg62-turbo-dev \
|
||||
liblcms2-dev \
|
||||
liborc-dev \
|
||||
libspng-dev \
|
||||
libtiff-dev \
|
||||
libwebp-dev \
|
||||
|
@ -209,7 +206,7 @@ FROM build AS ffmpeg
|
|||
|
||||
# ffmpeg version to compile, change with [--build-arg FFMPEG_VERSION="7.0.x"]
|
||||
# renovate: datasource=repology depName=ffmpeg packageName=openpkg_current/ffmpeg
|
||||
ARG FFMPEG_VERSION=7.1
|
||||
ARG FFMPEG_VERSION=7.1.1
|
||||
# ffmpeg download URL, change with [--build-arg FFMPEG_URL="https://ffmpeg.org/releases"]
|
||||
ARG FFMPEG_URL=https://ffmpeg.org/releases
|
||||
|
||||
|
@ -327,28 +324,28 @@ RUN \
|
|||
# Apt update install non-dev versions of necessary components
|
||||
apt-get install -y --no-install-recommends \
|
||||
libexpat1 \
|
||||
libglib2.0-0 \
|
||||
libicu72 \
|
||||
libglib2.0-0t64 \
|
||||
libicu76 \
|
||||
libidn12 \
|
||||
libpq5 \
|
||||
libreadline8 \
|
||||
libssl3 \
|
||||
libreadline8t64 \
|
||||
libssl3t64 \
|
||||
libyaml-0-2 \
|
||||
# libvips components
|
||||
libcgif0 \
|
||||
libexif12 \
|
||||
libheif1/bookworm-backports \
|
||||
libheif1 \
|
||||
libhwy1t64 \
|
||||
libimagequant0 \
|
||||
libjpeg62-turbo \
|
||||
liblcms2-2 \
|
||||
liborc-0.4-0 \
|
||||
libspng0 \
|
||||
libtiff6 \
|
||||
libwebp7 \
|
||||
libwebpdemux2 \
|
||||
libwebpmux3 \
|
||||
# ffmpeg components
|
||||
libdav1d6 \
|
||||
libdav1d7 \
|
||||
libmp3lame0 \
|
||||
libopencore-amrnb0 \
|
||||
libopencore-amrwb0 \
|
||||
|
@ -358,9 +355,9 @@ RUN \
|
|||
libvorbis0a \
|
||||
libvorbisenc2 \
|
||||
libvorbisfile3 \
|
||||
libvpx7 \
|
||||
libvpx9 \
|
||||
libx264-164 \
|
||||
libx265-199 \
|
||||
libx265-215 \
|
||||
;
|
||||
|
||||
# Copy Mastodon sources into final layer
|
||||
|
|
18
Gemfile
18
Gemfile
|
@ -82,13 +82,13 @@ gem 'rqrcode', '~> 3.0'
|
|||
gem 'ruby-progressbar', '~> 1.13'
|
||||
gem 'sanitize', '~> 7.0'
|
||||
gem 'scenic', '~> 1.7'
|
||||
gem 'sidekiq', '< 8'
|
||||
gem 'sidekiq', '< 9'
|
||||
gem 'sidekiq-bulk', '~> 0.2.0'
|
||||
gem 'sidekiq-scheduler', '~> 5.0'
|
||||
gem 'sidekiq-scheduler', '~> 6.0'
|
||||
gem 'sidekiq-unique-jobs', '> 8'
|
||||
gem 'simple_form', '~> 5.2'
|
||||
gem 'simple-navigation', '~> 4.4'
|
||||
gem 'stoplight', '~> 4.1'
|
||||
gem 'stoplight', github: 'ClearlyClaire/stoplight', ref: 'f13e0c0d5e6d34af8d3cfc888871caa84237db42'
|
||||
gem 'strong_migrations'
|
||||
gem 'tty-prompt', '~> 0.23', require: false
|
||||
gem 'twitter-text', '~> 3.1.0'
|
||||
|
@ -102,17 +102,17 @@ gem 'rdf-normalize', '~> 0.5'
|
|||
|
||||
gem 'prometheus_exporter', '~> 2.2', require: false
|
||||
|
||||
gem 'opentelemetry-api', '~> 1.5.0'
|
||||
gem 'opentelemetry-api', '~> 1.6.0'
|
||||
|
||||
group :opentelemetry do
|
||||
gem 'opentelemetry-exporter-otlp', '~> 0.30.0', require: false
|
||||
gem 'opentelemetry-instrumentation-active_job', '~> 0.8.0', require: false
|
||||
gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.22.0', require: false
|
||||
gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.22.0', require: false
|
||||
gem 'opentelemetry-instrumentation-excon', '~> 0.23.0', require: false
|
||||
gem 'opentelemetry-instrumentation-faraday', '~> 0.27.0', require: false
|
||||
gem 'opentelemetry-instrumentation-excon', '~> 0.24.0', require: false
|
||||
gem 'opentelemetry-instrumentation-faraday', '~> 0.28.0', require: false
|
||||
gem 'opentelemetry-instrumentation-http', '~> 0.25.0', require: false
|
||||
gem 'opentelemetry-instrumentation-http_client', '~> 0.23.0', require: false
|
||||
gem 'opentelemetry-instrumentation-http_client', '~> 0.24.0', require: false
|
||||
gem 'opentelemetry-instrumentation-net_http', '~> 0.23.0', require: false
|
||||
gem 'opentelemetry-instrumentation-pg', '~> 0.30.0', require: false
|
||||
gem 'opentelemetry-instrumentation-rack', '~> 0.26.0', require: false
|
||||
|
@ -146,7 +146,7 @@ group :test do
|
|||
gem 'climate_control'
|
||||
|
||||
# Validate schemas in specs
|
||||
gem 'json-schema', '~> 5.0'
|
||||
gem 'json-schema', '~> 6.0'
|
||||
|
||||
# Test harness fo rack components
|
||||
gem 'rack-test', '~> 2.1'
|
||||
|
@ -223,7 +223,7 @@ gem 'connection_pool', require: false
|
|||
gem 'xorcist', '~> 1.1'
|
||||
|
||||
gem 'net-http', '~> 0.6.0'
|
||||
gem 'rubyzip', '~> 2.3'
|
||||
gem 'rubyzip', '~> 3.0'
|
||||
|
||||
gem 'hcaptcha', '~> 7.1'
|
||||
|
||||
|
|
227
Gemfile.lock
227
Gemfile.lock
|
@ -1,3 +1,11 @@
|
|||
GIT
|
||||
remote: https://github.com/ClearlyClaire/stoplight.git
|
||||
revision: f13e0c0d5e6d34af8d3cfc888871caa84237db42
|
||||
ref: f13e0c0d5e6d34af8d3cfc888871caa84237db42
|
||||
specs:
|
||||
stoplight (5.3.1)
|
||||
zeitwerk
|
||||
|
||||
GIT
|
||||
remote: https://github.com/mastodon/webpush.git
|
||||
revision: 9631ac63045cfabddacc69fc06e919b4c13eb913
|
||||
|
@ -10,29 +18,29 @@ GIT
|
|||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
actioncable (8.0.2.1)
|
||||
actionpack (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
activejob (= 8.0.2)
|
||||
activerecord (= 8.0.2)
|
||||
activestorage (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
actionmailbox (8.0.2.1)
|
||||
actionpack (= 8.0.2.1)
|
||||
activejob (= 8.0.2.1)
|
||||
activerecord (= 8.0.2.1)
|
||||
activestorage (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
mail (>= 2.8.0)
|
||||
actionmailer (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
actionview (= 8.0.2)
|
||||
activejob (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
actionmailer (8.0.2.1)
|
||||
actionpack (= 8.0.2.1)
|
||||
actionview (= 8.0.2.1)
|
||||
activejob (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
mail (>= 2.8.0)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (8.0.2)
|
||||
actionview (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
actionpack (8.0.2.1)
|
||||
actionview (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
nokogiri (>= 1.8.5)
|
||||
rack (>= 2.2.4)
|
||||
rack-session (>= 1.0.1)
|
||||
|
@ -40,15 +48,15 @@ GEM
|
|||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
useragent (~> 0.16)
|
||||
actiontext (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
activerecord (= 8.0.2)
|
||||
activestorage (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
actiontext (8.0.2.1)
|
||||
actionpack (= 8.0.2.1)
|
||||
activerecord (= 8.0.2.1)
|
||||
activestorage (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
actionview (8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
|
@ -58,22 +66,22 @@ GEM
|
|||
activemodel (>= 4.1)
|
||||
case_transform (>= 0.2)
|
||||
jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
|
||||
activejob (8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
activejob (8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
activerecord (8.0.2)
|
||||
activemodel (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
activemodel (8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
activerecord (8.0.2.1)
|
||||
activemodel (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
timeout (>= 0.4.0)
|
||||
activestorage (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
activejob (= 8.0.2)
|
||||
activerecord (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
activestorage (8.0.2.1)
|
||||
actionpack (= 8.0.2.1)
|
||||
activejob (= 8.0.2.1)
|
||||
activerecord (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
marcel (~> 1.0)
|
||||
activesupport (8.0.2)
|
||||
activesupport (8.0.2.1)
|
||||
base64
|
||||
benchmark (>= 0.3)
|
||||
bigdecimal
|
||||
|
@ -90,13 +98,13 @@ GEM
|
|||
public_suffix (>= 2.0.2, < 7.0)
|
||||
aes_key_wrap (1.1.0)
|
||||
android_key_attestation (0.3.0)
|
||||
annotaterb (4.17.0)
|
||||
annotaterb (4.18.0)
|
||||
activerecord (>= 6.0.0)
|
||||
activesupport (>= 6.0.0)
|
||||
ast (2.4.3)
|
||||
attr_required (1.0.2)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1131.0)
|
||||
aws-partitions (1.1135.0)
|
||||
aws-sdk-core (3.215.1)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
|
@ -144,7 +152,7 @@ GEM
|
|||
rack-test (>= 0.6.3)
|
||||
regexp_parser (>= 1.5, < 3.0)
|
||||
xpath (~> 3.2)
|
||||
capybara-playwright-driver (0.5.6)
|
||||
capybara-playwright-driver (0.5.7)
|
||||
addressable
|
||||
capybara
|
||||
playwright-ruby-client (>= 1.16.0)
|
||||
|
@ -175,9 +183,9 @@ GEM
|
|||
css_parser (1.21.1)
|
||||
addressable
|
||||
csv (3.3.5)
|
||||
database_cleaner-active_record (2.2.1)
|
||||
database_cleaner-active_record (2.2.2)
|
||||
activerecord (>= 5.a)
|
||||
database_cleaner-core (~> 2.0.0)
|
||||
database_cleaner-core (~> 2.0)
|
||||
database_cleaner-core (2.0.1)
|
||||
date (3.4.1)
|
||||
debug (1.11.0)
|
||||
|
@ -233,7 +241,7 @@ GEM
|
|||
fabrication (3.0.0)
|
||||
faker (3.5.2)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.13.2)
|
||||
faraday (2.13.4)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
|
@ -287,7 +295,7 @@ GEM
|
|||
activesupport (>= 5.1)
|
||||
haml (>= 4.0.6)
|
||||
railties (>= 5.1)
|
||||
haml_lint (0.65.1)
|
||||
haml_lint (0.66.0)
|
||||
haml (>= 5.0)
|
||||
parallel (~> 1.10)
|
||||
rainbow
|
||||
|
@ -300,8 +308,8 @@ GEM
|
|||
highline (3.1.2)
|
||||
reline
|
||||
hiredis (0.6.3)
|
||||
hiredis-client (0.25.1)
|
||||
redis-client (= 0.25.1)
|
||||
hiredis-client (0.25.2)
|
||||
redis-client (= 0.25.2)
|
||||
hkdf (0.3.0)
|
||||
htmlentities (4.3.4)
|
||||
http (5.3.1)
|
||||
|
@ -315,7 +323,7 @@ GEM
|
|||
http_accept_language (2.1.1)
|
||||
httpclient (2.9.0)
|
||||
mutex_m
|
||||
httplog (1.7.2)
|
||||
httplog (1.7.3)
|
||||
rack (>= 2.0)
|
||||
rainbow (>= 2.0.0)
|
||||
i18n (1.14.7)
|
||||
|
@ -345,7 +353,7 @@ GEM
|
|||
azure-blob (~> 0.5.2)
|
||||
hashie (~> 5.0)
|
||||
jmespath (1.6.2)
|
||||
json (2.13.0)
|
||||
json (2.13.2)
|
||||
json-canonicalization (1.0.0)
|
||||
json-jwt (1.16.7)
|
||||
activesupport (>= 4.2)
|
||||
|
@ -365,7 +373,7 @@ GEM
|
|||
json-ld-preloaded (3.3.2)
|
||||
json-ld (~> 3.3)
|
||||
rdf (~> 3.3)
|
||||
json-schema (5.2.1)
|
||||
json-schema (6.0.0)
|
||||
addressable (~> 2.8)
|
||||
bigdecimal (~> 3.1)
|
||||
jsonapi-renderer (0.2.2)
|
||||
|
@ -438,7 +446,7 @@ GEM
|
|||
mime-types (3.7.0)
|
||||
logger
|
||||
mime-types-data (~> 3.2025, >= 3.2025.0507)
|
||||
mime-types-data (3.2025.0715)
|
||||
mime-types-data (3.2025.0729)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.9)
|
||||
minitest (5.25.5)
|
||||
|
@ -497,7 +505,7 @@ GEM
|
|||
openssl (3.3.0)
|
||||
openssl-signature_algorithm (1.3.0)
|
||||
openssl (> 2.0)
|
||||
opentelemetry-api (1.5.0)
|
||||
opentelemetry-api (1.6.0)
|
||||
opentelemetry-common (0.22.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-exporter-otlp (0.30.0)
|
||||
|
@ -547,19 +555,19 @@ GEM
|
|||
opentelemetry-instrumentation-concurrent_ruby (0.22.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-instrumentation-excon (0.23.0)
|
||||
opentelemetry-instrumentation-excon (0.24.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-instrumentation-faraday (0.27.0)
|
||||
opentelemetry-instrumentation-faraday (0.28.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-instrumentation-http (0.25.1)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-instrumentation-http_client (0.23.0)
|
||||
opentelemetry-instrumentation-http_client (0.24.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-instrumentation-net_http (0.23.0)
|
||||
opentelemetry-instrumentation-net_http (0.23.1)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-instrumentation-pg (0.30.1)
|
||||
|
@ -589,7 +597,7 @@ GEM
|
|||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-registry (0.4.0)
|
||||
opentelemetry-api (~> 1.1)
|
||||
opentelemetry-sdk (1.8.0)
|
||||
opentelemetry-sdk (1.8.1)
|
||||
opentelemetry-api (~> 1.1)
|
||||
opentelemetry-common (~> 0.20)
|
||||
opentelemetry-registry (~> 0.2)
|
||||
|
@ -601,16 +609,16 @@ GEM
|
|||
ox (2.14.23)
|
||||
bigdecimal (>= 3.0)
|
||||
parallel (1.27.0)
|
||||
parser (3.3.8.0)
|
||||
parser (3.3.9.0)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
parslet (2.0.0)
|
||||
pastel (0.8.0)
|
||||
tty-color (~> 0.5)
|
||||
pg (1.5.9)
|
||||
pg (1.6.1)
|
||||
pghero (3.7.0)
|
||||
activerecord (>= 7.1)
|
||||
playwright-ruby-client (1.54.0)
|
||||
playwright-ruby-client (1.54.1)
|
||||
concurrent-ruby (>= 1.1.6)
|
||||
mime-types (>= 3.0)
|
||||
pp (0.6.2)
|
||||
|
@ -625,7 +633,7 @@ GEM
|
|||
premailer (~> 1.7, >= 1.7.9)
|
||||
prettyprint (0.2.0)
|
||||
prism (1.4.0)
|
||||
prometheus_exporter (2.2.0)
|
||||
prometheus_exporter (2.3.0)
|
||||
webrick
|
||||
propshaft (1.2.1)
|
||||
actionpack (>= 7.0.0)
|
||||
|
@ -635,7 +643,7 @@ GEM
|
|||
date
|
||||
stringio
|
||||
public_suffix (6.0.2)
|
||||
puma (6.6.0)
|
||||
puma (6.6.1)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.5.0)
|
||||
activesupport (>= 3.0.0)
|
||||
|
@ -667,20 +675,20 @@ GEM
|
|||
rack (>= 1.3)
|
||||
rackup (2.2.1)
|
||||
rack (>= 3)
|
||||
rails (8.0.2)
|
||||
actioncable (= 8.0.2)
|
||||
actionmailbox (= 8.0.2)
|
||||
actionmailer (= 8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
actiontext (= 8.0.2)
|
||||
actionview (= 8.0.2)
|
||||
activejob (= 8.0.2)
|
||||
activemodel (= 8.0.2)
|
||||
activerecord (= 8.0.2)
|
||||
activestorage (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
rails (8.0.2.1)
|
||||
actioncable (= 8.0.2.1)
|
||||
actionmailbox (= 8.0.2.1)
|
||||
actionmailer (= 8.0.2.1)
|
||||
actionpack (= 8.0.2.1)
|
||||
actiontext (= 8.0.2.1)
|
||||
actionview (= 8.0.2.1)
|
||||
activejob (= 8.0.2.1)
|
||||
activemodel (= 8.0.2.1)
|
||||
activerecord (= 8.0.2.1)
|
||||
activestorage (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 8.0.2)
|
||||
railties (= 8.0.2.1)
|
||||
rails-dom-testing (2.3.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
|
@ -688,12 +696,12 @@ GEM
|
|||
rails-html-sanitizer (1.6.2)
|
||||
loofah (~> 2.21)
|
||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||
rails-i18n (8.0.1)
|
||||
rails-i18n (8.0.2)
|
||||
i18n (>= 0.7, < 2)
|
||||
railties (>= 8.0.0, < 9)
|
||||
railties (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
railties (8.0.2.1)
|
||||
actionpack (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
|
@ -717,11 +725,9 @@ GEM
|
|||
reline
|
||||
redcarpet (3.6.1)
|
||||
redis (4.8.1)
|
||||
redis-client (0.25.1)
|
||||
redis-client (0.25.2)
|
||||
connection_pool
|
||||
redlock (1.3.2)
|
||||
redis (>= 3.0.0, < 6.0)
|
||||
regexp_parser (2.10.0)
|
||||
regexp_parser (2.11.2)
|
||||
reline (0.6.2)
|
||||
io-console (~> 0.5)
|
||||
request_store (1.7.0)
|
||||
|
@ -731,7 +737,7 @@ GEM
|
|||
railties (>= 5.2)
|
||||
rexml (3.4.1)
|
||||
rotp (6.3.0)
|
||||
rouge (4.5.2)
|
||||
rouge (4.6.0)
|
||||
rpam2 (4.0.2)
|
||||
rqrcode (3.1.0)
|
||||
chunky_png (~> 1.0)
|
||||
|
@ -751,7 +757,7 @@ GEM
|
|||
rspec-mocks (3.13.5)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-rails (8.0.1)
|
||||
rspec-rails (8.0.2)
|
||||
actionpack (>= 7.2)
|
||||
activesupport (>= 7.2)
|
||||
railties (>= 7.2)
|
||||
|
@ -765,7 +771,7 @@ GEM
|
|||
rspec-mocks (~> 3.0)
|
||||
sidekiq (>= 5, < 9)
|
||||
rspec-support (3.13.4)
|
||||
rubocop (1.79.0)
|
||||
rubocop (1.79.2)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
|
@ -775,7 +781,6 @@ GEM
|
|||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.46.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
tsort (>= 0.2.0)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.46.0)
|
||||
parser (>= 3.3.7.2)
|
||||
|
@ -790,7 +795,7 @@ GEM
|
|||
lint_roller (~> 1.1)
|
||||
rubocop (>= 1.75.0, < 2.0)
|
||||
rubocop-ast (>= 1.38.0, < 2.0)
|
||||
rubocop-rails (2.32.0)
|
||||
rubocop-rails (2.33.3)
|
||||
activesupport (>= 4.2.0)
|
||||
lint_roller (~> 1.1)
|
||||
rack (>= 1.1)
|
||||
|
@ -806,13 +811,13 @@ GEM
|
|||
ruby-prof (1.7.2)
|
||||
base64
|
||||
ruby-progressbar (1.13.0)
|
||||
ruby-saml (1.18.0)
|
||||
ruby-saml (1.18.1)
|
||||
nokogiri (>= 1.13.10)
|
||||
rexml
|
||||
ruby-vips (2.2.4)
|
||||
ruby-vips (2.2.5)
|
||||
ffi (~> 1.12)
|
||||
logger
|
||||
rubyzip (2.4.1)
|
||||
rubyzip (3.0.2)
|
||||
rufus-scheduler (3.9.2)
|
||||
fugit (~> 1.1, >= 1.11.1)
|
||||
safety_net_attestation (0.4.0)
|
||||
|
@ -826,18 +831,17 @@ GEM
|
|||
securerandom (0.4.1)
|
||||
shoulda-matchers (6.5.0)
|
||||
activesupport (>= 5.2.0)
|
||||
sidekiq (7.3.9)
|
||||
base64
|
||||
connection_pool (>= 2.3.0)
|
||||
logger
|
||||
rack (>= 2.2.4)
|
||||
redis-client (>= 0.22.2)
|
||||
sidekiq (8.0.7)
|
||||
connection_pool (>= 2.5.0)
|
||||
json (>= 2.9.0)
|
||||
logger (>= 1.6.2)
|
||||
rack (>= 3.1.0)
|
||||
redis-client (>= 0.23.2)
|
||||
sidekiq-bulk (0.2.0)
|
||||
sidekiq
|
||||
sidekiq-scheduler (5.0.6)
|
||||
sidekiq-scheduler (6.0.1)
|
||||
rufus-scheduler (~> 3.2)
|
||||
sidekiq (>= 6, < 8)
|
||||
tilt (>= 1.4.0, < 3)
|
||||
sidekiq (>= 7.3, < 9)
|
||||
sidekiq-unique-jobs (8.0.11)
|
||||
concurrent-ruby (~> 1.0, >= 1.0.5)
|
||||
sidekiq (>= 7.0.0, < 9.0.0)
|
||||
|
@ -857,8 +861,6 @@ GEM
|
|||
stackprof (0.2.27)
|
||||
starry (0.2.0)
|
||||
base64
|
||||
stoplight (4.1.1)
|
||||
redlock (~> 1.0)
|
||||
stringio (3.1.7)
|
||||
strong_migrations (2.5.0)
|
||||
activerecord (>= 7.1)
|
||||
|
@ -868,7 +870,7 @@ GEM
|
|||
faraday (~> 2.0)
|
||||
faraday-follow_redirects
|
||||
sysexits (1.2.0)
|
||||
temple (0.10.3)
|
||||
temple (0.10.4)
|
||||
terminal-table (4.0.0)
|
||||
unicode-display_width (>= 1.1.1, < 4)
|
||||
terrapin (1.1.1)
|
||||
|
@ -881,7 +883,6 @@ GEM
|
|||
bindata (~> 2.4)
|
||||
openssl (> 2.0)
|
||||
openssl-signature_algorithm (~> 1.0)
|
||||
tsort (0.2.0)
|
||||
tty-color (0.6.0)
|
||||
tty-cursor (0.7.1)
|
||||
tty-prompt (0.23.1)
|
||||
|
@ -1008,7 +1009,7 @@ DEPENDENCIES
|
|||
jd-paperclip-azure (~> 3.0)
|
||||
json-ld
|
||||
json-ld-preloaded (~> 3.2)
|
||||
json-schema (~> 5.0)
|
||||
json-schema (~> 6.0)
|
||||
kaminari (~> 1.2)
|
||||
kt-paperclip (~> 7.2)
|
||||
letter_opener (~> 1.8)
|
||||
|
@ -1030,15 +1031,15 @@ DEPENDENCIES
|
|||
omniauth-rails_csrf_protection (~> 1.0)
|
||||
omniauth-saml (~> 2.0)
|
||||
omniauth_openid_connect (~> 0.8.0)
|
||||
opentelemetry-api (~> 1.5.0)
|
||||
opentelemetry-api (~> 1.6.0)
|
||||
opentelemetry-exporter-otlp (~> 0.30.0)
|
||||
opentelemetry-instrumentation-active_job (~> 0.8.0)
|
||||
opentelemetry-instrumentation-active_model_serializers (~> 0.22.0)
|
||||
opentelemetry-instrumentation-concurrent_ruby (~> 0.22.0)
|
||||
opentelemetry-instrumentation-excon (~> 0.23.0)
|
||||
opentelemetry-instrumentation-faraday (~> 0.27.0)
|
||||
opentelemetry-instrumentation-excon (~> 0.24.0)
|
||||
opentelemetry-instrumentation-faraday (~> 0.28.0)
|
||||
opentelemetry-instrumentation-http (~> 0.25.0)
|
||||
opentelemetry-instrumentation-http_client (~> 0.23.0)
|
||||
opentelemetry-instrumentation-http_client (~> 0.24.0)
|
||||
opentelemetry-instrumentation-net_http (~> 0.23.0)
|
||||
opentelemetry-instrumentation-pg (~> 0.30.0)
|
||||
opentelemetry-instrumentation-rack (~> 0.26.0)
|
||||
|
@ -1078,20 +1079,20 @@ DEPENDENCIES
|
|||
ruby-prof
|
||||
ruby-progressbar (~> 1.13)
|
||||
ruby-vips (~> 2.2)
|
||||
rubyzip (~> 2.3)
|
||||
rubyzip (~> 3.0)
|
||||
sanitize (~> 7.0)
|
||||
scenic (~> 1.7)
|
||||
shoulda-matchers
|
||||
sidekiq (< 8)
|
||||
sidekiq (< 9)
|
||||
sidekiq-bulk (~> 0.2.0)
|
||||
sidekiq-scheduler (~> 5.0)
|
||||
sidekiq-scheduler (~> 6.0)
|
||||
sidekiq-unique-jobs (> 8)
|
||||
simple-navigation (~> 4.4)
|
||||
simple_form (~> 5.2)
|
||||
simplecov (~> 0.22)
|
||||
simplecov-lcov (~> 0.8)
|
||||
stackprof
|
||||
stoplight (~> 4.1)
|
||||
stoplight!
|
||||
strong_migrations
|
||||
test-prof
|
||||
thor (~> 1.2)
|
||||
|
@ -1108,4 +1109,4 @@ RUBY VERSION
|
|||
ruby 3.4.1p0
|
||||
|
||||
BUNDLED WITH
|
||||
2.7.0
|
||||
2.7.1
|
||||
|
|
|
@ -58,7 +58,7 @@ Mastodon is a **free, open-source social network server** based on [ActivityPub]
|
|||
|
||||
- **Ruby** 3.2+
|
||||
- **PostgreSQL** 13+
|
||||
- **Redis** 6.2+
|
||||
- **Redis** 7.0+
|
||||
- **Node.js** 20+
|
||||
|
||||
This repository includes deployment configurations for **Docker and docker-compose**, as well as for other environments like Heroku and Scalingo. For Helm charts, reference the [mastodon/chart repository](https://github.com/mastodon/chart). A [**standalone** installation guide](https://docs.joinmastodon.org/admin/install/) is available in the main documentation.
|
||||
|
|
3
Vagrantfile
vendored
3
Vagrantfile
vendored
|
@ -54,6 +54,7 @@ sudo apt-get install \
|
|||
pkg-config \
|
||||
protobuf-compiler \
|
||||
zlib1g-dev \
|
||||
libvips42t64 \
|
||||
-y
|
||||
|
||||
# Install rvm
|
||||
|
@ -134,7 +135,7 @@ VAGRANTFILE_API_VERSION = "2"
|
|||
|
||||
Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
|
||||
|
||||
config.vm.box = "ubuntu/focal64"
|
||||
config.vm.box = "bento/ubuntu-24.04"
|
||||
|
||||
config.vm.provider :virtualbox do |vb|
|
||||
vb.name = "mastodon"
|
||||
|
|
|
@ -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
|
|
@ -6,7 +6,7 @@ module Admin
|
|||
|
||||
def index
|
||||
authorize :audit_log, :index?
|
||||
@auditable_accounts = Account.auditable.select(:id, :username)
|
||||
@auditable_accounts = Account.auditable.select(:id, :username).order(username: :asc)
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
@ -18,7 +18,7 @@ class Admin::Disputes::AppealsController < Admin::BaseController
|
|||
end
|
||||
|
||||
def reject
|
||||
authorize @appeal, :approve?
|
||||
authorize @appeal, :reject?
|
||||
log_action :reject, @appeal
|
||||
@appeal.reject!(current_account)
|
||||
UserMailer.appeal_rejected(@appeal.account.user, @appeal).deliver_later
|
||||
|
|
|
@ -36,7 +36,7 @@ module Admin
|
|||
end
|
||||
|
||||
def edit
|
||||
authorize :domain_block, :create?
|
||||
authorize :domain_block, :update?
|
||||
end
|
||||
|
||||
def create
|
||||
|
@ -129,7 +129,7 @@ module Admin
|
|||
end
|
||||
|
||||
def requires_confirmation?
|
||||
@domain_block.valid? && (@domain_block.new_record? || @domain_block.severity_changed?) && @domain_block.severity.to_s == 'suspend' && !params[:confirm]
|
||||
@domain_block.valid? && (@domain_block.new_record? || @domain_block.severity_changed?) && @domain_block.suspend? && !params[:confirm]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
77
app/controllers/admin/username_blocks_controller.rb
Normal file
77
app/controllers/admin/username_blocks_controller.rb
Normal 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
|
|
@ -48,6 +48,7 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController
|
|||
default_privacy: source_params.fetch(:privacy, @account.user.setting_default_privacy),
|
||||
default_sensitive: source_params.fetch(:sensitive, @account.user.setting_default_sensitive),
|
||||
default_language: source_params.fetch(:language, @account.user.setting_default_language),
|
||||
default_quote_policy: source_params.fetch(:quote_policy, @account.user.setting_default_quote_policy),
|
||||
},
|
||||
}
|
||||
end
|
||||
|
|
|
@ -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
|
72
app/controllers/api/v1/statuses/quotes_controller.rb
Normal file
72
app/controllers/api/v1/statuses/quotes_controller.rb
Normal 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
|
|
@ -3,6 +3,7 @@
|
|||
class Api::V1::StatusesController < Api::BaseController
|
||||
include Authorization
|
||||
include AsyncRefreshesConcern
|
||||
include Api::InteractionPoliciesConcern
|
||||
|
||||
before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [:create, :update, :destroy]
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:create, :update, :destroy]
|
||||
|
@ -66,7 +67,11 @@ class Api::V1::StatusesController < Api::BaseController
|
|||
add_async_refresh_header(async_refresh)
|
||||
elsif !current_account.nil? && @status.should_fetch_replies?
|
||||
add_async_refresh_header(AsyncRefresh.create(refresh_key))
|
||||
ActivityPub::FetchAllRepliesWorker.perform_async(@status.id)
|
||||
|
||||
WorkerBatch.new.within do |batch|
|
||||
batch.connect(refresh_key, threshold: 1.0)
|
||||
ActivityPub::FetchAllRepliesWorker.perform_async(@status.id, { 'batch_id' => batch.id })
|
||||
end
|
||||
end
|
||||
|
||||
render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id)
|
||||
|
@ -78,6 +83,7 @@ class Api::V1::StatusesController < Api::BaseController
|
|||
text: status_params[:status],
|
||||
thread: @thread,
|
||||
quoted_status: @quoted_status,
|
||||
quote_approval_policy: quote_approval_policy,
|
||||
media_ids: status_params[:media_ids],
|
||||
sensitive: status_params[:sensitive],
|
||||
spoiler_text: status_params[:spoiler_text],
|
||||
|
@ -109,7 +115,8 @@ class Api::V1::StatusesController < Api::BaseController
|
|||
sensitive: status_params[:sensitive],
|
||||
language: status_params[:language],
|
||||
spoiler_text: status_params[:spoiler_text],
|
||||
poll: status_params[:poll]
|
||||
poll: status_params[:poll],
|
||||
quote_approval_policy: quote_approval_policy
|
||||
)
|
||||
|
||||
render json: @status, serializer: REST::StatusSerializer
|
||||
|
@ -176,6 +183,7 @@ class Api::V1::StatusesController < Api::BaseController
|
|||
:status,
|
||||
:in_reply_to_id,
|
||||
:quoted_status_id,
|
||||
:quote_approval_policy,
|
||||
:sensitive,
|
||||
:spoiler_text,
|
||||
:visibility,
|
||||
|
|
|
@ -20,7 +20,7 @@ class Api::V2::SearchController < Api::BaseController
|
|||
@search = Search.new(search_results)
|
||||
render json: @search, serializer: REST::SearchSerializer
|
||||
rescue Mastodon::SyntaxError
|
||||
unprocessable_entity
|
||||
unprocessable_content
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
not_found
|
||||
end
|
||||
|
|
|
@ -28,7 +28,7 @@ class ApplicationController < ActionController::Base
|
|||
rescue_from Mastodon::NotPermittedError, with: :forbidden
|
||||
rescue_from ActionController::RoutingError, ActiveRecord::RecordNotFound, with: :not_found
|
||||
rescue_from ActionController::UnknownFormat, with: :not_acceptable
|
||||
rescue_from ActionController::InvalidAuthenticityToken, with: :unprocessable_entity
|
||||
rescue_from ActionController::InvalidAuthenticityToken, with: :unprocessable_content
|
||||
rescue_from Mastodon::RateLimitExceededError, with: :too_many_requests
|
||||
|
||||
rescue_from(*Mastodon::HTTP_CONNECTION_ERRORS, with: :internal_server_error)
|
||||
|
@ -123,7 +123,7 @@ class ApplicationController < ActionController::Base
|
|||
respond_with_error(410)
|
||||
end
|
||||
|
||||
def unprocessable_entity
|
||||
def unprocessable_content
|
||||
respond_with_error(422)
|
||||
end
|
||||
|
||||
|
|
|
@ -23,11 +23,11 @@ class Auth::RegistrationsController < Devise::RegistrationsController
|
|||
super(&:build_invite_request)
|
||||
end
|
||||
|
||||
def edit # rubocop:disable Lint/UselessMethodDefinition
|
||||
def edit
|
||||
super
|
||||
end
|
||||
|
||||
def create # rubocop:disable Lint/UselessMethodDefinition
|
||||
def create
|
||||
super
|
||||
end
|
||||
|
||||
|
|
|
@ -12,6 +12,8 @@ class Auth::SessionsController < Devise::SessionsController
|
|||
skip_before_action :require_functional!
|
||||
skip_before_action :update_user_sign_in
|
||||
|
||||
around_action :preserve_stored_location, only: :destroy, if: :continue_after?
|
||||
|
||||
prepend_before_action :check_suspicious!, only: [:create]
|
||||
|
||||
include Auth::TwoFactorAuthenticationConcern
|
||||
|
@ -31,11 +33,9 @@ class Auth::SessionsController < Devise::SessionsController
|
|||
end
|
||||
|
||||
def destroy
|
||||
tmp_stored_location = stored_location_for(:user)
|
||||
super
|
||||
session.delete(:challenge_passed_at)
|
||||
flash.delete(:notice)
|
||||
store_location_for(:user, tmp_stored_location) if continue_after?
|
||||
end
|
||||
|
||||
def webauthn_options
|
||||
|
@ -96,6 +96,12 @@ class Auth::SessionsController < Devise::SessionsController
|
|||
|
||||
private
|
||||
|
||||
def preserve_stored_location
|
||||
original_stored_location = stored_location_for(:user)
|
||||
yield
|
||||
store_location_for(:user, original_stored_location)
|
||||
end
|
||||
|
||||
def check_suspicious!
|
||||
user = find_user
|
||||
@login_is_suspicious = suspicious_sign_in?(user) unless user.nil?
|
||||
|
|
22
app/controllers/concerns/api/interaction_policies_concern.rb
Normal file
22
app/controllers/concerns/api/interaction_policies_concern.rb
Normal 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
|
|
@ -9,6 +9,8 @@ module SignatureVerification
|
|||
|
||||
EXPIRATION_WINDOW_LIMIT = 12.hours
|
||||
CLOCK_SKEW_MARGIN = 1.hour
|
||||
STOPLIGHT_COOL_OFF_TIME = 5.minutes.seconds
|
||||
STOPLIGHT_THRESHOLD = 1
|
||||
|
||||
def require_account_signature!
|
||||
render json: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_account
|
||||
|
@ -107,10 +109,12 @@ module SignatureVerification
|
|||
end
|
||||
|
||||
def stoplight_wrapper
|
||||
Stoplight("source:#{request.remote_ip}")
|
||||
.with_threshold(1)
|
||||
.with_cool_off_time(5.minutes.seconds)
|
||||
.with_error_handler { |error, handle| error.is_a?(HTTP::Error) || error.is_a?(OpenSSL::SSL::SSLError) ? handle.call(error) : raise(error) }
|
||||
Stoplight(
|
||||
"source:#{request.remote_ip}",
|
||||
cool_off_time: STOPLIGHT_COOL_OFF_TIME,
|
||||
threshold: STOPLIGHT_THRESHOLD,
|
||||
tracked_errors: [HTTP::Error, OpenSSL::SSL::SSLError]
|
||||
)
|
||||
end
|
||||
|
||||
def actor_refresh_key!(actor)
|
||||
|
|
|
@ -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
|
|
@ -52,7 +52,7 @@ module Settings
|
|||
end
|
||||
else
|
||||
flash[:error] = I18n.t('webauthn_credentials.create.error')
|
||||
status = :unprocessable_entity
|
||||
status = :unprocessable_content
|
||||
end
|
||||
else
|
||||
flash[:error] = t('webauthn_credentials.create.error')
|
||||
|
|
|
@ -11,6 +11,7 @@ class StatusesController < ApplicationController
|
|||
before_action :require_account_signature!, only: [:show, :activity], if: -> { request.format == :json && authorized_fetch_mode? }
|
||||
before_action :set_status
|
||||
before_action :redirect_to_original, only: :show
|
||||
before_action :verify_embed_allowed, only: :embed
|
||||
|
||||
after_action :set_link_headers
|
||||
|
||||
|
@ -40,8 +41,6 @@ class StatusesController < ApplicationController
|
|||
end
|
||||
|
||||
def embed
|
||||
return not_found if @status.hidden? || @status.reblog?
|
||||
|
||||
expires_in 180, public: true
|
||||
response.headers.delete('X-Frame-Options')
|
||||
|
||||
|
@ -50,6 +49,10 @@ class StatusesController < ApplicationController
|
|||
|
||||
private
|
||||
|
||||
def verify_embed_allowed
|
||||
not_found if @status.hidden? || @status.reblog?
|
||||
end
|
||||
|
||||
def set_link_headers
|
||||
response.headers['Link'] = LinkHeader.new(
|
||||
[[ActivityPub::TagManager.instance.uri_for(@status), [%w(rel alternate), %w(type application/activity+json)]]]
|
||||
|
|
|
@ -13,6 +13,8 @@ module Admin::ActionLogsHelper
|
|||
end
|
||||
when 'UserRole'
|
||||
link_to log.human_identifier, admin_roles_path(log.target_id)
|
||||
when 'UsernameBlock'
|
||||
link_to log.human_identifier, edit_admin_username_block_path(log.target_id)
|
||||
when 'Report'
|
||||
link_to "##{log.human_identifier.presence || log.target_id}", admin_report_path(log.target_id)
|
||||
when 'Instance', 'DomainBlock', 'DomainAllow', 'UnavailableDomain'
|
||||
|
|
|
@ -102,6 +102,16 @@ module ApplicationHelper
|
|||
policy(record).public_send(:"#{action}?")
|
||||
end
|
||||
|
||||
def conditional_link_to(condition, name, options = {}, html_options = {}, &block)
|
||||
if condition && !current_page?(block_given? ? name : options)
|
||||
link_to(name, options, html_options, &block)
|
||||
elsif block_given?
|
||||
content_tag(:span, options, html_options, &block)
|
||||
else
|
||||
content_tag(:span, name, html_options)
|
||||
end
|
||||
end
|
||||
|
||||
def material_symbol(icon, attributes = {})
|
||||
safe_join(
|
||||
[
|
||||
|
@ -233,6 +243,10 @@ module ApplicationHelper
|
|||
tag.input(type: :text, maxlength: 999, spellcheck: false, readonly: true, **options)
|
||||
end
|
||||
|
||||
def recent_tag_users(tag)
|
||||
tag.statuses.public_visibility.joins(:account).merge(Account.without_suspended.without_silenced).includes(:account).limit(3).map(&:account)
|
||||
end
|
||||
|
||||
def recent_tag_usage(tag)
|
||||
people = tag.history.aggregate(2.days.ago.to_date..Time.zone.today).accounts
|
||||
I18n.t 'user_mailer.welcome.hashtags_recent_count', people: number_with_delimiter(people), count: people
|
||||
|
|
|
@ -39,6 +39,12 @@ module ContextHelper
|
|||
'automaticApproval' => { '@id' => 'gts:automaticApproval', '@type' => '@id' },
|
||||
'manualApproval' => { '@id' => 'gts:manualApproval', '@type' => '@id' },
|
||||
},
|
||||
quote_authorizations: {
|
||||
'gts' => 'https://gotosocial.org/ns#',
|
||||
'quoteAuthorization' => { '@id' => 'https://w3id.org/fep/044f#quoteAuthorization', '@type' => '@id' },
|
||||
'interactingObject' => { '@id' => 'gts:interactingObject' },
|
||||
'interactionTarget' => { '@id' => 'gts:interactionTarget' },
|
||||
},
|
||||
}.freeze
|
||||
|
||||
def full_context
|
||||
|
|
|
@ -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
|
|
@ -39,16 +39,6 @@ module HomeHelper
|
|||
end
|
||||
end
|
||||
|
||||
def obscured_counter(count)
|
||||
if count <= 0
|
||||
'0'
|
||||
elsif count == 1
|
||||
'1'
|
||||
else
|
||||
'1+'
|
||||
end
|
||||
end
|
||||
|
||||
def field_verified_class(verified)
|
||||
if verified
|
||||
'verified'
|
||||
|
|
|
@ -1 +1,3 @@
|
|||
Images in this folder are based on [Tabler.io icons](https://tabler.io/icons).
|
||||
|
||||
Seems to be 1.5 width icons scaled to 64×64px and centered above a blue square with round corners (24px).
|
||||
|
|
BIN
app/javascript/images/mailer-new/heading/quote.png
Normal file
BIN
app/javascript/images/mailer-new/heading/quote.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.8 KiB |
|
@ -84,6 +84,7 @@ export const COMPOSE_FOCUS = 'COMPOSE_FOCUS';
|
|||
const messages = defineMessages({
|
||||
uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' },
|
||||
uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' },
|
||||
uploadQuote: { id: 'upload_error.quote', defaultMessage: 'File upload not allowed with quotes.' },
|
||||
open: { id: 'compose.published.open', defaultMessage: 'Open' },
|
||||
published: { id: 'compose.published.body', defaultMessage: 'Post published.' },
|
||||
saved: { id: 'compose.saved.body', defaultMessage: 'Post saved.' },
|
||||
|
@ -96,12 +97,17 @@ export const ensureComposeIsVisible = (getState) => {
|
|||
};
|
||||
|
||||
export function setComposeToStatus(status, text, spoiler_text) {
|
||||
return{
|
||||
type: COMPOSE_SET_STATUS,
|
||||
status,
|
||||
text,
|
||||
spoiler_text,
|
||||
};
|
||||
return (dispatch, getState) => {
|
||||
const maxOptions = getState().server.getIn(['server', 'configuration', 'polls', 'max_options']);
|
||||
|
||||
dispatch({
|
||||
type: COMPOSE_SET_STATUS,
|
||||
status,
|
||||
text,
|
||||
spoiler_text,
|
||||
maxOptions,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function changeCompose(text) {
|
||||
|
@ -146,7 +152,7 @@ export function resetCompose() {
|
|||
};
|
||||
}
|
||||
|
||||
export const focusCompose = (defaultText) => (dispatch, getState) => {
|
||||
export const focusCompose = (defaultText = '') => (dispatch, getState) => {
|
||||
dispatch({
|
||||
type: COMPOSE_FOCUS,
|
||||
defaultText,
|
||||
|
@ -183,7 +189,7 @@ export function directCompose(account) {
|
|||
};
|
||||
}
|
||||
|
||||
export function submitCompose() {
|
||||
export function submitCompose(successCallback) {
|
||||
return function (dispatch, getState) {
|
||||
const status = getState().getIn(['compose', 'text'], '');
|
||||
const media = getState().getIn(['compose', 'media_attachments']);
|
||||
|
@ -215,6 +221,7 @@ export function submitCompose() {
|
|||
});
|
||||
}
|
||||
|
||||
const visibility = getState().getIn(['compose', 'privacy']);
|
||||
api().request({
|
||||
url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`,
|
||||
method: statusId === null ? 'post' : 'put',
|
||||
|
@ -225,9 +232,11 @@ export function submitCompose() {
|
|||
media_attributes,
|
||||
sensitive: getState().getIn(['compose', 'sensitive']),
|
||||
spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '',
|
||||
visibility: getState().getIn(['compose', 'privacy']),
|
||||
visibility: visibility,
|
||||
poll: getState().getIn(['compose', 'poll'], null),
|
||||
language: getState().getIn(['compose', 'language']),
|
||||
quoted_status_id: getState().getIn(['compose', 'quoted_status_id']),
|
||||
quote_approval_policy: visibility === 'private' || visibility === 'direct' ? 'nobody' : getState().getIn(['compose', 'quote_policy']),
|
||||
},
|
||||
headers: {
|
||||
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
|
||||
|
@ -239,6 +248,9 @@ export function submitCompose() {
|
|||
|
||||
dispatch(insertIntoTagHistory(response.data.tags, status));
|
||||
dispatch(submitComposeSuccess({ ...response.data }));
|
||||
if (typeof successCallback === 'function') {
|
||||
successCallback(response.data);
|
||||
}
|
||||
|
||||
// To make the app more responsive, immediately push the status
|
||||
// into the columns
|
||||
|
@ -298,6 +310,11 @@ export function submitComposeFail(error) {
|
|||
|
||||
export function uploadCompose(files) {
|
||||
return function (dispatch, getState) {
|
||||
// Exit if there's a quote.
|
||||
if (getState().compose.get('quoted_status_id')) {
|
||||
dispatch(showAlert({ message: messages.uploadQuote }));
|
||||
return;
|
||||
}
|
||||
const uploadLimit = getState().getIn(['server', 'server', 'configuration', 'statuses', 'max_media_attachments']);
|
||||
const media = getState().getIn(['compose', 'media_attachments']);
|
||||
const pending = getState().getIn(['compose', 'pending_media_attachments']);
|
||||
|
|
|
@ -1,9 +1,40 @@
|
|||
import { defineMessages } from 'react-intl';
|
||||
|
||||
import { createAction } from '@reduxjs/toolkit';
|
||||
import type { List as ImmutableList, Map as ImmutableMap } from 'immutable';
|
||||
|
||||
import { apiUpdateMedia } from 'mastodon/api/compose';
|
||||
import type { ApiMediaAttachmentJSON } from 'mastodon/api_types/media_attachments';
|
||||
import type { MediaAttachment } from 'mastodon/models/media_attachment';
|
||||
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
||||
import {
|
||||
createDataLoadingThunk,
|
||||
createAppThunk,
|
||||
} from 'mastodon/store/typed_functions';
|
||||
|
||||
import type { ApiQuotePolicy } from '../api_types/quotes';
|
||||
import type { Status } from '../models/status';
|
||||
|
||||
import { showAlert } from './alerts';
|
||||
import { focusCompose } from './compose';
|
||||
|
||||
const messages = defineMessages({
|
||||
quoteErrorUpload: {
|
||||
id: 'quote_error.upload',
|
||||
defaultMessage: 'Quoting is not allowed with media attachments.',
|
||||
},
|
||||
quoteErrorPoll: {
|
||||
id: 'quote_error.poll',
|
||||
defaultMessage: 'Quoting is not allowed with polls.',
|
||||
},
|
||||
quoteErrorQuote: {
|
||||
id: 'quote_error.quote',
|
||||
defaultMessage: 'Only one quote at a time is allowed.',
|
||||
},
|
||||
quoteErrorUnauthorized: {
|
||||
id: 'quote_error.unauthorized',
|
||||
defaultMessage: 'You are not authorized to quote this post.',
|
||||
},
|
||||
});
|
||||
|
||||
type SimulatedMediaAttachmentJSON = ApiMediaAttachmentJSON & {
|
||||
unattached?: boolean;
|
||||
|
@ -68,3 +99,55 @@ export const changeUploadCompose = createDataLoadingThunk(
|
|||
useLoadingBar: false,
|
||||
},
|
||||
);
|
||||
|
||||
export const quoteCompose = createAppThunk(
|
||||
'compose/quoteComposeStatus',
|
||||
(status: Status, { dispatch }) => {
|
||||
dispatch(focusCompose());
|
||||
return status;
|
||||
},
|
||||
);
|
||||
|
||||
export const quoteComposeByStatus = createAppThunk(
|
||||
(status: Status, { dispatch, getState }) => {
|
||||
const composeState = getState().compose;
|
||||
const mediaAttachments = composeState.get('media_attachments');
|
||||
|
||||
if (composeState.get('poll')) {
|
||||
dispatch(showAlert({ message: messages.quoteErrorPoll }));
|
||||
} else if (
|
||||
composeState.get('is_uploading') ||
|
||||
(mediaAttachments &&
|
||||
typeof mediaAttachments !== 'string' &&
|
||||
typeof mediaAttachments !== 'number' &&
|
||||
typeof mediaAttachments !== 'boolean' &&
|
||||
mediaAttachments.size !== 0)
|
||||
) {
|
||||
dispatch(showAlert({ message: messages.quoteErrorUpload }));
|
||||
} else if (composeState.get('quoted_status_id')) {
|
||||
dispatch(showAlert({ message: messages.quoteErrorQuote }));
|
||||
} else if (
|
||||
status.getIn(['quote_approval', 'current_user']) !== 'automatic' &&
|
||||
status.getIn(['quote_approval', 'current_user']) !== 'manual'
|
||||
) {
|
||||
dispatch(showAlert({ message: messages.quoteErrorUnauthorized }));
|
||||
} else {
|
||||
dispatch(quoteCompose(status));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const quoteComposeById = createAppThunk(
|
||||
(statusId: string, { dispatch, getState }) => {
|
||||
const status = getState().statuses.get(statusId);
|
||||
if (status) {
|
||||
dispatch(quoteComposeByStatus(status));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const quoteComposeCancel = createAction('compose/quoteComposeCancel');
|
||||
|
||||
export const setComposeQuotePolicy = createAction<ApiQuotePolicy>(
|
||||
'compose/setQuotePolicy',
|
||||
);
|
||||
|
|
|
@ -1,8 +1,13 @@
|
|||
import { apiReblog, apiUnreblog } from 'mastodon/api/interactions';
|
||||
import {
|
||||
apiReblog,
|
||||
apiUnreblog,
|
||||
apiRevokeQuote,
|
||||
apiGetQuotes,
|
||||
} from 'mastodon/api/interactions';
|
||||
import type { StatusVisibility } from 'mastodon/models/status';
|
||||
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
||||
|
||||
import { importFetchedStatus } from './importer';
|
||||
import { importFetchedStatus, importFetchedStatuses } from './importer';
|
||||
|
||||
export const reblog = createDataLoadingThunk(
|
||||
'status/reblog',
|
||||
|
@ -33,3 +38,35 @@ export const unreblog = createDataLoadingThunk(
|
|||
return discardLoadData;
|
||||
},
|
||||
);
|
||||
|
||||
export const revokeQuote = createDataLoadingThunk(
|
||||
'status/revoke_quote',
|
||||
({
|
||||
statusId,
|
||||
quotedStatusId,
|
||||
}: {
|
||||
statusId: string;
|
||||
quotedStatusId: string;
|
||||
}) => apiRevokeQuote(quotedStatusId, statusId),
|
||||
(data, { dispatch, discardLoadData }) => {
|
||||
dispatch(importFetchedStatus(data));
|
||||
|
||||
return discardLoadData;
|
||||
},
|
||||
);
|
||||
|
||||
export const fetchQuotes = createDataLoadingThunk(
|
||||
'status/fetch_quotes',
|
||||
async ({ statusId, next }: { statusId: string; next?: string }) => {
|
||||
const { links, statuses } = await apiGetQuotes(statusId, next);
|
||||
|
||||
return {
|
||||
links,
|
||||
statuses,
|
||||
replace: !next,
|
||||
};
|
||||
},
|
||||
(payload, { dispatch }) => {
|
||||
dispatch(importFetchedStatuses(payload.statuses));
|
||||
},
|
||||
);
|
||||
|
|
|
@ -30,8 +30,21 @@ import { importFetchedAccounts, importFetchedStatuses } from './importer';
|
|||
import { NOTIFICATIONS_FILTER_SET } from './notifications';
|
||||
import { saveSettings } from './settings';
|
||||
|
||||
function notificationTypeForFilter(type: NotificationType) {
|
||||
if (type === 'quoted_update') return 'update';
|
||||
else return type;
|
||||
}
|
||||
|
||||
function notificationTypeForQuickFilter(type: NotificationType) {
|
||||
if (type === 'quoted_update') return 'update';
|
||||
else if (type === 'quote') return 'mention';
|
||||
else return type;
|
||||
}
|
||||
|
||||
function excludeAllTypesExcept(filter: string) {
|
||||
return allNotificationTypes.filter((item) => item !== filter);
|
||||
return allNotificationTypes.filter(
|
||||
(item) => notificationTypeForQuickFilter(item) !== filter,
|
||||
);
|
||||
}
|
||||
|
||||
function getExcludedTypes(state: RootState) {
|
||||
|
@ -155,13 +168,17 @@ export const processNewNotificationForGroups = createAppAsyncThunk(
|
|||
|
||||
const showInColumn =
|
||||
activeFilter === 'all'
|
||||
? notificationShows[notification.type] !== false
|
||||
: activeFilter === notification.type;
|
||||
? notificationShows[notificationTypeForFilter(notification.type)] !==
|
||||
false
|
||||
: activeFilter === notificationTypeForQuickFilter(notification.type);
|
||||
|
||||
if (!showInColumn) return;
|
||||
|
||||
if (
|
||||
(notification.type === 'mention' || notification.type === 'update') &&
|
||||
(notification.type === 'mention' ||
|
||||
notification.type === 'quote' ||
|
||||
notification.type === 'update' ||
|
||||
notification.type === 'quoted_update') &&
|
||||
notification.status?.filtered
|
||||
) {
|
||||
const filters = notification.status.filtered.filter((result) =>
|
||||
|
|
|
@ -31,7 +31,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
|
|||
|
||||
let filtered = false;
|
||||
|
||||
if (['mention', 'status'].includes(notification.type) && notification.status.filtered) {
|
||||
if (['mention', 'quote', 'status'].includes(notification.type) && notification.status.filtered) {
|
||||
const filters = notification.status.filtered.filter(result => result.filter.context.includes('notifications'));
|
||||
|
||||
if (filters.some(result => result.filter.filter_action === 'hide')) {
|
||||
|
|
|
@ -1,9 +1,12 @@
|
|||
import { defineMessages } from 'react-intl';
|
||||
|
||||
import { browserHistory } from 'mastodon/components/router';
|
||||
|
||||
import api from '../api';
|
||||
|
||||
import { showAlert } from './alerts';
|
||||
import { ensureComposeIsVisible, setComposeToStatus } from './compose';
|
||||
import { importFetchedStatus, importFetchedStatuses, importFetchedAccount } from './importer';
|
||||
import { importFetchedStatus, importFetchedAccount } from './importer';
|
||||
import { fetchContext } from './statuses_typed';
|
||||
import { deleteFromTimelines } from './timelines';
|
||||
|
||||
|
@ -40,6 +43,10 @@ export const STATUS_TRANSLATE_SUCCESS = 'STATUS_TRANSLATE_SUCCESS';
|
|||
export const STATUS_TRANSLATE_FAIL = 'STATUS_TRANSLATE_FAIL';
|
||||
export const STATUS_TRANSLATE_UNDO = 'STATUS_TRANSLATE_UNDO';
|
||||
|
||||
const messages = defineMessages({
|
||||
deleteSuccess: { id: 'status.delete.success', defaultMessage: 'Post deleted' },
|
||||
});
|
||||
|
||||
export function fetchStatusRequest(id, skipLoading) {
|
||||
return {
|
||||
type: STATUS_FETCH_REQUEST,
|
||||
|
@ -48,7 +55,18 @@ export function fetchStatusRequest(id, skipLoading) {
|
|||
};
|
||||
}
|
||||
|
||||
export function fetchStatus(id, forceFetch = false, alsoFetchContext = true) {
|
||||
/**
|
||||
* @param {string} id
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.forceFetch]
|
||||
* @param {boolean} [options.alsoFetchContext]
|
||||
* @param {string | null | undefined} [options.parentQuotePostId]
|
||||
*/
|
||||
export function fetchStatus(id, {
|
||||
forceFetch = false,
|
||||
alsoFetchContext = true,
|
||||
parentQuotePostId,
|
||||
} = {}) {
|
||||
return (dispatch, getState) => {
|
||||
const skipLoading = !forceFetch && getState().getIn(['statuses', id], null) !== null;
|
||||
|
||||
|
@ -66,7 +84,7 @@ export function fetchStatus(id, forceFetch = false, alsoFetchContext = true) {
|
|||
dispatch(importFetchedStatus(response.data));
|
||||
dispatch(fetchStatusSuccess(skipLoading));
|
||||
}).catch(error => {
|
||||
dispatch(fetchStatusFail(id, error, skipLoading));
|
||||
dispatch(fetchStatusFail(id, error, skipLoading, parentQuotePostId));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
@ -78,21 +96,27 @@ export function fetchStatusSuccess(skipLoading) {
|
|||
};
|
||||
}
|
||||
|
||||
export function fetchStatusFail(id, error, skipLoading) {
|
||||
export function fetchStatusFail(id, error, skipLoading, parentQuotePostId) {
|
||||
return {
|
||||
type: STATUS_FETCH_FAIL,
|
||||
id,
|
||||
error,
|
||||
parentQuotePostId,
|
||||
skipLoading,
|
||||
skipAlert: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function redraft(status, raw_text) {
|
||||
return {
|
||||
type: REDRAFT,
|
||||
status,
|
||||
raw_text,
|
||||
return (dispatch, getState) => {
|
||||
const maxOptions = getState().server.getIn(['server', 'configuration', 'polls', 'max_options']);
|
||||
|
||||
dispatch({
|
||||
type: REDRAFT,
|
||||
status,
|
||||
raw_text,
|
||||
maxOptions,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -137,7 +161,7 @@ export function deleteStatus(id, withRedraft = false) {
|
|||
|
||||
dispatch(deleteStatusRequest(id));
|
||||
|
||||
api().delete(`/api/v1/statuses/${id}`, { params: { delete_media: !withRedraft } }).then(response => {
|
||||
return api().delete(`/api/v1/statuses/${id}`, { params: { delete_media: !withRedraft } }).then(response => {
|
||||
dispatch(deleteStatusSuccess(id));
|
||||
dispatch(deleteFromTimelines(id));
|
||||
dispatch(importFetchedAccount(response.data.account));
|
||||
|
@ -145,9 +169,14 @@ export function deleteStatus(id, withRedraft = false) {
|
|||
if (withRedraft) {
|
||||
dispatch(redraft(status, response.data.text));
|
||||
ensureComposeIsVisible(getState);
|
||||
} else {
|
||||
dispatch(showAlert({ message: messages.deleteSuccess }));
|
||||
}
|
||||
|
||||
return response;
|
||||
}).catch(error => {
|
||||
dispatch(deleteStatusFail(id, error));
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
import { createAction } from '@reduxjs/toolkit';
|
||||
|
||||
import { apiGetContext } from 'mastodon/api/statuses';
|
||||
import { apiGetContext, apiSetQuotePolicy } from 'mastodon/api/statuses';
|
||||
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
||||
|
||||
import type { ApiQuotePolicy } from '../api_types/quotes';
|
||||
|
||||
import { importFetchedStatuses } from './importer';
|
||||
|
||||
export const fetchContext = createDataLoadingThunk(
|
||||
|
@ -23,3 +25,10 @@ export const fetchContext = createDataLoadingThunk(
|
|||
export const completeContextRefresh = createAction<{ statusId: string }>(
|
||||
'status/context/complete',
|
||||
);
|
||||
|
||||
export const setStatusQuotePolicy = createDataLoadingThunk(
|
||||
'status/setQuotePolicy',
|
||||
({ statusId, policy }: { statusId: string; policy: ApiQuotePolicy }) => {
|
||||
return apiSetQuotePolicy(statusId, policy);
|
||||
},
|
||||
);
|
||||
|
|
|
@ -1,10 +1,28 @@
|
|||
import { apiRequestPost } from 'mastodon/api';
|
||||
import type { Status, StatusVisibility } from 'mastodon/models/status';
|
||||
import api, { apiRequestPost, getLinks } from 'mastodon/api';
|
||||
import type { ApiStatusJSON } from 'mastodon/api_types/statuses';
|
||||
import type { StatusVisibility } from 'mastodon/models/status';
|
||||
|
||||
export const apiReblog = (statusId: string, visibility: StatusVisibility) =>
|
||||
apiRequestPost<{ reblog: Status }>(`v1/statuses/${statusId}/reblog`, {
|
||||
apiRequestPost<{ reblog: ApiStatusJSON }>(`v1/statuses/${statusId}/reblog`, {
|
||||
visibility,
|
||||
});
|
||||
|
||||
export const apiUnreblog = (statusId: string) =>
|
||||
apiRequestPost<Status>(`v1/statuses/${statusId}/unreblog`);
|
||||
apiRequestPost<ApiStatusJSON>(`v1/statuses/${statusId}/unreblog`);
|
||||
|
||||
export const apiRevokeQuote = (quotedStatusId: string, statusId: string) =>
|
||||
apiRequestPost<ApiStatusJSON>(
|
||||
`v1/statuses/${quotedStatusId}/quotes/${statusId}/revoke`,
|
||||
);
|
||||
|
||||
export const apiGetQuotes = async (statusId: string, url?: string) => {
|
||||
const response = await api().request<ApiStatusJSON[]>({
|
||||
method: 'GET',
|
||||
url: url ?? `/api/v1/statuses/${statusId}/quotes`,
|
||||
});
|
||||
|
||||
return {
|
||||
statuses: response.data,
|
||||
links: getLinks(response),
|
||||
};
|
||||
};
|
||||
|
|
|
@ -1,5 +1,10 @@
|
|||
import api, { getAsyncRefreshHeader } from 'mastodon/api';
|
||||
import type { ApiContextJSON } from 'mastodon/api_types/statuses';
|
||||
import api, { apiRequestPut, getAsyncRefreshHeader } from 'mastodon/api';
|
||||
import type {
|
||||
ApiContextJSON,
|
||||
ApiStatusJSON,
|
||||
} from 'mastodon/api_types/statuses';
|
||||
|
||||
import type { ApiQuotePolicy } from '../api_types/quotes';
|
||||
|
||||
export const apiGetContext = async (statusId: string) => {
|
||||
const response = await api().request<ApiContextJSON>({
|
||||
|
@ -12,3 +17,15 @@ export const apiGetContext = async (statusId: string) => {
|
|||
refresh: getAsyncRefreshHeader(response),
|
||||
};
|
||||
};
|
||||
|
||||
export const apiSetQuotePolicy = async (
|
||||
statusId: string,
|
||||
policy: ApiQuotePolicy,
|
||||
) => {
|
||||
return apiRequestPut<ApiStatusJSON>(
|
||||
`v1/statuses/${statusId}/interaction_policy`,
|
||||
{
|
||||
quote_approval_policy: policy,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
|
@ -37,7 +37,7 @@ export interface BaseApiAccountJSON {
|
|||
roles?: ApiAccountJSON[];
|
||||
statuses_count: number;
|
||||
uri: string;
|
||||
url: string;
|
||||
url?: string;
|
||||
username: string;
|
||||
moved?: ApiAccountJSON;
|
||||
suspended?: boolean;
|
||||
|
|
|
@ -7,12 +7,13 @@ import type { ApiReportJSON } from './reports';
|
|||
import type { ApiStatusJSON } from './statuses';
|
||||
|
||||
// See app/model/notification.rb
|
||||
export const allNotificationTypes = [
|
||||
export const allNotificationTypes: NotificationType[] = [
|
||||
'follow',
|
||||
'follow_request',
|
||||
'favourite',
|
||||
'reblog',
|
||||
'mention',
|
||||
'quote',
|
||||
'poll',
|
||||
'status',
|
||||
'update',
|
||||
|
@ -28,8 +29,10 @@ export type NotificationWithStatusType =
|
|||
| 'reblog'
|
||||
| 'status'
|
||||
| 'mention'
|
||||
| 'quote'
|
||||
| 'poll'
|
||||
| 'update';
|
||||
| 'update'
|
||||
| 'quoted_update';
|
||||
|
||||
export type NotificationType =
|
||||
| NotificationWithStatusType
|
||||
|
|
38
app/javascript/mastodon/api_types/quotes.ts
Normal file
38
app/javascript/mastodon/api_types/quotes.ts
Normal 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);
|
||||
}
|
|
@ -4,6 +4,7 @@ import type { ApiAccountJSON } from './accounts';
|
|||
import type { ApiCustomEmojiJSON } from './custom_emoji';
|
||||
import type { ApiMediaAttachmentJSON } from './media_attachments';
|
||||
import type { ApiPollJSON } from './polls';
|
||||
import type { ApiQuoteJSON, ApiQuotePolicyJSON } from './quotes';
|
||||
|
||||
// See app/modals/status.rb
|
||||
export type StatusVisibility =
|
||||
|
@ -95,6 +96,7 @@ export interface ApiStatusJSON {
|
|||
replies_count: number;
|
||||
reblogs_count: number;
|
||||
favorites_count: number;
|
||||
quotes_count: number;
|
||||
edited_at?: string;
|
||||
|
||||
favorited?: boolean;
|
||||
|
@ -118,9 +120,23 @@ export interface ApiStatusJSON {
|
|||
|
||||
card?: ApiPreviewCardJSON;
|
||||
poll?: ApiPollJSON;
|
||||
quote?: ApiQuoteJSON;
|
||||
quote_approval?: ApiQuotePolicyJSON;
|
||||
}
|
||||
|
||||
export interface ApiContextJSON {
|
||||
ancestors: ApiStatusJSON[];
|
||||
descendants: ApiStatusJSON[];
|
||||
}
|
||||
|
||||
export interface ApiStatusSourceJSON {
|
||||
id: string;
|
||||
text: string;
|
||||
spoiler_text: string;
|
||||
}
|
||||
|
||||
export function isStatusVisibility(
|
||||
visibility: string,
|
||||
): visibility is StatusVisibility {
|
||||
return ['public', 'unlisted', 'private', 'direct'].includes(visibility);
|
||||
}
|
||||
|
|
|
@ -53,6 +53,7 @@ const AutosuggestTextarea = forwardRef(({
|
|||
onFocus,
|
||||
autoFocus = true,
|
||||
lang,
|
||||
className,
|
||||
}, textareaRef) => {
|
||||
|
||||
const [suggestionsHidden, setSuggestionsHidden] = useState(true);
|
||||
|
@ -192,7 +193,7 @@ const AutosuggestTextarea = forwardRef(({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className='autosuggest-textarea'>
|
||||
<div className={classNames('autosuggest-textarea', className)}>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
className='autosuggest-textarea__textarea'
|
||||
|
|
114
app/javascript/mastodon/components/dropdown/index.tsx
Normal file
114
app/javascript/mastodon/components/dropdown/index.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -41,13 +41,16 @@ import { IconButton } from './icon_button';
|
|||
|
||||
let id = 0;
|
||||
|
||||
type RenderItemFn<Item = MenuItem> = (
|
||||
export interface RenderItemFnHandlers {
|
||||
onClick: React.MouseEventHandler;
|
||||
onKeyUp: React.KeyboardEventHandler;
|
||||
}
|
||||
|
||||
export type RenderItemFn<Item = MenuItem> = (
|
||||
item: Item,
|
||||
index: number,
|
||||
handlers: {
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
onKeyUp: (e: React.KeyboardEvent) => void;
|
||||
},
|
||||
handlers: RenderItemFnHandlers,
|
||||
focusRefCallback?: (c: HTMLAnchorElement | HTMLButtonElement | null) => void,
|
||||
) => React.ReactNode;
|
||||
|
||||
type ItemClickFn<Item = MenuItem> = (item: Item, index: number) => void;
|
||||
|
@ -173,7 +176,7 @@ export const DropdownMenu = <Item = MenuItem,>({
|
|||
onItemClick(item, i);
|
||||
} else if (isActionItem(item)) {
|
||||
e.preventDefault();
|
||||
item.action();
|
||||
item.action(e);
|
||||
}
|
||||
},
|
||||
[onClose, onItemClick, items],
|
||||
|
@ -277,10 +280,15 @@ export const DropdownMenu = <Item = MenuItem,>({
|
|||
})}
|
||||
>
|
||||
{items.map((option, i) =>
|
||||
renderItemMethod(option, i, {
|
||||
onClick: handleItemClick,
|
||||
onKeyUp: handleItemKeyUp,
|
||||
}),
|
||||
renderItemMethod(
|
||||
option,
|
||||
i,
|
||||
{
|
||||
onClick: handleItemClick,
|
||||
onKeyUp: handleItemKeyUp,
|
||||
},
|
||||
i === 0 ? handleFocusedItemRef : undefined,
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
|
@ -307,7 +315,9 @@ interface DropdownProps<Item = MenuItem> {
|
|||
forceDropdown?: boolean;
|
||||
renderItem?: RenderItemFn<Item>;
|
||||
renderHeader?: RenderHeaderFn<Item>;
|
||||
onOpen?: () => void;
|
||||
onOpen?: // Must use a union type for the full function as a union with void is not allowed.
|
||||
| ((event: React.MouseEvent | React.KeyboardEvent) => void)
|
||||
| ((event: React.MouseEvent | React.KeyboardEvent) => boolean);
|
||||
onItemClick?: ItemClickFn<Item>;
|
||||
}
|
||||
|
||||
|
@ -376,7 +386,7 @@ export const Dropdown = <Item = MenuItem,>({
|
|||
onItemClick(item, i);
|
||||
} else if (isActionItem(item)) {
|
||||
e.preventDefault();
|
||||
item.action();
|
||||
item.action(e);
|
||||
}
|
||||
},
|
||||
[handleClose, onItemClick, items],
|
||||
|
@ -389,7 +399,10 @@ export const Dropdown = <Item = MenuItem,>({
|
|||
if (open) {
|
||||
handleClose();
|
||||
} else {
|
||||
onOpen?.();
|
||||
const allow = onOpen?.(e);
|
||||
if (allow === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (prefetchAccountId) {
|
||||
dispatch(fetchRelationships([prefetchAccountId]));
|
||||
|
|
|
@ -13,8 +13,8 @@ const listenerOptions = supportsPassiveEvents
|
|||
? { passive: true, capture: true }
|
||||
: true;
|
||||
|
||||
export interface SelectItem {
|
||||
value: string;
|
||||
export interface SelectItem<Value extends string = string> {
|
||||
value: Value;
|
||||
icon?: string;
|
||||
iconComponent?: IconProp;
|
||||
text: string;
|
||||
|
@ -24,7 +24,7 @@ export interface SelectItem {
|
|||
|
||||
interface Props {
|
||||
value: string;
|
||||
classNamePrefix: string;
|
||||
classNamePrefix?: string;
|
||||
style?: React.CSSProperties;
|
||||
items: SelectItem[];
|
||||
onChange: (value: string) => void;
|
||||
|
@ -98,13 +98,13 @@ export const DropdownSelector: React.FC<Props> = ({
|
|||
break;
|
||||
case 'Tab':
|
||||
if (e.shiftKey) {
|
||||
element =
|
||||
nodeRef.current?.children[index + 1] ??
|
||||
nodeRef.current?.firstElementChild;
|
||||
} else {
|
||||
element =
|
||||
nodeRef.current?.children[index - 1] ??
|
||||
nodeRef.current?.lastElementChild;
|
||||
} else {
|
||||
element =
|
||||
nodeRef.current?.children[index + 1] ??
|
||||
nodeRef.current?.firstElementChild;
|
||||
}
|
||||
break;
|
||||
case 'Home':
|
||||
|
|
|
@ -7,11 +7,7 @@ import { normalizeKey, isKeyboardEvent } from './utils';
|
|||
* the hotkey with a higher priority is selected. All others
|
||||
* are ignored.
|
||||
*/
|
||||
const hotkeyPriority = {
|
||||
singleKey: 0,
|
||||
combo: 1,
|
||||
sequence: 2,
|
||||
} as const;
|
||||
const hotkeyPriority = { singleKey: 0, combo: 1, sequence: 2 } as const;
|
||||
|
||||
/**
|
||||
* This type of function receives a keyboard event and an array of
|
||||
|
@ -105,14 +101,16 @@ const hotkeyMatcherMap = {
|
|||
new: just('n'),
|
||||
forceNew: optionPlus('n'),
|
||||
focusColumn: any('1', '2', '3', '4', '5', '6', '7', '8', '9'),
|
||||
focusLoadMore: just('l'),
|
||||
reply: just('r'),
|
||||
favourite: just('f'),
|
||||
boost: just('b'),
|
||||
quote: just('q'),
|
||||
mention: just('m'),
|
||||
open: any('enter', 'o'),
|
||||
openProfile: just('p'),
|
||||
moveDown: any('down', 'j'),
|
||||
moveUp: any('up', 'k'),
|
||||
moveDown: just('j'),
|
||||
moveUp: just('k'),
|
||||
toggleHidden: just('x'),
|
||||
toggleSensitive: just('h'),
|
||||
toggleComposeSpoilers: optionPlus('x'),
|
||||
|
|
63
app/javascript/mastodon/components/learn_more_link.tsx
Normal file
63
app/javascript/mastodon/components/learn_more_link.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -10,6 +10,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
|||
|
||||
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
||||
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
|
||||
import CancelFillIcon from '@/material-icons/400-24px/cancel-fill.svg?react';
|
||||
import { Hotkeys } from 'mastodon/components/hotkeys';
|
||||
import { ContentWarning } from 'mastodon/components/content_warning';
|
||||
import { FilterWarning } from 'mastodon/components/filter_warning';
|
||||
|
@ -34,6 +35,8 @@ import StatusActionBar from './status_action_bar';
|
|||
import StatusContent from './status_content';
|
||||
import { StatusThreadLabel } from './status_thread_label';
|
||||
import { VisibilityIcon } from './visibility_icon';
|
||||
import { IconButton } from './icon_button';
|
||||
|
||||
const domParser = new DOMParser();
|
||||
|
||||
export const textForScreenReader = (intl, status, rebloggedByText = false) => {
|
||||
|
@ -75,6 +78,7 @@ const messages = defineMessages({
|
|||
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers' },
|
||||
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Specific people' },
|
||||
edited: { id: 'status.edited', defaultMessage: 'Edited {date}' },
|
||||
quote_cancel: { id: 'status.quote.cancel', defaultMessage: 'Cancel quote' },
|
||||
});
|
||||
|
||||
class Status extends ImmutablePureComponent {
|
||||
|
@ -92,6 +96,7 @@ class Status extends ImmutablePureComponent {
|
|||
onReply: PropTypes.func,
|
||||
onFavourite: PropTypes.func,
|
||||
onReblog: PropTypes.func,
|
||||
onQuote: PropTypes.func,
|
||||
onDelete: PropTypes.func,
|
||||
onDirect: PropTypes.func,
|
||||
onMention: PropTypes.func,
|
||||
|
@ -106,11 +111,10 @@ class Status extends ImmutablePureComponent {
|
|||
onToggleCollapsed: PropTypes.func,
|
||||
onTranslate: PropTypes.func,
|
||||
onInteractionModal: PropTypes.func,
|
||||
onQuoteCancel: PropTypes.func,
|
||||
muted: PropTypes.bool,
|
||||
hidden: PropTypes.bool,
|
||||
unread: PropTypes.bool,
|
||||
onMoveUp: PropTypes.func,
|
||||
onMoveDown: PropTypes.func,
|
||||
showThread: PropTypes.bool,
|
||||
isQuotedPost: PropTypes.bool,
|
||||
getScrollPosition: PropTypes.func,
|
||||
|
@ -126,6 +130,7 @@ class Status extends ImmutablePureComponent {
|
|||
inUse: PropTypes.bool,
|
||||
available: PropTypes.bool,
|
||||
}),
|
||||
contextType: PropTypes.string,
|
||||
...WithOptionalRouterPropTypes,
|
||||
};
|
||||
|
||||
|
@ -272,6 +277,10 @@ class Status extends ImmutablePureComponent {
|
|||
this.props.onReblog(this._properStatus(), e);
|
||||
};
|
||||
|
||||
handleHotkeyQuote = () => {
|
||||
this.props.onQuote(this._properStatus());
|
||||
};
|
||||
|
||||
handleHotkeyMention = e => {
|
||||
e.preventDefault();
|
||||
this.props.onMention(this._properStatus().get('account'));
|
||||
|
@ -322,14 +331,6 @@ class Status extends ImmutablePureComponent {
|
|||
history.push(`/@${status.getIn(['account', 'acct'])}`);
|
||||
};
|
||||
|
||||
handleHotkeyMoveUp = e => {
|
||||
this.props.onMoveUp?.(this.props.status.get('id'), this.node.getAttribute('data-featured'));
|
||||
};
|
||||
|
||||
handleHotkeyMoveDown = e => {
|
||||
this.props.onMoveDown?.(this.props.status.get('id'), this.node.getAttribute('data-featured'));
|
||||
};
|
||||
|
||||
handleHotkeyToggleHidden = () => {
|
||||
const { onToggleHidden } = this.props;
|
||||
const status = this._properStatus();
|
||||
|
@ -359,6 +360,10 @@ class Status extends ImmutablePureComponent {
|
|||
this.setState(state => ({ ...state, showDespiteFilter: !state.showDespiteFilter }));
|
||||
};
|
||||
|
||||
handleQuoteCancel = () => {
|
||||
this.props.onQuoteCancel?.();
|
||||
}
|
||||
|
||||
_properStatus () {
|
||||
const { status } = this.props;
|
||||
|
||||
|
@ -386,11 +391,10 @@ class Status extends ImmutablePureComponent {
|
|||
reply: this.handleHotkeyReply,
|
||||
favourite: this.handleHotkeyFavourite,
|
||||
boost: this.handleHotkeyBoost,
|
||||
quote: this.handleHotkeyQuote,
|
||||
mention: this.handleHotkeyMention,
|
||||
open: this.handleHotkeyOpen,
|
||||
openProfile: this.handleHotkeyOpenProfile,
|
||||
moveUp: this.handleHotkeyMoveUp,
|
||||
moveDown: this.handleHotkeyMoveDown,
|
||||
toggleHidden: this.handleHotkeyToggleHidden,
|
||||
toggleSensitive: this.handleHotkeyToggleSensitive,
|
||||
openMedia: this.handleHotkeyOpenMedia,
|
||||
|
@ -573,6 +577,16 @@ class Status extends ImmutablePureComponent {
|
|||
|
||||
<DisplayName account={status.get('account')} />
|
||||
</Link>
|
||||
|
||||
{isQuotedPost && !!this.props.onQuoteCancel && (
|
||||
<IconButton
|
||||
onClick={this.handleQuoteCancel}
|
||||
className='status__quote-cancel'
|
||||
title={intl.formatMessage(messages.quote_cancel)}
|
||||
icon="cancel-fill"
|
||||
iconComponent={CancelFillIcon}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{matchedFilters && <FilterWarning title={matchedFilters.join(', ')} expanded={this.state.showDespiteFilter} onClick={this.handleFilterToggle} />}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
),
|
||||
};
|
425
app/javascript/mastodon/components/status/reblog_button.tsx
Normal file
425
app/javascript/mastodon/components/status/reblog_button.tsx
Normal 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;
|
||||
}
|
|
@ -2,7 +2,6 @@ import PropTypes from 'prop-types';
|
|||
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
|
@ -12,15 +11,10 @@ import { connect } from 'react-redux';
|
|||
import BookmarkIcon from '@/material-icons/400-24px/bookmark-fill.svg?react';
|
||||
import BookmarkBorderIcon from '@/material-icons/400-24px/bookmark.svg?react';
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
|
||||
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
|
||||
import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react';
|
||||
import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
|
||||
import StarBorderIcon from '@/material-icons/400-24px/star.svg?react';
|
||||
import RepeatActiveIcon from '@/svg-icons/repeat_active.svg?react';
|
||||
import RepeatDisabledIcon from '@/svg-icons/repeat_disabled.svg?react';
|
||||
import RepeatPrivateIcon from '@/svg-icons/repeat_private.svg?react';
|
||||
import RepeatPrivateActiveIcon from '@/svg-icons/repeat_private_active.svg?react';
|
||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
||||
import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/permissions';
|
||||
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
|
||||
|
@ -29,6 +23,8 @@ import { Dropdown } from 'mastodon/components/dropdown_menu';
|
|||
import { me } from '../initial_state';
|
||||
|
||||
import { IconButton } from './icon_button';
|
||||
import { isFeatureEnabled } from '../utils/environment';
|
||||
import { ReblogButton } from './status/reblog_button';
|
||||
|
||||
const messages = defineMessages({
|
||||
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
||||
|
@ -42,10 +38,6 @@ const messages = defineMessages({
|
|||
share: { id: 'status.share', defaultMessage: 'Share' },
|
||||
more: { id: 'status.more', defaultMessage: 'More' },
|
||||
replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
|
||||
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
|
||||
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
|
||||
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
|
||||
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
|
||||
favourite: { id: 'status.favourite', defaultMessage: 'Favorite' },
|
||||
removeFavourite: { id: 'status.remove_favourite', defaultMessage: 'Remove from favorites' },
|
||||
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
|
||||
|
@ -67,21 +59,29 @@ const messages = defineMessages({
|
|||
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
|
||||
filter: { id: 'status.filter', defaultMessage: 'Filter this post' },
|
||||
openOriginalPage: { id: 'account.open_original_page', defaultMessage: 'Open original page' },
|
||||
revokeQuote: { id: 'status.revoke_quote', defaultMessage: 'Remove my post from @{name}’s post' },
|
||||
quotePolicyChange: { id: 'status.quote_policy_change', defaultMessage: 'Change who can quote' },
|
||||
});
|
||||
|
||||
const mapStateToProps = (state, { status }) => ({
|
||||
relationship: state.getIn(['relationships', status.getIn(['account', 'id'])]),
|
||||
});
|
||||
const mapStateToProps = (state, { status }) => {
|
||||
const quotedStatusId = status.getIn(['quote', 'quoted_status']);
|
||||
return ({
|
||||
relationship: state.getIn(['relationships', status.getIn(['account', 'id'])]),
|
||||
quotedAccountId: quotedStatusId ? state.getIn(['statuses', quotedStatusId, 'account']) : null,
|
||||
});
|
||||
};
|
||||
|
||||
class StatusActionBar extends ImmutablePureComponent {
|
||||
static propTypes = {
|
||||
identity: identityContextPropShape,
|
||||
status: ImmutablePropTypes.map.isRequired,
|
||||
relationship: ImmutablePropTypes.record,
|
||||
quotedAccountId: PropTypes.string,
|
||||
onReply: PropTypes.func,
|
||||
onFavourite: PropTypes.func,
|
||||
onReblog: PropTypes.func,
|
||||
onDelete: PropTypes.func,
|
||||
onRevokeQuote: PropTypes.func,
|
||||
onQuotePolicyChange: PropTypes.func,
|
||||
onDirect: PropTypes.func,
|
||||
onMention: PropTypes.func,
|
||||
onMute: PropTypes.func,
|
||||
|
@ -110,6 +110,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
updateOnProps = [
|
||||
'status',
|
||||
'relationship',
|
||||
'quotedAccountId',
|
||||
'withDismiss',
|
||||
];
|
||||
|
||||
|
@ -141,16 +142,6 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
}
|
||||
};
|
||||
|
||||
handleReblogClick = e => {
|
||||
const { signedIn } = this.props.identity;
|
||||
|
||||
if (signedIn) {
|
||||
this.props.onReblog(this.props.status, e);
|
||||
} else {
|
||||
this.props.onInteractionModal('reblog', this.props.status);
|
||||
}
|
||||
};
|
||||
|
||||
handleBookmarkClick = () => {
|
||||
this.props.onBookmark(this.props.status);
|
||||
};
|
||||
|
@ -190,6 +181,14 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
}
|
||||
};
|
||||
|
||||
handleRevokeQuoteClick = () => {
|
||||
this.props.onRevokeQuote(this.props.status);
|
||||
};
|
||||
|
||||
handleQuotePolicyChange = () => {
|
||||
this.props.onQuotePolicyChange(this.props.status);
|
||||
};
|
||||
|
||||
handleBlockClick = () => {
|
||||
const { status, relationship, onBlock, onUnblock } = this.props;
|
||||
const account = status.get('account');
|
||||
|
@ -241,7 +240,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
};
|
||||
|
||||
render () {
|
||||
const { status, relationship, intl, withDismiss, withCounters, scrollKey } = this.props;
|
||||
const { status, relationship, quotedAccountId, intl, withDismiss, withCounters, scrollKey } = this.props;
|
||||
const { signedIn, permissions } = this.props.identity;
|
||||
|
||||
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
|
||||
|
@ -279,6 +278,9 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
|
||||
if (writtenByMe || withDismiss) {
|
||||
menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
|
||||
if (writtenByMe && isFeatureEnabled('outgoing_quotes') && !['private', 'direct'].includes(status.get('visibility'))) {
|
||||
menu.push({ text: intl.formatMessage(messages.quotePolicyChange), action: this.handleQuotePolicyChange });
|
||||
}
|
||||
menu.push(null);
|
||||
}
|
||||
|
||||
|
@ -291,6 +293,10 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
menu.push({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.handleDirectClick });
|
||||
menu.push(null);
|
||||
|
||||
if (quotedAccountId === me) {
|
||||
menu.push({ text: intl.formatMessage(messages.revokeQuote, { name: account.get('username') }), action: this.handleRevokeQuoteClick, dangerous: true });
|
||||
}
|
||||
|
||||
if (relationship && relationship.get('muting')) {
|
||||
menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.handleMuteClick });
|
||||
} else {
|
||||
|
@ -351,25 +357,6 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
replyTitle = intl.formatMessage(messages.replyAll);
|
||||
}
|
||||
|
||||
const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private';
|
||||
|
||||
let reblogTitle, reblogIconComponent;
|
||||
|
||||
if (status.get('reblogged')) {
|
||||
reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
|
||||
reblogIconComponent = publicStatus ? RepeatActiveIcon : RepeatPrivateActiveIcon;
|
||||
} else if (publicStatus) {
|
||||
reblogTitle = intl.formatMessage(messages.reblog);
|
||||
reblogIconComponent = RepeatIcon;
|
||||
} else if (reblogPrivate) {
|
||||
reblogTitle = intl.formatMessage(messages.reblog_private);
|
||||
reblogIconComponent = RepeatPrivateIcon;
|
||||
} else {
|
||||
reblogTitle = intl.formatMessage(messages.cannot_reblog);
|
||||
reblogIconComponent = RepeatDisabledIcon;
|
||||
}
|
||||
|
||||
|
||||
const bookmarkTitle = intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark);
|
||||
const favouriteTitle = intl.formatMessage(status.get('favourited') ? messages.removeFavourite : messages.favourite);
|
||||
const isReply = status.get('in_reply_to_account_id') === status.getIn(['account', 'id']);
|
||||
|
@ -380,7 +367,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
<IconButton className='status__action-bar__button' title={replyTitle} icon={isReply ? 'reply' : replyIcon} iconComponent={isReply ? ReplyIcon : replyIconComponent} onClick={this.handleReplyClick} counter={status.get('replies_count')} />
|
||||
</div>
|
||||
<div className='status__action-bar__button-wrapper'>
|
||||
<IconButton className={classNames('status__action-bar__button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' iconComponent={reblogIconComponent} onClick={this.handleReblogClick} counter={withCounters ? status.get('reblogs_count') : undefined} />
|
||||
<ReblogButton status={status} counters={withCounters} />
|
||||
</div>
|
||||
<div className='status__action-bar__button-wrapper'>
|
||||
<IconButton className='status__action-bar__button star-icon' animate active={status.get('favourited')} title={favouriteTitle} icon='star' iconComponent={status.get('favourited') ? StarIcon : StarBorderIcon} onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} />
|
||||
|
|
|
@ -138,6 +138,16 @@ class StatusContent extends PureComponent {
|
|||
|
||||
onCollapsedToggle(collapsed);
|
||||
}
|
||||
|
||||
// Remove quote fallback link from the DOM so it doesn't
|
||||
// mess with paragraph margins
|
||||
if (!!status.get('quote')) {
|
||||
const inlineQuote = node.querySelector('.quote-inline');
|
||||
|
||||
if (inlineQuote) {
|
||||
inlineQuote.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleMouseEnter = ({ currentTarget }) => {
|
||||
|
|
|
@ -14,6 +14,7 @@ import { StatusQuoteManager } from '../components/status_quoted';
|
|||
import { LoadGap } from './load_gap';
|
||||
import ScrollableList from './scrollable_list';
|
||||
|
||||
|
||||
export default class StatusList extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
|
@ -40,84 +41,6 @@ export default class StatusList extends ImmutablePureComponent {
|
|||
trackScroll: true,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.columnHeaderHeight = this.node?.node
|
||||
? parseFloat(
|
||||
getComputedStyle(this.node.node).getPropertyValue('--column-header-height')
|
||||
) || 0
|
||||
: 0;
|
||||
}
|
||||
|
||||
getFeaturedStatusCount = () => {
|
||||
return this.props.featuredStatusIds ? this.props.featuredStatusIds.size : 0;
|
||||
};
|
||||
|
||||
getCurrentStatusIndex = (id, featured) => {
|
||||
if (featured) {
|
||||
return this.props.featuredStatusIds.indexOf(id);
|
||||
} else {
|
||||
return this.props.statusIds.indexOf(id) + this.getFeaturedStatusCount();
|
||||
}
|
||||
};
|
||||
|
||||
handleMoveUp = (id, featured) => {
|
||||
const index = this.getCurrentStatusIndex(id, featured);
|
||||
this._selectChild(id, index, -1);
|
||||
};
|
||||
|
||||
handleMoveDown = (id, featured) => {
|
||||
const index = this.getCurrentStatusIndex(id, featured);
|
||||
this._selectChild(id, index, 1);
|
||||
};
|
||||
|
||||
_selectChild = (id, index, direction) => {
|
||||
const listContainer = this.node?.node;
|
||||
let listItem = listContainer?.querySelector(
|
||||
// :nth-child uses 1-based indexing
|
||||
`.item-list > :nth-child(${index + 1 + direction})`
|
||||
);
|
||||
|
||||
if (!listItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If selected container element is empty, we skip it
|
||||
if (listItem.matches(':empty')) {
|
||||
this._selectChild(id, index + direction, direction);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the list item is a post
|
||||
let targetElement = listItem.querySelector('.focusable');
|
||||
|
||||
// Otherwise, check if the item contains follow suggestions or
|
||||
// is a 'load more' button.
|
||||
if (
|
||||
!targetElement && (
|
||||
listItem.querySelector('.inline-follow-suggestions') ||
|
||||
listItem.matches('.load-more')
|
||||
)
|
||||
) {
|
||||
targetElement = listItem;
|
||||
}
|
||||
|
||||
if (targetElement) {
|
||||
const elementRect = targetElement.getBoundingClientRect();
|
||||
|
||||
const isFullyVisible =
|
||||
elementRect.top >= this.columnHeaderHeight &&
|
||||
elementRect.bottom <= window.innerHeight;
|
||||
|
||||
if (!isFullyVisible) {
|
||||
targetElement.scrollIntoView({
|
||||
block: direction === 1 ? 'start' : 'center',
|
||||
});
|
||||
}
|
||||
|
||||
targetElement.focus();
|
||||
}
|
||||
}
|
||||
|
||||
handleLoadOlder = debounce(() => {
|
||||
const { statusIds, lastId, onLoadMore } = this.props;
|
||||
onLoadMore(lastId || (statusIds.size > 0 ? statusIds.last() : undefined));
|
||||
|
@ -158,8 +81,6 @@ export default class StatusList extends ImmutablePureComponent {
|
|||
<StatusQuoteManager
|
||||
key={statusId}
|
||||
id={statusId}
|
||||
onMoveUp={this.handleMoveUp}
|
||||
onMoveDown={this.handleMoveDown}
|
||||
contextType={timelineId}
|
||||
scrollKey={this.props.scrollKey}
|
||||
showThread
|
||||
|
@ -176,8 +97,6 @@ export default class StatusList extends ImmutablePureComponent {
|
|||
key={`f-${statusId}`}
|
||||
id={statusId}
|
||||
featured
|
||||
onMoveUp={this.handleMoveUp}
|
||||
onMoveDown={this.handleMoveDown}
|
||||
contextType={timelineId}
|
||||
showThread
|
||||
withCounters={this.props.withCounters}
|
||||
|
@ -191,5 +110,4 @@ export default class StatusList extends ImmutablePureComponent {
|
|||
</ScrollableList>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -3,19 +3,15 @@ import { useEffect, useMemo } from 'react';
|
|||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import type { Map as ImmutableMap } from 'immutable';
|
||||
|
||||
import ArticleIcon from '@/material-icons/400-24px/article.svg?react';
|
||||
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { LearnMoreLink } from 'mastodon/components/learn_more_link';
|
||||
import StatusContainer from 'mastodon/containers/status_container';
|
||||
import type { Status } from 'mastodon/models/status';
|
||||
import type { RootState } from 'mastodon/store';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
import QuoteIcon from '../../images/quote.svg?react';
|
||||
import { fetchStatus } from '../actions/statuses';
|
||||
import { makeGetStatus } from '../selectors';
|
||||
|
||||
|
@ -31,41 +27,31 @@ const QuoteWrapper: React.FC<{
|
|||
'status__quote--error': isError,
|
||||
})}
|
||||
>
|
||||
<Icon id='quote' icon={QuoteIcon} className='status__quote-icon' />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const NestedQuoteLink: React.FC<{
|
||||
status: Status;
|
||||
}> = ({ status }) => {
|
||||
const NestedQuoteLink: React.FC<{ status: Status }> = ({ status }) => {
|
||||
const accountId = status.get('account') as string;
|
||||
const account = useAppSelector((state) =>
|
||||
accountId ? state.accounts.get(accountId) : undefined,
|
||||
);
|
||||
|
||||
const quoteAuthorName = account?.display_name_html;
|
||||
const quoteAuthorName = account?.acct;
|
||||
|
||||
if (!quoteAuthorName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const quoteAuthorElement = (
|
||||
<span dangerouslySetInnerHTML={{ __html: quoteAuthorName }} />
|
||||
);
|
||||
const quoteUrl = `/@${account.get('acct')}/${status.get('id') as string}`;
|
||||
|
||||
return (
|
||||
<Link to={quoteUrl} className='status__quote-author-button'>
|
||||
<div className='status__quote-author-button'>
|
||||
<FormattedMessage
|
||||
id='status.quote_post_author'
|
||||
defaultMessage='Post by {name}'
|
||||
values={{ name: quoteAuthorElement }}
|
||||
defaultMessage='Quoted a post by @{name}'
|
||||
values={{ name: quoteAuthorName }}
|
||||
/>
|
||||
<Icon id='chevron_right' icon={ChevronRightIcon} />
|
||||
<Icon id='article' icon={ArticleIcon} />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -75,24 +61,47 @@ type GetStatusSelector = (
|
|||
props: { id?: string | null; contextType?: string },
|
||||
) => Status | null;
|
||||
|
||||
export const QuotedStatus: React.FC<{
|
||||
interface QuotedStatusProps {
|
||||
quote: QuoteMap;
|
||||
contextType?: string;
|
||||
parentQuotePostId?: string | null;
|
||||
variant?: 'full' | 'link';
|
||||
nestingLevel?: number;
|
||||
}> = ({ quote, contextType, nestingLevel = 1, variant = 'full' }) => {
|
||||
onQuoteCancel?: () => void; // Used for composer.
|
||||
}
|
||||
|
||||
export const QuotedStatus: React.FC<QuotedStatusProps> = ({
|
||||
quote,
|
||||
contextType,
|
||||
parentQuotePostId,
|
||||
nestingLevel = 1,
|
||||
variant = 'full',
|
||||
onQuoteCancel,
|
||||
}) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const quoteState = useAppSelector((state) =>
|
||||
parentQuotePostId
|
||||
? state.statuses.getIn([parentQuotePostId, 'quote', 'state'])
|
||||
: quote.get('state'),
|
||||
);
|
||||
|
||||
const quotedStatusId = quote.get('quoted_status');
|
||||
const quoteState = quote.get('state');
|
||||
const status = useAppSelector((state) =>
|
||||
quotedStatusId ? state.statuses.get(quotedStatusId) : undefined,
|
||||
);
|
||||
|
||||
const shouldLoadQuote = !status?.get('isLoading') && quoteState !== 'deleted';
|
||||
|
||||
useEffect(() => {
|
||||
if (!status && quotedStatusId) {
|
||||
dispatch(fetchStatus(quotedStatusId));
|
||||
if (shouldLoadQuote && quotedStatusId) {
|
||||
dispatch(
|
||||
fetchStatus(quotedStatusId, {
|
||||
parentQuotePostId,
|
||||
alsoFetchContext: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, [status, quotedStatusId, dispatch]);
|
||||
}, [shouldLoadQuote, quotedStatusId, parentQuotePostId, dispatch]);
|
||||
|
||||
// In order to find out whether the quoted post should be completely hidden
|
||||
// due to a matching filter, we run it through the selector used by `status_container`.
|
||||
|
@ -112,39 +121,42 @@ export const QuotedStatus: React.FC<{
|
|||
defaultMessage='Hidden due to one of your filters'
|
||||
/>
|
||||
);
|
||||
} else if (quoteState === 'deleted') {
|
||||
quoteError = (
|
||||
<FormattedMessage
|
||||
id='status.quote_error.removed'
|
||||
defaultMessage='This post was removed by its author.'
|
||||
/>
|
||||
);
|
||||
} else if (quoteState === 'unauthorized') {
|
||||
quoteError = (
|
||||
<FormattedMessage
|
||||
id='status.quote_error.unauthorized'
|
||||
defaultMessage='This post cannot be displayed as you are not authorized to view it.'
|
||||
/>
|
||||
);
|
||||
} else if (quoteState === 'pending') {
|
||||
quoteError = (
|
||||
<FormattedMessage
|
||||
id='status.quote_error.pending_approval'
|
||||
defaultMessage='This post is pending approval from the original author.'
|
||||
/>
|
||||
<>
|
||||
<FormattedMessage
|
||||
id='status.quote_error.pending_approval'
|
||||
defaultMessage='Post pending'
|
||||
/>
|
||||
|
||||
<LearnMoreLink>
|
||||
<h6>
|
||||
<FormattedMessage
|
||||
id='status.quote_error.pending_approval_popout.title'
|
||||
defaultMessage='Pending quote? Remain calm'
|
||||
/>
|
||||
</h6>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id='status.quote_error.pending_approval_popout.body'
|
||||
defaultMessage='Quotes shared across the Fediverse may take time to display, as different servers have different protocols.'
|
||||
/>
|
||||
</p>
|
||||
</LearnMoreLink>
|
||||
</>
|
||||
);
|
||||
} else if (quoteState === 'rejected' || quoteState === 'revoked') {
|
||||
} else if (
|
||||
!status ||
|
||||
!quotedStatusId ||
|
||||
quoteState === 'deleted' ||
|
||||
quoteState === 'rejected' ||
|
||||
quoteState === 'revoked' ||
|
||||
quoteState === 'unauthorized'
|
||||
) {
|
||||
quoteError = (
|
||||
<FormattedMessage
|
||||
id='status.quote_error.rejected'
|
||||
defaultMessage='This post cannot be displayed as the original author does not allow it to be quoted.'
|
||||
/>
|
||||
);
|
||||
} else if (!status || !quotedStatusId) {
|
||||
quoteError = (
|
||||
<FormattedMessage
|
||||
id='status.quote_error.not_found'
|
||||
defaultMessage='This post cannot be displayed.'
|
||||
id='status.quote_error.not_available'
|
||||
defaultMessage='Post unavailable'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -168,11 +180,13 @@ export const QuotedStatus: React.FC<{
|
|||
isQuotedPost
|
||||
id={quotedStatusId}
|
||||
contextType={contextType}
|
||||
avatarSize={40}
|
||||
avatarSize={32}
|
||||
onQuoteCancel={onQuoteCancel}
|
||||
>
|
||||
{canRenderChildQuote && (
|
||||
<QuotedStatus
|
||||
quote={childQuote}
|
||||
parentQuotePostId={quotedStatusId}
|
||||
contextType={contextType}
|
||||
variant={
|
||||
nestingLevel === MAX_QUOTE_POSTS_NESTING_LEVEL ? 'link' : 'full'
|
||||
|
@ -208,7 +222,11 @@ export const StatusQuoteManager = (props: StatusQuoteManagerProps) => {
|
|||
if (quote) {
|
||||
return (
|
||||
<StatusContainer {...props}>
|
||||
<QuotedStatus quote={quote} contextType={props.contextType} />
|
||||
<QuotedStatus
|
||||
quote={quote}
|
||||
parentQuotePostId={status?.get('id') as string}
|
||||
contextType={props.contextType}
|
||||
/>
|
||||
</StatusContainer>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -12,6 +12,7 @@ import {
|
|||
mentionCompose,
|
||||
directCompose,
|
||||
} from '../actions/compose';
|
||||
import { quoteComposeById } from '../actions/compose_typed';
|
||||
import {
|
||||
initDomainBlockModal,
|
||||
unblockDomain,
|
||||
|
@ -41,10 +42,13 @@ import {
|
|||
translateStatus,
|
||||
undoStatusTranslation,
|
||||
} from '../actions/statuses';
|
||||
import { setStatusQuotePolicy } from '../actions/statuses_typed';
|
||||
import Status from '../components/status';
|
||||
import { deleteModal } from '../initial_state';
|
||||
import { makeGetStatus, makeGetPictureInPicture } from '../selectors';
|
||||
|
||||
import { isFeatureEnabled } from 'mastodon/utils/environment';
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const getStatus = makeGetStatus();
|
||||
const getPictureInPicture = makeGetPictureInPicture();
|
||||
|
@ -75,6 +79,12 @@ const mapDispatchToProps = (dispatch, { contextType }) => ({
|
|||
onReblog (status, e) {
|
||||
dispatch(toggleReblog(status.get('id'), e.shiftKey));
|
||||
},
|
||||
|
||||
onQuote (status) {
|
||||
if (isFeatureEnabled('outgoing_quotes')) {
|
||||
dispatch(quoteComposeById(status.get('id')));
|
||||
}
|
||||
},
|
||||
|
||||
onFavourite (status) {
|
||||
dispatch(toggleFavourite(status.get('id')));
|
||||
|
@ -107,10 +117,30 @@ const mapDispatchToProps = (dispatch, { contextType }) => ({
|
|||
if (!deleteModal) {
|
||||
dispatch(deleteStatus(status.get('id'), withRedraft));
|
||||
} else {
|
||||
dispatch(openModal({ modalType: 'CONFIRM_DELETE_STATUS', modalProps: { statusId: status.get('id'), withRedraft } }));
|
||||
dispatch(openModal({
|
||||
modalType: 'CONFIRM_DELETE_STATUS',
|
||||
modalProps: {
|
||||
statusId: status.get('id'),
|
||||
withRedraft
|
||||
}
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
onRevokeQuote (status) {
|
||||
dispatch(openModal({ modalType: 'CONFIRM_REVOKE_QUOTE', modalProps: { statusId: status.get('id'), quotedStatusId: status.getIn(['quote', 'quoted_status']) }}));
|
||||
},
|
||||
|
||||
onQuotePolicyChange(status) {
|
||||
const statusId = status.get('id');
|
||||
const handleChange = (_, quotePolicy) => {
|
||||
dispatch(
|
||||
setStatusQuotePolicy({ policy: quotePolicy, statusId }),
|
||||
);
|
||||
}
|
||||
dispatch(openModal({ modalType: 'COMPOSE_PRIVACY', modalProps: { statusId, onChange: handleChange } }));
|
||||
},
|
||||
|
||||
onEdit (status) {
|
||||
dispatch((_, getState) => {
|
||||
let state = getState();
|
||||
|
|
|
@ -15,10 +15,8 @@ import { missingAltTextModal } from 'mastodon/initial_state';
|
|||
import AutosuggestInput from 'mastodon/components/autosuggest_input';
|
||||
import AutosuggestTextarea from 'mastodon/components/autosuggest_textarea';
|
||||
import { Button } from 'mastodon/components/button';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
|
||||
import PollButtonContainer from '../containers/poll_button_container';
|
||||
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
|
||||
import SpoilerButtonContainer from '../containers/spoiler_button_container';
|
||||
import UploadButtonContainer from '../containers/upload_button_container';
|
||||
import { countableText } from '../util/counter';
|
||||
|
@ -31,6 +29,8 @@ import { PollForm } from "./poll_form";
|
|||
import { ReplyIndicator } from './reply_indicator';
|
||||
import { UploadForm } from './upload_form';
|
||||
import { Warning } from './warning';
|
||||
import { ComposeQuotedStatus } from './quoted_post';
|
||||
import { VisibilityButton } from './visibility_button';
|
||||
|
||||
const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d';
|
||||
|
||||
|
@ -73,6 +73,7 @@ class ComposeForm extends ImmutablePureComponent {
|
|||
singleColumn: PropTypes.bool,
|
||||
lang: PropTypes.string,
|
||||
maxChars: PropTypes.number,
|
||||
redirectOnSuccess: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
|
@ -255,62 +256,62 @@ class ComposeForm extends ImmutablePureComponent {
|
|||
<Warning />
|
||||
|
||||
<div className={classNames('compose-form__highlightable', { active: highlighted })} ref={this.setRef}>
|
||||
<div className='compose-form__scrollable'>
|
||||
<EditIndicator />
|
||||
<EditIndicator />
|
||||
|
||||
{this.props.spoiler && (
|
||||
<div className='spoiler-input'>
|
||||
<div className='spoiler-input__border' />
|
||||
|
||||
<AutosuggestInput
|
||||
placeholder={intl.formatMessage(messages.spoiler_placeholder)}
|
||||
value={this.props.spoilerText}
|
||||
disabled={isSubmitting}
|
||||
onChange={this.handleChangeSpoilerText}
|
||||
onKeyDown={this.handleKeyDownSpoiler}
|
||||
ref={this.setSpoilerText}
|
||||
suggestions={this.props.suggestions}
|
||||
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
||||
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
|
||||
onSuggestionSelected={this.onSpoilerSuggestionSelected}
|
||||
searchTokens={[':']}
|
||||
id='cw-spoiler-input'
|
||||
className='spoiler-input__input'
|
||||
lang={this.props.lang}
|
||||
spellCheck
|
||||
/>
|
||||
|
||||
<div className='spoiler-input__border' />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AutosuggestTextarea
|
||||
ref={this.textareaRef}
|
||||
placeholder={intl.formatMessage(messages.placeholder)}
|
||||
disabled={isSubmitting}
|
||||
value={this.props.text}
|
||||
onChange={this.handleChange}
|
||||
suggestions={this.props.suggestions}
|
||||
onFocus={this.handleFocus}
|
||||
onKeyDown={this.handleKeyDownPost}
|
||||
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
||||
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
|
||||
onSuggestionSelected={this.onSuggestionSelected}
|
||||
onPaste={onPaste}
|
||||
autoFocus={autoFocus}
|
||||
lang={this.props.lang}
|
||||
/>
|
||||
<div className='compose-form__dropdowns'>
|
||||
<VisibilityButton disabled={this.props.isEditing} />
|
||||
<LanguageDropdown />
|
||||
</div>
|
||||
|
||||
{this.props.spoiler && (
|
||||
<div className='spoiler-input'>
|
||||
<div className='spoiler-input__border' />
|
||||
|
||||
<AutosuggestInput
|
||||
placeholder={intl.formatMessage(messages.spoiler_placeholder)}
|
||||
value={this.props.spoilerText}
|
||||
disabled={isSubmitting}
|
||||
onChange={this.handleChangeSpoilerText}
|
||||
onKeyDown={this.handleKeyDownSpoiler}
|
||||
ref={this.setSpoilerText}
|
||||
suggestions={this.props.suggestions}
|
||||
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
||||
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
|
||||
onSuggestionSelected={this.onSpoilerSuggestionSelected}
|
||||
searchTokens={[':']}
|
||||
id='cw-spoiler-input'
|
||||
className='spoiler-input__input'
|
||||
lang={this.props.lang}
|
||||
spellCheck
|
||||
/>
|
||||
|
||||
<div className='spoiler-input__border' />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AutosuggestTextarea
|
||||
ref={this.textareaRef}
|
||||
placeholder={intl.formatMessage(messages.placeholder)}
|
||||
disabled={isSubmitting}
|
||||
value={this.props.text}
|
||||
onChange={this.handleChange}
|
||||
suggestions={this.props.suggestions}
|
||||
onFocus={this.handleFocus}
|
||||
onKeyDown={this.handleKeyDownPost}
|
||||
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
||||
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
|
||||
onSuggestionSelected={this.onSuggestionSelected}
|
||||
onPaste={onPaste}
|
||||
autoFocus={autoFocus}
|
||||
lang={this.props.lang}
|
||||
className='compose-form__input'
|
||||
/>
|
||||
|
||||
<UploadForm />
|
||||
<PollForm />
|
||||
<ComposeQuotedStatus />
|
||||
|
||||
<div className='compose-form__footer'>
|
||||
<div className='compose-form__dropdowns'>
|
||||
<PrivacyDropdownContainer disabled={this.props.isEditing} />
|
||||
<LanguageDropdown />
|
||||
</div>
|
||||
|
||||
<div className='compose-form__actions'>
|
||||
<div className='compose-form__buttons'>
|
||||
<UploadButtonContainer />
|
||||
|
@ -329,7 +330,7 @@ class ComposeForm extends ImmutablePureComponent {
|
|||
>
|
||||
{intl.formatMessage(
|
||||
this.props.isEditing ?
|
||||
messages.saveChanges :
|
||||
messages.saveChanges :
|
||||
(this.props.isInReply ? messages.reply : messages.publish)
|
||||
)}
|
||||
</Button>
|
||||
|
|
|
@ -396,7 +396,7 @@ export const LanguageDropdown: React.FC = () => {
|
|||
warning: guess !== '' && guess !== value,
|
||||
})}
|
||||
>
|
||||
<Icon id='' icon={TranslateIcon} />
|
||||
<Icon id='translate' icon={TranslateIcon} />
|
||||
<span className='dropdown-button__label'>{current[2] ?? value}</span>
|
||||
</button>
|
||||
|
||||
|
|
|
@ -14,7 +14,7 @@ import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react';
|
|||
import { DropdownSelector } from 'mastodon/components/dropdown_selector';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
|
||||
const messages = defineMessages({
|
||||
export const messages = defineMessages({
|
||||
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
|
||||
public_long: { id: 'privacy.public.long', defaultMessage: 'Anyone on and off Mastodon' },
|
||||
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Quiet public' },
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -25,7 +25,7 @@ export const ReplyIndicator = () => {
|
|||
<div className='reply-indicator__line' />
|
||||
|
||||
<Link to={`/@${account.get('acct')}`} className='detailed-status__display-avatar'>
|
||||
<Avatar account={account} size={46} />
|
||||
<Avatar key={`avatar-${account.get('id')}`} account={account} size={46} />
|
||||
</Link>
|
||||
|
||||
<div className='reply-indicator__main'>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -34,7 +34,7 @@ const mapStateToProps = state => ({
|
|||
maxChars: state.getIn(['server', 'server', 'configuration', 'statuses', 'max_characters'], 500),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
const mapDispatchToProps = (dispatch, props) => ({
|
||||
|
||||
onChange (text) {
|
||||
dispatch(changeCompose(text));
|
||||
|
@ -47,7 +47,11 @@ const mapDispatchToProps = (dispatch) => ({
|
|||
modalProps: {},
|
||||
}));
|
||||
} else {
|
||||
dispatch(submitCompose());
|
||||
dispatch(submitCompose((status) => {
|
||||
if (props.redirectOnSuccess) {
|
||||
window.location.assign(status.url);
|
||||
}
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
@ -3,10 +3,16 @@ import { connect } from 'react-redux';
|
|||
import { addPoll, removePoll } from '../../../actions/compose';
|
||||
import PollButton from '../components/poll_button';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 0),
|
||||
active: state.getIn(['compose', 'poll']) !== null,
|
||||
});
|
||||
const mapStateToProps = state => {
|
||||
const readyAttachmentsSize = state.compose.get('media_attachments').size ?? 0;
|
||||
const hasAttachments = readyAttachmentsSize > 0 || !!state.compose.get('is_uploading');
|
||||
const hasQuote = !!state.compose.get('quoted_status_id');
|
||||
|
||||
return ({
|
||||
disabled: hasAttachments || hasQuote,
|
||||
active: state.getIn(['compose', 'poll']) !== null,
|
||||
});
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
|
||||
|
|
|
@ -11,9 +11,10 @@ const mapStateToProps = state => {
|
|||
const attachmentsSize = readyAttachmentsSize + pendingAttachmentsSize;
|
||||
const isOverLimit = attachmentsSize > state.getIn(['server', 'server', 'configuration', 'statuses', 'max_media_attachments'])-1;
|
||||
const hasVideoOrAudio = state.getIn(['compose', 'media_attachments']).some(m => ['video', 'audio'].includes(m.get('type')));
|
||||
const hasQuote = !!state.compose.get('quoted_status_id');
|
||||
|
||||
return {
|
||||
disabled: isPoll || isUploading || isOverLimit || hasVideoOrAudio,
|
||||
disabled: isPoll || isUploading || isOverLimit || hasVideoOrAudio || hasQuote,
|
||||
resetFileKey: state.getIn(['compose', 'resetFileKey']),
|
||||
};
|
||||
};
|
||||
|
|
|
@ -45,7 +45,7 @@ const getAccounts = createSelector(
|
|||
|
||||
const getStatus = makeGetStatus();
|
||||
|
||||
export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown }) => {
|
||||
export const Conversation = ({ conversation, scrollKey }) => {
|
||||
const id = conversation.get('id');
|
||||
const unread = conversation.get('unread');
|
||||
const lastStatusId = conversation.get('last_status');
|
||||
|
@ -110,14 +110,6 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
|
|||
dispatch(deleteConversation(id));
|
||||
}, [dispatch, id]);
|
||||
|
||||
const handleHotkeyMoveUp = useCallback(() => {
|
||||
onMoveUp(id);
|
||||
}, [id, onMoveUp]);
|
||||
|
||||
const handleHotkeyMoveDown = useCallback(() => {
|
||||
onMoveDown(id);
|
||||
}, [id, onMoveDown]);
|
||||
|
||||
const handleConversationMute = useCallback(() => {
|
||||
if (lastStatus.get('muted')) {
|
||||
dispatch(unmuteStatus(lastStatus.get('id')));
|
||||
|
@ -161,8 +153,6 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
|
|||
const handlers = {
|
||||
reply: handleReply,
|
||||
open: handleClick,
|
||||
moveUp: handleHotkeyMoveUp,
|
||||
moveDown: handleHotkeyMoveDown,
|
||||
toggleHidden: handleShowMore,
|
||||
};
|
||||
|
||||
|
@ -224,6 +214,4 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
|
|||
Conversation.propTypes = {
|
||||
conversation: ImmutablePropTypes.map.isRequired,
|
||||
scrollKey: PropTypes.string,
|
||||
onMoveUp: PropTypes.func,
|
||||
onMoveDown: PropTypes.func,
|
||||
};
|
||||
|
|
|
@ -10,20 +10,6 @@ import ScrollableList from 'mastodon/components/scrollable_list';
|
|||
|
||||
import { Conversation } from './conversation';
|
||||
|
||||
const focusChild = (node, index, alignTop) => {
|
||||
const element = node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
|
||||
|
||||
if (element) {
|
||||
if (alignTop && node.scrollTop > element.offsetTop) {
|
||||
element.scrollIntoView(true);
|
||||
} else if (!alignTop && node.scrollTop + node.clientHeight < element.offsetTop + element.offsetHeight) {
|
||||
element.scrollIntoView(false);
|
||||
}
|
||||
|
||||
element.focus();
|
||||
}
|
||||
};
|
||||
|
||||
export const ConversationsList = ({ scrollKey, ...other }) => {
|
||||
const listRef = useRef();
|
||||
const conversations = useSelector(state => state.getIn(['conversations', 'items']));
|
||||
|
@ -32,16 +18,6 @@ export const ConversationsList = ({ scrollKey, ...other }) => {
|
|||
const dispatch = useDispatch();
|
||||
const lastStatusId = conversations.last()?.get('last_status');
|
||||
|
||||
const handleMoveUp = useCallback(id => {
|
||||
const elementIndex = conversations.findIndex(x => x.get('id') === id) - 1;
|
||||
focusChild(listRef.current.node, elementIndex, true);
|
||||
}, [listRef, conversations]);
|
||||
|
||||
const handleMoveDown = useCallback(id => {
|
||||
const elementIndex = conversations.findIndex(x => x.get('id') === id) + 1;
|
||||
focusChild(listRef.current.node, elementIndex, false);
|
||||
}, [listRef, conversations]);
|
||||
|
||||
const debouncedLoadMore = useMemo(() => debounce(id => {
|
||||
dispatch(expandConversations({ maxId: id }));
|
||||
}, 300, { leading: true }), [dispatch]);
|
||||
|
@ -58,8 +34,6 @@ export const ConversationsList = ({ scrollKey, ...other }) => {
|
|||
<Conversation
|
||||
key={item.get('id')}
|
||||
conversation={item}
|
||||
onMoveUp={handleMoveUp}
|
||||
onMoveDown={handleMoveDown}
|
||||
scrollKey={scrollKey}
|
||||
/>
|
||||
))}
|
||||
|
|
139
app/javascript/mastodon/features/emoji/database.test.ts
Normal file
139
app/javascript/mastodon/features/emoji/database.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -9,6 +9,7 @@ import type {
|
|||
UnicodeEmojiData,
|
||||
LocaleOrCustom,
|
||||
} from './types';
|
||||
import { emojiLogger } from './utils';
|
||||
|
||||
interface EmojiDB extends LocaleTables, DBSchema {
|
||||
custom: {
|
||||
|
@ -36,40 +37,63 @@ interface LocaleTable {
|
|||
}
|
||||
type LocaleTables = Record<Locale, LocaleTable>;
|
||||
|
||||
type Database = IDBPDatabase<EmojiDB>;
|
||||
|
||||
const SCHEMA_VERSION = 1;
|
||||
|
||||
let db: IDBPDatabase<EmojiDB> | null = null;
|
||||
const loadedLocales = new Set<Locale>();
|
||||
|
||||
async function loadDB() {
|
||||
if (db) {
|
||||
return db;
|
||||
}
|
||||
db = await openDB<EmojiDB>('mastodon-emoji', SCHEMA_VERSION, {
|
||||
upgrade(database) {
|
||||
const customTable = database.createObjectStore('custom', {
|
||||
keyPath: 'shortcode',
|
||||
autoIncrement: false,
|
||||
});
|
||||
customTable.createIndex('category', 'category');
|
||||
const log = emojiLogger('database');
|
||||
|
||||
database.createObjectStore('etags');
|
||||
// Loads the database in a way that ensures it's only loaded once.
|
||||
const loadDB = (() => {
|
||||
let dbPromise: Promise<Database> | null = null;
|
||||
|
||||
for (const locale of SUPPORTED_LOCALES) {
|
||||
const localeTable = database.createObjectStore(locale, {
|
||||
keyPath: 'hexcode',
|
||||
// Actually load the DB.
|
||||
async function initDB() {
|
||||
const db = await openDB<EmojiDB>('mastodon-emoji', SCHEMA_VERSION, {
|
||||
upgrade(database) {
|
||||
const customTable = database.createObjectStore('custom', {
|
||||
keyPath: 'shortcode',
|
||||
autoIncrement: false,
|
||||
});
|
||||
localeTable.createIndex('group', 'group');
|
||||
localeTable.createIndex('label', 'label');
|
||||
localeTable.createIndex('order', 'order');
|
||||
localeTable.createIndex('tags', 'tags', { multiEntry: true });
|
||||
}
|
||||
},
|
||||
});
|
||||
return db;
|
||||
}
|
||||
customTable.createIndex('category', 'category');
|
||||
|
||||
database.createObjectStore('etags');
|
||||
|
||||
for (const locale of SUPPORTED_LOCALES) {
|
||||
const localeTable = database.createObjectStore(locale, {
|
||||
keyPath: 'hexcode',
|
||||
autoIncrement: false,
|
||||
});
|
||||
localeTable.createIndex('group', 'group');
|
||||
localeTable.createIndex('label', 'label');
|
||||
localeTable.createIndex('order', 'order');
|
||||
localeTable.createIndex('tags', 'tags', { multiEntry: true });
|
||||
}
|
||||
},
|
||||
});
|
||||
await syncLocales(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
// Loads the database, or returns the existing promise if it hasn't resolved yet.
|
||||
const loadPromise = async (): Promise<Database> => {
|
||||
if (dbPromise) {
|
||||
return dbPromise;
|
||||
}
|
||||
dbPromise = initDB();
|
||||
return dbPromise;
|
||||
};
|
||||
// Special way to reset the database, used for unit testing.
|
||||
loadPromise.reset = () => {
|
||||
dbPromise = null;
|
||||
};
|
||||
return loadPromise;
|
||||
})();
|
||||
|
||||
export async function putEmojiData(emojis: UnicodeEmojiData[], locale: Locale) {
|
||||
loadedLocales.add(locale);
|
||||
const db = await loadDB();
|
||||
const trx = db.transaction(locale, 'readwrite');
|
||||
await Promise.all(emojis.map((emoji) => trx.store.put(emoji)));
|
||||
|
@ -86,15 +110,15 @@ export async function putCustomEmojiData(emojis: CustomEmojiData[]) {
|
|||
export async function putLatestEtag(etag: string, localeString: string) {
|
||||
const locale = toSupportedLocaleOrCustom(localeString);
|
||||
const db = await loadDB();
|
||||
return db.put('etags', etag, locale);
|
||||
await db.put('etags', etag, locale);
|
||||
}
|
||||
|
||||
export async function searchEmojiByHexcode(
|
||||
export async function loadEmojiByHexcode(
|
||||
hexcode: string,
|
||||
localeString: string,
|
||||
) {
|
||||
const locale = toSupportedLocale(localeString);
|
||||
const db = await loadDB();
|
||||
const locale = toLoadedLocale(localeString);
|
||||
return db.get(locale, hexcode);
|
||||
}
|
||||
|
||||
|
@ -102,45 +126,39 @@ export async function searchEmojisByHexcodes(
|
|||
hexcodes: string[],
|
||||
localeString: string,
|
||||
) {
|
||||
const locale = toSupportedLocale(localeString);
|
||||
const db = await loadDB();
|
||||
return db.getAll(
|
||||
const locale = toLoadedLocale(localeString);
|
||||
const sortedCodes = hexcodes.toSorted();
|
||||
const results = await db.getAll(
|
||||
locale,
|
||||
IDBKeyRange.bound(hexcodes[0], hexcodes[hexcodes.length - 1]),
|
||||
IDBKeyRange.bound(sortedCodes.at(0), sortedCodes.at(-1)),
|
||||
);
|
||||
return results.filter((emoji) => hexcodes.includes(emoji.hexcode));
|
||||
}
|
||||
|
||||
export async function searchEmojiByTag(tag: string, localeString: string) {
|
||||
const locale = toSupportedLocale(localeString);
|
||||
const range = IDBKeyRange.only(tag.toLowerCase());
|
||||
export async function searchEmojisByTag(tag: string, localeString: string) {
|
||||
const db = await loadDB();
|
||||
const locale = toLoadedLocale(localeString);
|
||||
const range = IDBKeyRange.bound(
|
||||
tag.toLowerCase(),
|
||||
`${tag.toLowerCase()}\uffff`,
|
||||
);
|
||||
return db.getAllFromIndex(locale, 'tags', range);
|
||||
}
|
||||
|
||||
export async function searchCustomEmojiByShortcode(shortcode: string) {
|
||||
export async function loadCustomEmojiByShortcode(shortcode: string) {
|
||||
const db = await loadDB();
|
||||
return db.get('custom', shortcode);
|
||||
}
|
||||
|
||||
export async function searchCustomEmojisByShortcodes(shortcodes: string[]) {
|
||||
const db = await loadDB();
|
||||
return db.getAll(
|
||||
const sortedCodes = shortcodes.toSorted();
|
||||
const results = await db.getAll(
|
||||
'custom',
|
||||
IDBKeyRange.bound(shortcodes[0], shortcodes[shortcodes.length - 1]),
|
||||
IDBKeyRange.bound(sortedCodes.at(0), sortedCodes.at(-1)),
|
||||
);
|
||||
}
|
||||
|
||||
export async function findMissingLocales(localeStrings: string[]) {
|
||||
const locales = new Set(localeStrings.map(toSupportedLocale));
|
||||
const missingLocales: Locale[] = [];
|
||||
const db = await loadDB();
|
||||
for (const locale of locales) {
|
||||
const rowCount = await db.count(locale);
|
||||
if (!rowCount) {
|
||||
missingLocales.push(locale);
|
||||
}
|
||||
}
|
||||
return missingLocales;
|
||||
return results.filter((emoji) => shortcodes.includes(emoji.shortcode));
|
||||
}
|
||||
|
||||
export async function loadLatestEtag(localeString: string) {
|
||||
|
@ -153,3 +171,51 @@ export async function loadLatestEtag(localeString: string) {
|
|||
const etag = await db.get('etags', locale);
|
||||
return etag ?? null;
|
||||
}
|
||||
|
||||
// Private functions
|
||||
|
||||
async function syncLocales(db: Database) {
|
||||
const locales = await Promise.all(
|
||||
SUPPORTED_LOCALES.map(
|
||||
async (locale) =>
|
||||
[locale, await hasLocale(locale, db)] satisfies [Locale, boolean],
|
||||
),
|
||||
);
|
||||
for (const [locale, loaded] of locales) {
|
||||
if (loaded) {
|
||||
loadedLocales.add(locale);
|
||||
} else {
|
||||
loadedLocales.delete(locale);
|
||||
}
|
||||
}
|
||||
log('Loaded %d locales: %o', loadedLocales.size, loadedLocales);
|
||||
}
|
||||
|
||||
function toLoadedLocale(localeString: string) {
|
||||
const locale = toSupportedLocale(localeString);
|
||||
if (localeString !== locale) {
|
||||
log(`Locale ${locale} is different from provided ${localeString}`);
|
||||
}
|
||||
if (!loadedLocales.has(locale)) {
|
||||
throw new Error(`Locale ${locale} is not loaded in emoji database`);
|
||||
}
|
||||
return locale;
|
||||
}
|
||||
|
||||
async function hasLocale(locale: Locale, db: Database): Promise<boolean> {
|
||||
if (loadedLocales.has(locale)) {
|
||||
return true;
|
||||
}
|
||||
const rowCount = await db.count(locale);
|
||||
return !!rowCount;
|
||||
}
|
||||
|
||||
// Testing helpers
|
||||
export async function testGet() {
|
||||
const db = await loadDB();
|
||||
return { db, loadedLocales };
|
||||
}
|
||||
export function testClear() {
|
||||
loadedLocales.clear();
|
||||
loadDB.reset();
|
||||
}
|
||||
|
|
|
@ -1,81 +1,44 @@
|
|||
import type { HTMLAttributes } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { ComponentPropsWithoutRef, ElementType } from 'react';
|
||||
|
||||
import type { List as ImmutableList } from 'immutable';
|
||||
import { isList } from 'immutable';
|
||||
|
||||
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
|
||||
import type { CustomEmoji } from '@/mastodon/models/custom_emoji';
|
||||
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
|
||||
|
||||
import { useEmojiAppState } from './hooks';
|
||||
import { emojifyElement } from './render';
|
||||
import type { ExtraCustomEmojiMap } from './types';
|
||||
import { useEmojify } from './hooks';
|
||||
import type { CustomEmojiMapArg } from './types';
|
||||
|
||||
type EmojiHTMLProps = Omit<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
type EmojiHTMLProps<Element extends ElementType = 'div'> = Omit<
|
||||
ComponentPropsWithoutRef<Element>,
|
||||
'dangerouslySetInnerHTML'
|
||||
> & {
|
||||
htmlString: string;
|
||||
extraEmojis?: ExtraCustomEmojiMap | ImmutableList<CustomEmoji>;
|
||||
extraEmojis?: CustomEmojiMapArg;
|
||||
as?: Element;
|
||||
};
|
||||
|
||||
export const EmojiHTML: React.FC<EmojiHTMLProps> = ({
|
||||
htmlString,
|
||||
export const ModernEmojiHTML = <Element extends ElementType>({
|
||||
extraEmojis,
|
||||
htmlString,
|
||||
as: asElement, // Rename for syntax highlighting
|
||||
...props
|
||||
}) => {
|
||||
if (isModernEmojiEnabled()) {
|
||||
return (
|
||||
<ModernEmojiHTML
|
||||
htmlString={htmlString}
|
||||
extraEmojis={extraEmojis}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <div dangerouslySetInnerHTML={{ __html: htmlString }} {...props} />;
|
||||
};
|
||||
}: EmojiHTMLProps<Element>) => {
|
||||
const Wrapper = asElement ?? 'div';
|
||||
const emojifiedHtml = useEmojify(htmlString, extraEmojis);
|
||||
|
||||
const ModernEmojiHTML: React.FC<EmojiHTMLProps> = ({
|
||||
extraEmojis: rawEmojis,
|
||||
htmlString: text,
|
||||
...props
|
||||
}) => {
|
||||
const appState = useEmojiAppState();
|
||||
const [innerHTML, setInnerHTML] = useState('');
|
||||
|
||||
const extraEmojis: ExtraCustomEmojiMap = useMemo(() => {
|
||||
if (!rawEmojis) {
|
||||
return {};
|
||||
}
|
||||
if (isList(rawEmojis)) {
|
||||
return (
|
||||
rawEmojis.toJS() as ApiCustomEmojiJSON[]
|
||||
).reduce<ExtraCustomEmojiMap>(
|
||||
(acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }),
|
||||
{},
|
||||
);
|
||||
}
|
||||
return rawEmojis;
|
||||
}, [rawEmojis]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
const cb = async () => {
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = text;
|
||||
const ele = await emojifyElement(div, appState, extraEmojis);
|
||||
setInnerHTML(ele.innerHTML);
|
||||
};
|
||||
void cb();
|
||||
}, [text, appState, extraEmojis]);
|
||||
|
||||
if (!innerHTML) {
|
||||
if (emojifiedHtml === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div {...props} dangerouslySetInnerHTML={{ __html: innerHTML }} />;
|
||||
return (
|
||||
<Wrapper {...props} dangerouslySetInnerHTML={{ __html: emojifiedHtml }} />
|
||||
);
|
||||
};
|
||||
|
||||
export const EmojiHTML = <Element extends ElementType>(
|
||||
props: EmojiHTMLProps<Element>,
|
||||
) => {
|
||||
if (isModernEmojiEnabled()) {
|
||||
return <ModernEmojiHTML {...props} />;
|
||||
}
|
||||
const { as: asElement, htmlString, extraEmojis, ...rest } = props;
|
||||
const Wrapper = asElement ?? 'div';
|
||||
return <Wrapper {...rest} dangerouslySetInnerHTML={{ __html: htmlString }} />;
|
||||
};
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,8 +1,64 @@
|
|||
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { isList } from 'immutable';
|
||||
|
||||
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
|
||||
import { useAppSelector } from '@/mastodon/store';
|
||||
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
|
||||
|
||||
import { toSupportedLocale } from './locale';
|
||||
import { determineEmojiMode } from './mode';
|
||||
import type { EmojiAppState } from './types';
|
||||
import type {
|
||||
CustomEmojiMapArg,
|
||||
EmojiAppState,
|
||||
ExtraCustomEmojiMap,
|
||||
} from './types';
|
||||
import { stringHasAnyEmoji } from './utils';
|
||||
|
||||
export function useEmojify(text: string, extraEmojis?: CustomEmojiMapArg) {
|
||||
const [emojifiedText, setEmojifiedText] = useState<string | null>(null);
|
||||
|
||||
const appState = useEmojiAppState();
|
||||
const extra: ExtraCustomEmojiMap = useMemo(() => {
|
||||
if (!extraEmojis) {
|
||||
return {};
|
||||
}
|
||||
if (isList(extraEmojis)) {
|
||||
return (
|
||||
extraEmojis.toJS() as ApiCustomEmojiJSON[]
|
||||
).reduce<ExtraCustomEmojiMap>(
|
||||
(acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }),
|
||||
{},
|
||||
);
|
||||
}
|
||||
return extraEmojis;
|
||||
}, [extraEmojis]);
|
||||
|
||||
const emojify = useCallback(
|
||||
async (input: string) => {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.innerHTML = input;
|
||||
const { emojifyElement } = await import('./render');
|
||||
const result = await emojifyElement(wrapper, appState, extra);
|
||||
if (result) {
|
||||
setEmojifiedText(result.innerHTML);
|
||||
} else {
|
||||
setEmojifiedText(input);
|
||||
}
|
||||
},
|
||||
[appState, extra],
|
||||
);
|
||||
useLayoutEffect(() => {
|
||||
if (isModernEmojiEnabled() && !!text.trim() && stringHasAnyEmoji(text)) {
|
||||
void emojify(text);
|
||||
} else {
|
||||
// If no emoji or we don't want to render, fall back.
|
||||
setEmojifiedText(text);
|
||||
}
|
||||
}, [emojify, text]);
|
||||
|
||||
return emojifiedText;
|
||||
}
|
||||
|
||||
export function useEmojiAppState(): EmojiAppState {
|
||||
const locale = useAppSelector((state) =>
|
||||
|
@ -12,5 +68,10 @@ export function useEmojiAppState(): EmojiAppState {
|
|||
determineEmojiMode(state.meta.get('emoji_style') as string),
|
||||
);
|
||||
|
||||
return { currentLocale: locale, locales: [locale], mode };
|
||||
return {
|
||||
currentLocale: locale,
|
||||
locales: [locale],
|
||||
mode,
|
||||
darkTheme: document.body.classList.contains('theme-default'),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -2,12 +2,16 @@ import initialState from '@/mastodon/initial_state';
|
|||
import { loadWorker } from '@/mastodon/utils/workers';
|
||||
|
||||
import { toSupportedLocale } from './locale';
|
||||
import { emojiLogger } from './utils';
|
||||
|
||||
const userLocale = toSupportedLocale(initialState?.meta.locale ?? 'en');
|
||||
|
||||
let worker: Worker | null = null;
|
||||
|
||||
export async function initializeEmoji() {
|
||||
const log = emojiLogger('index');
|
||||
|
||||
export function initializeEmoji() {
|
||||
log('initializing emojis');
|
||||
if (!worker && 'Worker' in window) {
|
||||
try {
|
||||
worker = loadWorker(new URL('./worker', import.meta.url), {
|
||||
|
@ -21,9 +25,16 @@ export async function initializeEmoji() {
|
|||
if (worker) {
|
||||
// Assign worker to const to make TS happy inside the event listener.
|
||||
const thisWorker = worker;
|
||||
const timeoutId = setTimeout(() => {
|
||||
log('worker is not ready after timeout');
|
||||
worker = null;
|
||||
void fallbackLoad();
|
||||
}, 500);
|
||||
thisWorker.addEventListener('message', (event: MessageEvent<string>) => {
|
||||
const { data: message } = event;
|
||||
if (message === 'ready') {
|
||||
log('worker ready, loading data');
|
||||
clearTimeout(timeoutId);
|
||||
thisWorker.postMessage('custom');
|
||||
void loadEmojiLocale(userLocale);
|
||||
// Load English locale as well, because people are still used to
|
||||
|
@ -31,15 +42,22 @@ export async function initializeEmoji() {
|
|||
if (userLocale !== 'en') {
|
||||
void loadEmojiLocale('en');
|
||||
}
|
||||
} else {
|
||||
log('got worker message: %s', message);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const { importCustomEmojiData } = await import('./loader');
|
||||
await importCustomEmojiData();
|
||||
await loadEmojiLocale(userLocale);
|
||||
if (userLocale !== 'en') {
|
||||
await loadEmojiLocale('en');
|
||||
}
|
||||
void fallbackLoad();
|
||||
}
|
||||
}
|
||||
|
||||
async function fallbackLoad() {
|
||||
log('falling back to main thread for loading');
|
||||
const { importCustomEmojiData } = await import('./loader');
|
||||
await importCustomEmojiData();
|
||||
await loadEmojiLocale(userLocale);
|
||||
if (userLocale !== 'en') {
|
||||
await loadEmojiLocale('en');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -2,7 +2,6 @@ import { flattenEmojiData } from 'emojibase';
|
|||
import type { CompactEmoji, FlatCompactEmoji } from 'emojibase';
|
||||
|
||||
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
|
||||
import { isDevelopment } from '@/mastodon/utils/environment';
|
||||
|
||||
import {
|
||||
putEmojiData,
|
||||
|
@ -12,6 +11,9 @@ import {
|
|||
} from './database';
|
||||
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale';
|
||||
import type { LocaleOrCustom } from './types';
|
||||
import { emojiLogger } from './utils';
|
||||
|
||||
const log = emojiLogger('loader');
|
||||
|
||||
export async function importEmojiData(localeString: string) {
|
||||
const locale = toSupportedLocale(localeString);
|
||||
|
@ -20,6 +22,7 @@ export async function importEmojiData(localeString: string) {
|
|||
return;
|
||||
}
|
||||
const flattenedEmojis: FlatCompactEmoji[] = flattenEmojiData(emojis);
|
||||
log('loaded %d for %s locale', flattenedEmojis.length, locale);
|
||||
await putEmojiData(flattenedEmojis, locale);
|
||||
}
|
||||
|
||||
|
@ -28,6 +31,7 @@ export async function importCustomEmojiData() {
|
|||
if (!emojis) {
|
||||
return;
|
||||
}
|
||||
log('loaded %d custom emojis', emojis.length);
|
||||
await putCustomEmojiData(emojis);
|
||||
}
|
||||
|
||||
|
@ -41,7 +45,9 @@ async function fetchAndCheckEtag<ResultType extends object[]>(
|
|||
if (locale === 'custom') {
|
||||
url.pathname = '/api/v1/custom_emojis';
|
||||
} else {
|
||||
url.pathname = `/packs${isDevelopment() ? '-dev' : ''}/emoji/${locale}.json`;
|
||||
// This doesn't use isDevelopment() as that module loads initial state
|
||||
// which breaks workers, as they cannot access the DOM.
|
||||
url.pathname = `/packs${import.meta.env.DEV ? '-dev' : ''}/emoji/${locale}.json`;
|
||||
}
|
||||
|
||||
const oldEtag = await loadLatestEtag(locale);
|
||||
|
|
|
@ -1,94 +1,184 @@
|
|||
import { customEmojiFactory, unicodeEmojiFactory } from '@/testing/factories';
|
||||
|
||||
import {
|
||||
EMOJI_MODE_NATIVE,
|
||||
EMOJI_MODE_NATIVE_WITH_FLAGS,
|
||||
EMOJI_MODE_TWEMOJI,
|
||||
} from './constants';
|
||||
import { emojifyElement, tokenizeText } from './render';
|
||||
import type { CustomEmojiData, UnicodeEmojiData } from './types';
|
||||
import * as db from './database';
|
||||
import {
|
||||
emojifyElement,
|
||||
emojifyText,
|
||||
testCacheClear,
|
||||
tokenizeText,
|
||||
} from './render';
|
||||
import type { EmojiAppState, ExtraCustomEmojiMap } from './types';
|
||||
|
||||
vitest.mock('./database', () => ({
|
||||
searchCustomEmojisByShortcodes: vitest.fn(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
shortcode: 'custom',
|
||||
static_url: 'emoji/static',
|
||||
url: 'emoji/custom',
|
||||
category: 'test',
|
||||
visible_in_picker: true,
|
||||
},
|
||||
] satisfies CustomEmojiData[],
|
||||
),
|
||||
searchEmojisByHexcodes: vitest.fn(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
function mockDatabase() {
|
||||
return {
|
||||
searchCustomEmojisByShortcodes: vi
|
||||
.spyOn(db, 'searchCustomEmojisByShortcodes')
|
||||
.mockResolvedValue([customEmojiFactory()]),
|
||||
searchEmojisByHexcodes: vi
|
||||
.spyOn(db, 'searchEmojisByHexcodes')
|
||||
.mockResolvedValue([
|
||||
unicodeEmojiFactory({
|
||||
hexcode: '1F60A',
|
||||
group: 0,
|
||||
label: 'smiling face with smiling eyes',
|
||||
order: 0,
|
||||
tags: ['smile', 'happy'],
|
||||
unicode: '😊',
|
||||
},
|
||||
{
|
||||
}),
|
||||
unicodeEmojiFactory({
|
||||
hexcode: '1F1EA-1F1FA',
|
||||
group: 0,
|
||||
label: 'flag-eu',
|
||||
order: 0,
|
||||
tags: ['flag', 'european union'],
|
||||
unicode: '🇪🇺',
|
||||
},
|
||||
] satisfies UnicodeEmojiData[],
|
||||
),
|
||||
findMissingLocales: vitest.fn(() => []),
|
||||
}));
|
||||
}),
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
const expectedSmileImage =
|
||||
'<img draggable="false" class="emojione" alt="😊" title="smiling face with smiling eyes" src="/emoji/1f60a.svg">';
|
||||
const expectedFlagImage =
|
||||
'<img draggable="false" class="emojione" alt="🇪🇺" title="flag-eu" src="/emoji/1f1ea-1f1fa.svg">';
|
||||
const expectedCustomEmojiImage =
|
||||
'<img draggable="false" class="emojione custom-emoji" alt=":custom:" title=":custom:" src="emoji/custom/static" data-original="emoji/custom" data-static="emoji/custom/static">';
|
||||
const expectedRemoteCustomEmojiImage =
|
||||
'<img draggable="false" class="emojione custom-emoji" alt=":remote:" title=":remote:" src="remote.social/static" data-original="remote.social/custom" data-static="remote.social/static">';
|
||||
|
||||
const mockExtraCustom: ExtraCustomEmojiMap = {
|
||||
remote: {
|
||||
shortcode: 'remote',
|
||||
static_url: 'remote.social/static',
|
||||
url: 'remote.social/custom',
|
||||
},
|
||||
};
|
||||
|
||||
function testAppState(state: Partial<EmojiAppState> = {}) {
|
||||
return {
|
||||
locales: ['en'],
|
||||
mode: EMOJI_MODE_TWEMOJI,
|
||||
currentLocale: 'en',
|
||||
darkTheme: false,
|
||||
...state,
|
||||
} satisfies EmojiAppState;
|
||||
}
|
||||
|
||||
describe('emojifyElement', () => {
|
||||
const testElement = document.createElement('div');
|
||||
testElement.innerHTML = '<p>Hello 😊🇪🇺!</p><p>:custom:</p>';
|
||||
|
||||
const expectedSmileImage =
|
||||
'<img draggable="false" class="emojione" alt="😊" title="smiling face with smiling eyes" src="/emoji/1f60a.svg">';
|
||||
const expectedFlagImage =
|
||||
'<img draggable="false" class="emojione" alt="🇪🇺" title="flag-eu" src="/emoji/1f1ea-1f1fa.svg">';
|
||||
const expectedCustomEmojiImage =
|
||||
'<img draggable="false" class="emojione custom-emoji" alt=":custom:" title=":custom:" src="emoji/static" data-original="emoji/custom" data-static="emoji/static">';
|
||||
|
||||
function cloneTestElement() {
|
||||
return testElement.cloneNode(true) as HTMLElement;
|
||||
function testElement(text = '<p>Hello 😊🇪🇺!</p><p>:custom:</p>') {
|
||||
const testElement = document.createElement('div');
|
||||
testElement.innerHTML = text;
|
||||
return testElement;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
testCacheClear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('caches element rendering results', async () => {
|
||||
const { searchCustomEmojisByShortcodes, searchEmojisByHexcodes } =
|
||||
mockDatabase();
|
||||
await emojifyElement(testElement(), testAppState());
|
||||
await emojifyElement(testElement(), testAppState());
|
||||
await emojifyElement(testElement(), testAppState());
|
||||
expect(searchEmojisByHexcodes).toHaveBeenCalledExactlyOnceWith(
|
||||
['1F1EA-1F1FA', '1F60A'],
|
||||
'en',
|
||||
);
|
||||
expect(searchCustomEmojisByShortcodes).toHaveBeenCalledExactlyOnceWith([
|
||||
'custom',
|
||||
]);
|
||||
});
|
||||
|
||||
test('emojifies custom emoji in native mode', async () => {
|
||||
const emojifiedElement = await emojifyElement(cloneTestElement(), {
|
||||
locales: ['en'],
|
||||
mode: EMOJI_MODE_NATIVE,
|
||||
currentLocale: 'en',
|
||||
});
|
||||
expect(emojifiedElement.innerHTML).toBe(
|
||||
const { searchEmojisByHexcodes } = mockDatabase();
|
||||
const actual = await emojifyElement(
|
||||
testElement(),
|
||||
testAppState({ mode: EMOJI_MODE_NATIVE }),
|
||||
);
|
||||
assert(actual);
|
||||
expect(actual.innerHTML).toBe(
|
||||
`<p>Hello 😊🇪🇺!</p><p>${expectedCustomEmojiImage}</p>`,
|
||||
);
|
||||
expect(searchEmojisByHexcodes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('emojifies flag emoji in native-with-flags mode', async () => {
|
||||
const emojifiedElement = await emojifyElement(cloneTestElement(), {
|
||||
locales: ['en'],
|
||||
mode: EMOJI_MODE_NATIVE_WITH_FLAGS,
|
||||
currentLocale: 'en',
|
||||
});
|
||||
expect(emojifiedElement.innerHTML).toBe(
|
||||
const { searchEmojisByHexcodes } = mockDatabase();
|
||||
const actual = await emojifyElement(
|
||||
testElement(),
|
||||
testAppState({ mode: EMOJI_MODE_NATIVE_WITH_FLAGS }),
|
||||
);
|
||||
assert(actual);
|
||||
expect(actual.innerHTML).toBe(
|
||||
`<p>Hello 😊${expectedFlagImage}!</p><p>${expectedCustomEmojiImage}</p>`,
|
||||
);
|
||||
expect(searchEmojisByHexcodes).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test('emojifies everything in twemoji mode', async () => {
|
||||
const emojifiedElement = await emojifyElement(cloneTestElement(), {
|
||||
locales: ['en'],
|
||||
mode: EMOJI_MODE_TWEMOJI,
|
||||
currentLocale: 'en',
|
||||
});
|
||||
expect(emojifiedElement.innerHTML).toBe(
|
||||
const { searchCustomEmojisByShortcodes, searchEmojisByHexcodes } =
|
||||
mockDatabase();
|
||||
const actual = await emojifyElement(testElement(), testAppState());
|
||||
assert(actual);
|
||||
expect(actual.innerHTML).toBe(
|
||||
`<p>Hello ${expectedSmileImage}${expectedFlagImage}!</p><p>${expectedCustomEmojiImage}</p>`,
|
||||
);
|
||||
expect(searchEmojisByHexcodes).toHaveBeenCalledOnce();
|
||||
expect(searchCustomEmojisByShortcodes).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test('emojifies with provided custom emoji', async () => {
|
||||
const { searchCustomEmojisByShortcodes, searchEmojisByHexcodes } =
|
||||
mockDatabase();
|
||||
const actual = await emojifyElement(
|
||||
testElement('<p>hi :remote:</p>'),
|
||||
testAppState(),
|
||||
mockExtraCustom,
|
||||
);
|
||||
assert(actual);
|
||||
expect(actual.innerHTML).toBe(
|
||||
`<p>hi ${expectedRemoteCustomEmojiImage}</p>`,
|
||||
);
|
||||
expect(searchEmojisByHexcodes).not.toHaveBeenCalled();
|
||||
expect(searchCustomEmojisByShortcodes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('returns null when no emoji are found', async () => {
|
||||
mockDatabase();
|
||||
const actual = await emojifyElement(
|
||||
testElement('<p>here is just text :)</p>'),
|
||||
testAppState(),
|
||||
);
|
||||
expect(actual).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('emojifyText', () => {
|
||||
test('returns original input when no emoji are in string', async () => {
|
||||
const actual = await emojifyText('nothing here', testAppState());
|
||||
expect(actual).toBe('nothing here');
|
||||
});
|
||||
|
||||
test('renders Unicode emojis to twemojis', async () => {
|
||||
mockDatabase();
|
||||
const actual = await emojifyText('Hello 😊🇪🇺!', testAppState());
|
||||
expect(actual).toBe(`Hello ${expectedSmileImage}${expectedFlagImage}!`);
|
||||
});
|
||||
|
||||
test('renders custom emojis', async () => {
|
||||
mockDatabase();
|
||||
const actual = await emojifyText('Hello :custom:!', testAppState());
|
||||
expect(actual).toBe(`Hello ${expectedCustomEmojiImage}!`);
|
||||
});
|
||||
|
||||
test('renders provided extra emojis', async () => {
|
||||
const actual = await emojifyText(
|
||||
'remote emoji :remote:',
|
||||
testAppState(),
|
||||
mockExtraCustom,
|
||||
);
|
||||
expect(actual).toBe(`remote emoji ${expectedRemoteCustomEmojiImage}`);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
import type { Locale } from 'emojibase';
|
||||
import EMOJI_REGEX from 'emojibase-regex/emoji-loose';
|
||||
|
||||
import { autoPlayGif } from '@/mastodon/initial_state';
|
||||
import { createLimitedCache } from '@/mastodon/utils/cache';
|
||||
import { assetHost } from '@/mastodon/utils/config';
|
||||
import * as perf from '@/mastodon/utils/performance';
|
||||
|
||||
import {
|
||||
EMOJI_MODE_NATIVE,
|
||||
|
@ -12,11 +11,9 @@ import {
|
|||
EMOJI_STATE_MISSING,
|
||||
} from './constants';
|
||||
import {
|
||||
findMissingLocales,
|
||||
searchCustomEmojisByShortcodes,
|
||||
searchEmojisByHexcodes,
|
||||
} from './database';
|
||||
import { loadEmojiLocale } from './index';
|
||||
import {
|
||||
emojiToUnicodeHex,
|
||||
twemojiHasBorder,
|
||||
|
@ -34,18 +31,38 @@ import type {
|
|||
LocaleOrCustom,
|
||||
UnicodeEmojiToken,
|
||||
} from './types';
|
||||
import { stringHasUnicodeFlags } from './utils';
|
||||
import {
|
||||
anyEmojiRegex,
|
||||
emojiLogger,
|
||||
stringHasAnyEmoji,
|
||||
stringHasUnicodeFlags,
|
||||
} from './utils';
|
||||
|
||||
const localeCacheMap = new Map<LocaleOrCustom, EmojiStateMap>([
|
||||
[EMOJI_TYPE_CUSTOM, new Map()],
|
||||
]);
|
||||
const log = emojiLogger('render');
|
||||
|
||||
// Emojifies an element. This modifies the element in place, replacing text nodes with emojified versions.
|
||||
/**
|
||||
* Emojifies an element. This modifies the element in place, replacing text nodes with emojified versions.
|
||||
*/
|
||||
export async function emojifyElement<Element extends HTMLElement>(
|
||||
element: Element,
|
||||
appState: EmojiAppState,
|
||||
extraEmojis: ExtraCustomEmojiMap = {},
|
||||
): Promise<Element> {
|
||||
): Promise<Element | null> {
|
||||
const cacheKey = createCacheKey(element, appState, extraEmojis);
|
||||
const cached = getCached(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
log('Cache hit on %s', element.outerHTML);
|
||||
if (cached === null) {
|
||||
return null;
|
||||
}
|
||||
element.innerHTML = cached;
|
||||
return element;
|
||||
}
|
||||
if (!stringHasAnyEmoji(element.innerHTML)) {
|
||||
updateCache(cacheKey, null);
|
||||
return null;
|
||||
}
|
||||
perf.start('emojifyElement()');
|
||||
const queue: (HTMLElement | Text)[] = [element];
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
|
@ -61,7 +78,7 @@ export async function emojifyElement<Element extends HTMLElement>(
|
|||
current.textContent &&
|
||||
(current instanceof Text || !current.hasChildNodes())
|
||||
) {
|
||||
const renderedContent = await emojifyText(
|
||||
const renderedContent = await textToElementArray(
|
||||
current.textContent,
|
||||
appState,
|
||||
extraEmojis,
|
||||
|
@ -70,7 +87,7 @@ export async function emojifyElement<Element extends HTMLElement>(
|
|||
if (!(current instanceof Text)) {
|
||||
current.textContent = null; // Clear the text content if it's not a Text node.
|
||||
}
|
||||
current.replaceWith(renderedToHTMLFragment(renderedContent));
|
||||
current.replaceWith(renderedToHTML(renderedContent));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
@ -81,6 +98,8 @@ export async function emojifyElement<Element extends HTMLElement>(
|
|||
}
|
||||
}
|
||||
}
|
||||
updateCache(cacheKey, element.innerHTML);
|
||||
perf.stop('emojifyElement()');
|
||||
return element;
|
||||
}
|
||||
|
||||
|
@ -88,7 +107,54 @@ export async function emojifyText(
|
|||
text: string,
|
||||
appState: EmojiAppState,
|
||||
extraEmojis: ExtraCustomEmojiMap = {},
|
||||
): Promise<string | null> {
|
||||
const cacheKey = createCacheKey(text, appState, extraEmojis);
|
||||
const cached = getCached(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
log('Cache hit on %s', text);
|
||||
return cached ?? text;
|
||||
}
|
||||
if (!stringHasAnyEmoji(text)) {
|
||||
updateCache(cacheKey, null);
|
||||
return text;
|
||||
}
|
||||
const eleArray = await textToElementArray(text, appState, extraEmojis);
|
||||
if (!eleArray) {
|
||||
updateCache(cacheKey, null);
|
||||
return text;
|
||||
}
|
||||
const rendered = renderedToHTML(eleArray, document.createElement('div'));
|
||||
updateCache(cacheKey, rendered.innerHTML);
|
||||
return rendered.innerHTML;
|
||||
}
|
||||
|
||||
// Private functions
|
||||
|
||||
const {
|
||||
set: updateCache,
|
||||
get: getCached,
|
||||
clear: cacheClear,
|
||||
} = createLimitedCache<string | null>({ log: log.extend('cache') });
|
||||
|
||||
function createCacheKey(
|
||||
input: HTMLElement | string,
|
||||
appState: EmojiAppState,
|
||||
extraEmojis: ExtraCustomEmojiMap,
|
||||
) {
|
||||
return JSON.stringify([
|
||||
input instanceof HTMLElement ? input.outerHTML : input,
|
||||
appState,
|
||||
extraEmojis,
|
||||
]);
|
||||
}
|
||||
|
||||
type EmojifiedTextArray = (string | HTMLImageElement)[];
|
||||
|
||||
async function textToElementArray(
|
||||
text: string,
|
||||
appState: EmojiAppState,
|
||||
extraEmojis: ExtraCustomEmojiMap = {},
|
||||
): Promise<EmojifiedTextArray | null> {
|
||||
// Exit if no text to convert.
|
||||
if (!text.trim()) {
|
||||
return null;
|
||||
|
@ -102,10 +168,9 @@ export async function emojifyText(
|
|||
}
|
||||
|
||||
// Get all emoji from the state map, loading any missing ones.
|
||||
await ensureLocalesAreLoaded(appState.locales);
|
||||
await loadMissingEmojiIntoCache(tokens, appState.locales);
|
||||
await loadMissingEmojiIntoCache(tokens, appState, extraEmojis);
|
||||
|
||||
const renderedFragments: (string | HTMLImageElement)[] = [];
|
||||
const renderedFragments: EmojifiedTextArray = [];
|
||||
for (const token of tokens) {
|
||||
if (typeof token !== 'string' && shouldRenderImage(token, appState.mode)) {
|
||||
let state: EmojiState | undefined;
|
||||
|
@ -125,7 +190,7 @@ export async function emojifyText(
|
|||
|
||||
// If the state is valid, create an image element. Otherwise, just append as text.
|
||||
if (state && typeof state !== 'string') {
|
||||
const image = stateToImage(state);
|
||||
const image = stateToImage(state, appState);
|
||||
renderedFragments.push(image);
|
||||
continue;
|
||||
}
|
||||
|
@ -137,21 +202,6 @@ export async function emojifyText(
|
|||
return renderedFragments;
|
||||
}
|
||||
|
||||
// Private functions
|
||||
|
||||
async function ensureLocalesAreLoaded(locales: Locale[]) {
|
||||
const missingLocales = await findMissingLocales(locales);
|
||||
for (const locale of missingLocales) {
|
||||
await loadEmojiLocale(locale);
|
||||
}
|
||||
}
|
||||
|
||||
const CUSTOM_EMOJI_REGEX = /:([a-z0-9_]+):/i;
|
||||
const TOKENIZE_REGEX = new RegExp(
|
||||
`(${EMOJI_REGEX.source}|${CUSTOM_EMOJI_REGEX.source})`,
|
||||
'g',
|
||||
);
|
||||
|
||||
type TokenizedText = (string | EmojiToken)[];
|
||||
|
||||
export function tokenizeText(text: string): TokenizedText {
|
||||
|
@ -161,7 +211,7 @@ export function tokenizeText(text: string): TokenizedText {
|
|||
|
||||
const tokens = [];
|
||||
let lastIndex = 0;
|
||||
for (const match of text.matchAll(TOKENIZE_REGEX)) {
|
||||
for (const match of text.matchAll(anyEmojiRegex())) {
|
||||
if (match.index > lastIndex) {
|
||||
tokens.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
|
@ -189,8 +239,18 @@ export function tokenizeText(text: string): TokenizedText {
|
|||
return tokens;
|
||||
}
|
||||
|
||||
const localeCacheMap = new Map<LocaleOrCustom, EmojiStateMap>([
|
||||
[
|
||||
EMOJI_TYPE_CUSTOM,
|
||||
createLimitedCache<EmojiState>({ log: log.extend('custom') }),
|
||||
],
|
||||
]);
|
||||
|
||||
function cacheForLocale(locale: LocaleOrCustom): EmojiStateMap {
|
||||
return localeCacheMap.get(locale) ?? (new Map() as EmojiStateMap);
|
||||
return (
|
||||
localeCacheMap.get(locale) ??
|
||||
createLimitedCache<EmojiState>({ log: log.extend(locale) })
|
||||
);
|
||||
}
|
||||
|
||||
function emojiForLocale(
|
||||
|
@ -203,7 +263,8 @@ function emojiForLocale(
|
|||
|
||||
async function loadMissingEmojiIntoCache(
|
||||
tokens: TokenizedText,
|
||||
locales: Locale[],
|
||||
{ mode, currentLocale }: EmojiAppState,
|
||||
extraEmojis: ExtraCustomEmojiMap,
|
||||
) {
|
||||
const missingUnicodeEmoji = new Set<string>();
|
||||
const missingCustomEmoji = new Set<string>();
|
||||
|
@ -217,42 +278,41 @@ async function loadMissingEmojiIntoCache(
|
|||
// If this is a custom emoji, check it separately.
|
||||
if (token.type === EMOJI_TYPE_CUSTOM) {
|
||||
const code = token.code;
|
||||
if (code in extraEmojis) {
|
||||
continue; // We don't care about extra emoji.
|
||||
}
|
||||
const emojiState = emojiForLocale(code, EMOJI_TYPE_CUSTOM);
|
||||
if (!emojiState) {
|
||||
missingCustomEmoji.add(code);
|
||||
}
|
||||
// Otherwise this is a unicode emoji, so check it against all locales.
|
||||
} else {
|
||||
} else if (shouldRenderImage(token, mode)) {
|
||||
const code = emojiToUnicodeHex(token.code);
|
||||
if (missingUnicodeEmoji.has(code)) {
|
||||
continue; // Already marked as missing.
|
||||
}
|
||||
for (const locale of locales) {
|
||||
const emojiState = emojiForLocale(code, locale);
|
||||
if (!emojiState) {
|
||||
// If it's missing in one locale, we consider it missing for all.
|
||||
missingUnicodeEmoji.add(code);
|
||||
}
|
||||
const emojiState = emojiForLocale(code, currentLocale);
|
||||
if (!emojiState) {
|
||||
// If it's missing in one locale, we consider it missing for all.
|
||||
missingUnicodeEmoji.add(code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (missingUnicodeEmoji.size > 0) {
|
||||
const missingEmojis = Array.from(missingUnicodeEmoji).toSorted();
|
||||
for (const locale of locales) {
|
||||
const emojis = await searchEmojisByHexcodes(missingEmojis, locale);
|
||||
const cache = cacheForLocale(locale);
|
||||
for (const emoji of emojis) {
|
||||
cache.set(emoji.hexcode, { type: EMOJI_TYPE_UNICODE, data: emoji });
|
||||
}
|
||||
const notFoundEmojis = missingEmojis.filter((code) =>
|
||||
emojis.every((emoji) => emoji.hexcode !== code),
|
||||
);
|
||||
for (const code of notFoundEmojis) {
|
||||
cache.set(code, EMOJI_STATE_MISSING); // Mark as missing if not found, as it's probably not a valid emoji.
|
||||
}
|
||||
localeCacheMap.set(locale, cache);
|
||||
const emojis = await searchEmojisByHexcodes(missingEmojis, currentLocale);
|
||||
const cache = cacheForLocale(currentLocale);
|
||||
for (const emoji of emojis) {
|
||||
cache.set(emoji.hexcode, { type: EMOJI_TYPE_UNICODE, data: emoji });
|
||||
}
|
||||
const notFoundEmojis = missingEmojis.filter((code) =>
|
||||
emojis.every((emoji) => emoji.hexcode !== code),
|
||||
);
|
||||
for (const code of notFoundEmojis) {
|
||||
cache.set(code, EMOJI_STATE_MISSING); // Mark as missing if not found, as it's probably not a valid emoji.
|
||||
}
|
||||
localeCacheMap.set(currentLocale, cache);
|
||||
}
|
||||
|
||||
if (missingCustomEmoji.size > 0) {
|
||||
|
@ -288,22 +348,24 @@ function shouldRenderImage(token: EmojiToken, mode: EmojiMode): boolean {
|
|||
return true;
|
||||
}
|
||||
|
||||
function stateToImage(state: EmojiLoadedState) {
|
||||
function stateToImage(state: EmojiLoadedState, appState: EmojiAppState) {
|
||||
const image = document.createElement('img');
|
||||
image.draggable = false;
|
||||
image.classList.add('emojione');
|
||||
|
||||
if (state.type === EMOJI_TYPE_UNICODE) {
|
||||
const emojiInfo = twemojiHasBorder(unicodeToTwemojiHex(state.data.hexcode));
|
||||
if (emojiInfo.hasLightBorder) {
|
||||
image.dataset.lightCode = `${emojiInfo.hexCode}_BORDER`;
|
||||
} else if (emojiInfo.hasDarkBorder) {
|
||||
image.dataset.darkCode = `${emojiInfo.hexCode}_BORDER`;
|
||||
let fileName = emojiInfo.hexCode;
|
||||
if (
|
||||
(appState.darkTheme && emojiInfo.hasDarkBorder) ||
|
||||
(!appState.darkTheme && emojiInfo.hasLightBorder)
|
||||
) {
|
||||
fileName = `${emojiInfo.hexCode}_border`;
|
||||
}
|
||||
|
||||
image.alt = state.data.unicode;
|
||||
image.title = state.data.label;
|
||||
image.src = `${assetHost}/emoji/${emojiInfo.hexCode}.svg`;
|
||||
image.src = `${assetHost}/emoji/${fileName}.svg`;
|
||||
} else {
|
||||
// Custom emoji
|
||||
const shortCode = `:${state.data.shortcode}:`;
|
||||
|
@ -318,8 +380,16 @@ function stateToImage(state: EmojiLoadedState) {
|
|||
return image;
|
||||
}
|
||||
|
||||
function renderedToHTMLFragment(renderedArray: (string | HTMLImageElement)[]) {
|
||||
const fragment = document.createDocumentFragment();
|
||||
function renderedToHTML(renderedArray: EmojifiedTextArray): DocumentFragment;
|
||||
function renderedToHTML<ParentType extends ParentNode>(
|
||||
renderedArray: EmojifiedTextArray,
|
||||
parent: ParentType,
|
||||
): ParentType;
|
||||
function renderedToHTML(
|
||||
renderedArray: EmojifiedTextArray,
|
||||
parent: ParentNode | null = null,
|
||||
) {
|
||||
const fragment = parent ?? document.createDocumentFragment();
|
||||
for (const fragmentItem of renderedArray) {
|
||||
if (typeof fragmentItem === 'string') {
|
||||
fragment.appendChild(document.createTextNode(fragmentItem));
|
||||
|
@ -329,3 +399,9 @@ function renderedToHTMLFragment(renderedArray: (string | HTMLImageElement)[]) {
|
|||
}
|
||||
return fragment;
|
||||
}
|
||||
|
||||
// Testing helpers
|
||||
export const testCacheClear = () => {
|
||||
cacheClear();
|
||||
localeCacheMap.clear();
|
||||
};
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
import type { List as ImmutableList } from 'immutable';
|
||||
|
||||
import type { FlatCompactEmoji, Locale } from 'emojibase';
|
||||
|
||||
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
|
||||
import type { CustomEmoji } from '@/mastodon/models/custom_emoji';
|
||||
import type { LimitedCache } from '@/mastodon/utils/cache';
|
||||
|
||||
import type {
|
||||
EMOJI_MODE_NATIVE,
|
||||
|
@ -22,6 +26,7 @@ export interface EmojiAppState {
|
|||
locales: Locale[];
|
||||
currentLocale: Locale;
|
||||
mode: EmojiMode;
|
||||
darkTheme: boolean;
|
||||
}
|
||||
|
||||
export interface UnicodeEmojiToken {
|
||||
|
@ -45,7 +50,7 @@ export interface EmojiStateUnicode {
|
|||
}
|
||||
export interface EmojiStateCustom {
|
||||
type: typeof EMOJI_TYPE_CUSTOM;
|
||||
data: CustomEmojiData;
|
||||
data: CustomEmojiRenderFields;
|
||||
}
|
||||
export type EmojiState =
|
||||
| EmojiStateMissing
|
||||
|
@ -53,9 +58,16 @@ export type EmojiState =
|
|||
| EmojiStateCustom;
|
||||
export type EmojiLoadedState = EmojiStateUnicode | EmojiStateCustom;
|
||||
|
||||
export type EmojiStateMap = Map<string, EmojiState>;
|
||||
export type EmojiStateMap = LimitedCache<string, EmojiState>;
|
||||
|
||||
export type ExtraCustomEmojiMap = Record<string, ApiCustomEmojiJSON>;
|
||||
export type CustomEmojiMapArg =
|
||||
| ExtraCustomEmojiMap
|
||||
| ImmutableList<CustomEmoji>;
|
||||
export type CustomEmojiRenderFields = Pick<
|
||||
CustomEmojiData,
|
||||
'shortcode' | 'static_url' | 'url'
|
||||
>;
|
||||
export type ExtraCustomEmojiMap = Record<string, CustomEmojiRenderFields>;
|
||||
|
||||
export interface TwemojiBorderInfo {
|
||||
hexCode: string;
|
||||
|
|
|
@ -1,8 +1,14 @@
|
|||
import { stringHasUnicodeEmoji, stringHasUnicodeFlags } from './utils';
|
||||
import {
|
||||
stringHasAnyEmoji,
|
||||
stringHasCustomEmoji,
|
||||
stringHasUnicodeEmoji,
|
||||
stringHasUnicodeFlags,
|
||||
} from './utils';
|
||||
|
||||
describe('stringHasEmoji', () => {
|
||||
describe('stringHasUnicodeEmoji', () => {
|
||||
test.concurrent.for([
|
||||
['only text', false],
|
||||
['text with non-emoji symbols ™©', false],
|
||||
['text with emoji 😀', true],
|
||||
['multiple emojis 😀😃😄', true],
|
||||
['emoji with skin tone 👍🏽', true],
|
||||
|
@ -19,14 +25,14 @@ describe('stringHasEmoji', () => {
|
|||
['emoji with enclosing keycap #️⃣', true],
|
||||
['emoji with no visible glyph \u200D', false],
|
||||
] as const)(
|
||||
'stringHasEmoji has emojis in "%s": %o',
|
||||
'stringHasUnicodeEmoji has emojis in "%s": %o',
|
||||
([text, expected], { expect }) => {
|
||||
expect(stringHasUnicodeEmoji(text)).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('stringHasFlags', () => {
|
||||
describe('stringHasUnicodeFlags', () => {
|
||||
test.concurrent.for([
|
||||
['EU 🇪🇺', true],
|
||||
['Germany 🇩🇪', true],
|
||||
|
@ -45,3 +51,27 @@ describe('stringHasFlags', () => {
|
|||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('stringHasCustomEmoji', () => {
|
||||
test('string with custom emoji returns true', () => {
|
||||
expect(stringHasCustomEmoji(':custom: :test:')).toBeTruthy();
|
||||
});
|
||||
test('string without custom emoji returns false', () => {
|
||||
expect(stringHasCustomEmoji('🏳️🌈 :🏳️🌈: text ™')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('stringHasAnyEmoji', () => {
|
||||
test('string without any emoji or characters', () => {
|
||||
expect(stringHasAnyEmoji('normal text. 12356?!')).toBeFalsy();
|
||||
});
|
||||
test('string with non-emoji characters', () => {
|
||||
expect(stringHasAnyEmoji('™©')).toBeFalsy();
|
||||
});
|
||||
test('has unicode emoji', () => {
|
||||
expect(stringHasAnyEmoji('🏳️🌈🔥🇸🇹 👩🔬')).toBeTruthy();
|
||||
});
|
||||
test('has custom emoji', () => {
|
||||
expect(stringHasAnyEmoji(':test: :custom:')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,13 +1,56 @@
|
|||
import EMOJI_REGEX from 'emojibase-regex/emoji-loose';
|
||||
import debug from 'debug';
|
||||
|
||||
export function stringHasUnicodeEmoji(text: string): boolean {
|
||||
return EMOJI_REGEX.test(text);
|
||||
import { emojiRegexPolyfill } from '@/mastodon/polyfills';
|
||||
|
||||
export function emojiLogger(segment: string) {
|
||||
return debug(`emojis:${segment}`);
|
||||
}
|
||||
|
||||
// From https://github.com/talkjs/country-flag-emoji-polyfill/blob/master/src/index.ts#L49-L50
|
||||
const EMOJIS_FLAGS_REGEX =
|
||||
/[\u{1F1E6}-\u{1F1FF}|\u{E0062}-\u{E0063}|\u{E0065}|\u{E0067}|\u{E006C}|\u{E006E}|\u{E0073}-\u{E0074}|\u{E0077}|\u{E007F}]+/u;
|
||||
|
||||
export function stringHasUnicodeFlags(text: string): boolean {
|
||||
return EMOJIS_FLAGS_REGEX.test(text);
|
||||
export function stringHasUnicodeEmoji(input: string): boolean {
|
||||
return new RegExp(EMOJI_REGEX, supportedFlags()).test(input);
|
||||
}
|
||||
|
||||
export function stringHasUnicodeFlags(input: string): boolean {
|
||||
if (supportsRegExpSets()) {
|
||||
return new RegExp(
|
||||
'\\p{RGI_Emoji_Flag_Sequence}|\\p{RGI_Emoji_Tag_Sequence}',
|
||||
'v',
|
||||
).test(input);
|
||||
}
|
||||
return new RegExp(
|
||||
// First range is regional indicator symbols,
|
||||
// Second is a black flag + 0-9|a-z tag chars + cancel tag.
|
||||
// See: https://en.wikipedia.org/wiki/Regional_indicator_symbol
|
||||
'(?:\uD83C[\uDDE6-\uDDFF]){2}|\uD83C\uDFF4(?:\uDB40[\uDC30-\uDC7A])+\uDB40\uDC7F',
|
||||
).test(input);
|
||||
}
|
||||
|
||||
// Constant as this is supported by all browsers.
|
||||
const CUSTOM_EMOJI_REGEX = /:([a-z0-9_]+):/i;
|
||||
export function stringHasCustomEmoji(input: string) {
|
||||
return CUSTOM_EMOJI_REGEX.test(input);
|
||||
}
|
||||
|
||||
export function stringHasAnyEmoji(input: string) {
|
||||
return stringHasUnicodeEmoji(input) || stringHasCustomEmoji(input);
|
||||
}
|
||||
|
||||
export function anyEmojiRegex() {
|
||||
return new RegExp(
|
||||
`${EMOJI_REGEX}|${CUSTOM_EMOJI_REGEX.source}`,
|
||||
supportedFlags('gi'),
|
||||
);
|
||||
}
|
||||
|
||||
function supportsRegExpSets() {
|
||||
return 'unicodeSets' in RegExp.prototype;
|
||||
}
|
||||
|
||||
function supportedFlags(flags = '') {
|
||||
if (supportsRegExpSets()) {
|
||||
return `${flags}v`;
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
const EMOJI_REGEX = emojiRegexPolyfill?.source ?? '\\p{RGI_Emoji}';
|
||||
|
|
|
@ -5,9 +5,14 @@ self.postMessage('ready'); // After the worker is ready, notify the main thread
|
|||
|
||||
function handleMessage(event: MessageEvent<string>) {
|
||||
const { data: locale } = event;
|
||||
if (locale !== 'custom') {
|
||||
void importEmojiData(locale);
|
||||
} else {
|
||||
void importCustomEmojiData();
|
||||
}
|
||||
void loadData(locale);
|
||||
}
|
||||
|
||||
async function loadData(locale: string) {
|
||||
if (locale !== 'custom') {
|
||||
await importEmojiData(locale);
|
||||
} else {
|
||||
await importCustomEmojiData();
|
||||
}
|
||||
self.postMessage(`loaded ${locale}`);
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useCallback, useRef, useState } from 'react';
|
||||
import { useEffect, useCallback, useRef, useState, useId } from 'react';
|
||||
|
||||
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
|
@ -19,6 +19,7 @@ import { DisplayName } from 'mastodon/components/display_name';
|
|||
import { FollowButton } from 'mastodon/components/follow_button';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
import { VerifiedBadge } from 'mastodon/components/verified_badge';
|
||||
import { domain } from 'mastodon/initial_state';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
@ -56,9 +57,7 @@ const messages = defineMessages({
|
|||
},
|
||||
});
|
||||
|
||||
const Source: React.FC<{
|
||||
id: ApiSuggestionSourceJSON;
|
||||
}> = ({ id }) => {
|
||||
const Source: React.FC<{ id: ApiSuggestionSourceJSON }> = ({ id }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
let label, hint;
|
||||
|
@ -168,10 +167,11 @@ const Card: React.FC<{
|
|||
|
||||
const DISMISSIBLE_ID = 'home/follow-suggestions';
|
||||
|
||||
export const InlineFollowSuggestions: React.FC<{
|
||||
hidden?: boolean;
|
||||
}> = ({ hidden }) => {
|
||||
export const InlineFollowSuggestions: React.FC<{ hidden?: boolean }> = ({
|
||||
hidden,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const uniqueId = useId();
|
||||
const dispatch = useAppDispatch();
|
||||
const suggestions = useAppSelector((state) => state.suggestions.items);
|
||||
const isLoading = useAppSelector((state) => state.suggestions.isLoading);
|
||||
|
@ -257,9 +257,14 @@ export const InlineFollowSuggestions: React.FC<{
|
|||
}
|
||||
|
||||
return (
|
||||
<div className='inline-follow-suggestions'>
|
||||
<div
|
||||
role='group'
|
||||
aria-labelledby={uniqueId}
|
||||
className='inline-follow-suggestions focusable'
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className='inline-follow-suggestions__header'>
|
||||
<h3>
|
||||
<h3 id={uniqueId}>
|
||||
<FormattedMessage
|
||||
id='follow_suggestions.who_to_follow'
|
||||
defaultMessage='Who to follow'
|
||||
|
@ -288,13 +293,17 @@ export const InlineFollowSuggestions: React.FC<{
|
|||
ref={bodyRef}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{suggestions.map((suggestion) => (
|
||||
<Card
|
||||
key={suggestion.account_id}
|
||||
id={suggestion.account_id}
|
||||
sources={suggestion.sources}
|
||||
/>
|
||||
))}
|
||||
{isLoading ? (
|
||||
<LoadingIndicator />
|
||||
) : (
|
||||
suggestions.map((suggestion) => (
|
||||
<Card
|
||||
key={suggestion.account_id}
|
||||
id={suggestion.account_id}
|
||||
sources={suggestion.sources}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canScrollLeft && (
|
||||
|
|
|
@ -9,6 +9,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
|||
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
|
||||
import Column from 'mastodon/components/column';
|
||||
import ColumnHeader from 'mastodon/components/column_header';
|
||||
import { isFeatureEnabled } from 'mastodon/utils/environment';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'keyboard_shortcuts.heading', defaultMessage: 'Keyboard Shortcuts' },
|
||||
|
@ -62,6 +63,12 @@ class KeyboardShortcuts extends ImmutablePureComponent {
|
|||
<td><kbd>b</kbd></td>
|
||||
<td><FormattedMessage id='keyboard_shortcuts.boost' defaultMessage='to boost' /></td>
|
||||
</tr>
|
||||
{isFeatureEnabled('outgoing_quotes') && (
|
||||
<tr>
|
||||
<td><kbd>q</kbd></td>
|
||||
<td><FormattedMessage id='keyboard_shortcuts.quote' defaultMessage='Quote post' /></td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td><kbd>enter</kbd>, <kbd>o</kbd></td>
|
||||
<td><FormattedMessage id='keyboard_shortcuts.enter' defaultMessage='to open status' /></td>
|
||||
|
@ -83,13 +90,17 @@ class KeyboardShortcuts extends ImmutablePureComponent {
|
|||
<td><FormattedMessage id='keyboard_shortcuts.toggle_sensitivity' defaultMessage='to show/hide media' /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><kbd>up</kbd>, <kbd>k</kbd></td>
|
||||
<td><kbd>k</kbd></td>
|
||||
<td><FormattedMessage id='keyboard_shortcuts.up' defaultMessage='to move up in the list' /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><kbd>down</kbd>, <kbd>j</kbd></td>
|
||||
<td><kbd>j</kbd></td>
|
||||
<td><FormattedMessage id='keyboard_shortcuts.down' defaultMessage='to move down in the list' /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><kbd>l</kbd></td>
|
||||
<td><FormattedMessage id='keyboard_shortcuts.load_more' defaultMessage='Focus "Load more" button' /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><kbd>1</kbd>-<kbd>9</kbd></td>
|
||||
<td><FormattedMessage id='keyboard_shortcuts.column' defaultMessage='to focus a status in one of the columns' /></td>
|
||||
|
|
|
@ -143,6 +143,17 @@ class ColumnSettings extends PureComponent {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section role='group' aria-labelledby='notifications-quote'>
|
||||
<h3 id='notifications-quote'><FormattedMessage id='notifications.column_settings.quote' defaultMessage='Quotes:' /></h3>
|
||||
|
||||
<div className='column-settings__row'>
|
||||
<SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'quote']} onChange={onChange} label={alertStr} />
|
||||
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'quote']} onChange={this.onPushChange} label={pushStr} />}
|
||||
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'quote']} onChange={onChange} label={showStr} />
|
||||
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'quote']} onChange={onChange} label={soundStr} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section role='group' aria-labelledby='notifications-poll'>
|
||||
<h3 id='notifications-poll'><FormattedMessage id='notifications.column_settings.poll' defaultMessage='Poll results:' /></h3>
|
||||
|
||||
|
|
|
@ -8,9 +8,9 @@ import { Link, withRouter } from 'react-router-dom';
|
|||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
|
||||
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
|
||||
import FlagIcon from '@/material-icons/400-24px/flag-fill.svg?react';
|
||||
import FormatQuoteIcon from '@/material-icons/400-24px/format_quote.svg?react';
|
||||
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
|
||||
import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react';
|
||||
import PersonIcon from '@/material-icons/400-24px/person-fill.svg?react';
|
||||
|
@ -38,10 +38,12 @@ const messages = defineMessages({
|
|||
reblog: { id: 'notification.reblog', defaultMessage: '{name} boosted your post' },
|
||||
status: { id: 'notification.status', defaultMessage: '{name} just posted' },
|
||||
update: { id: 'notification.update', defaultMessage: '{name} edited a post' },
|
||||
quoted_update: { id: 'notification.quoted_update', defaultMessage: '{name} edited a post you have quoted' },
|
||||
adminSignUp: { id: 'notification.admin.sign_up', defaultMessage: '{name} signed up' },
|
||||
adminReport: { id: 'notification.admin.report', defaultMessage: '{name} reported {target}' },
|
||||
relationshipsSevered: { id: 'notification.relationships_severance_event', defaultMessage: 'Lost connections with {name}' },
|
||||
moderationWarning: { id: 'notification.moderation_warning', defaultMessage: 'You have received a moderation warning' },
|
||||
quote: { id: 'notification.label.quote', defaultMessage: '{name} quoted your post'}
|
||||
});
|
||||
|
||||
const notificationForScreenReader = (intl, message, timestamp) => {
|
||||
|
@ -56,8 +58,6 @@ class Notification extends ImmutablePureComponent {
|
|||
static propTypes = {
|
||||
notification: ImmutablePropTypes.map.isRequired,
|
||||
hidden: PropTypes.bool,
|
||||
onMoveUp: PropTypes.func.isRequired,
|
||||
onMoveDown: PropTypes.func.isRequired,
|
||||
onMention: PropTypes.func.isRequired,
|
||||
onFavourite: PropTypes.func.isRequired,
|
||||
onReblog: PropTypes.func.isRequired,
|
||||
|
@ -72,16 +72,6 @@ class Notification extends ImmutablePureComponent {
|
|||
...WithRouterPropTypes,
|
||||
};
|
||||
|
||||
handleMoveUp = () => {
|
||||
const { notification, onMoveUp } = this.props;
|
||||
onMoveUp(notification.get('id'));
|
||||
};
|
||||
|
||||
handleMoveDown = () => {
|
||||
const { notification, onMoveDown } = this.props;
|
||||
onMoveDown(notification.get('id'));
|
||||
};
|
||||
|
||||
handleOpen = () => {
|
||||
const { notification } = this.props;
|
||||
|
||||
|
@ -127,8 +117,6 @@ class Notification extends ImmutablePureComponent {
|
|||
mention: this.handleMention,
|
||||
open: this.handleOpen,
|
||||
openProfile: this.handleOpenProfile,
|
||||
moveUp: this.handleMoveUp,
|
||||
moveDown: this.handleMoveDown,
|
||||
toggleHidden: this.handleHotkeyToggleHidden,
|
||||
};
|
||||
}
|
||||
|
@ -179,8 +167,6 @@ class Notification extends ImmutablePureComponent {
|
|||
id={notification.get('status')}
|
||||
withDismiss
|
||||
hidden={this.props.hidden}
|
||||
onMoveDown={this.handleMoveDown}
|
||||
onMoveUp={this.handleMoveUp}
|
||||
contextType='notifications'
|
||||
getScrollPosition={this.props.getScrollPosition}
|
||||
updateScrollBottom={this.props.updateScrollBottom}
|
||||
|
@ -251,6 +237,36 @@ class Notification extends ImmutablePureComponent {
|
|||
);
|
||||
}
|
||||
|
||||
renderQuote (notification, link) {
|
||||
const { intl, unread } = this.props;
|
||||
|
||||
return (
|
||||
<Hotkeys handlers={this.getHandlers()}>
|
||||
<div className={classNames('notification notification-quote focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.quote, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
|
||||
<div className='notification__message'>
|
||||
<Icon id='quote' icon={FormatQuoteIcon} />
|
||||
|
||||
<span title={notification.get('created_at')}>
|
||||
<FormattedMessage id='notification.label.quote' defaultMessage='{name} quoted your post' values={{ name: link }} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<StatusQuoteManager
|
||||
id={notification.get('status')}
|
||||
account={notification.get('account')}
|
||||
muted
|
||||
withDismiss
|
||||
hidden={this.props.hidden}
|
||||
getScrollPosition={this.props.getScrollPosition}
|
||||
updateScrollBottom={this.props.updateScrollBottom}
|
||||
cachedMediaWidth={this.props.cachedMediaWidth}
|
||||
cacheMediaWidth={this.props.cacheMediaWidth}
|
||||
/>
|
||||
</div>
|
||||
</Hotkeys>
|
||||
);
|
||||
}
|
||||
|
||||
renderStatus (notification, link) {
|
||||
const { intl, unread, status } = this.props;
|
||||
|
||||
|
@ -321,6 +337,41 @@ class Notification extends ImmutablePureComponent {
|
|||
);
|
||||
}
|
||||
|
||||
renderQuotedUpdate (notification, link) {
|
||||
const { intl, unread, status } = this.props;
|
||||
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Hotkeys handlers={this.getHandlers()}>
|
||||
<div className={classNames('notification notification-update focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.update, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
|
||||
<div className='notification__message'>
|
||||
<Icon id='pencil' icon={EditIcon} />
|
||||
|
||||
<span title={notification.get('created_at')}>
|
||||
<FormattedMessage id='notification.quoted_update' defaultMessage='{name} edited a post you have quoted' values={{ name: link }} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<StatusQuoteManager
|
||||
id={notification.get('status')}
|
||||
account={notification.get('account')}
|
||||
contextType='notifications'
|
||||
muted
|
||||
withDismiss
|
||||
hidden={this.props.hidden}
|
||||
getScrollPosition={this.props.getScrollPosition}
|
||||
updateScrollBottom={this.props.updateScrollBottom}
|
||||
cachedMediaWidth={this.props.cachedMediaWidth}
|
||||
cacheMediaWidth={this.props.cacheMediaWidth}
|
||||
/>
|
||||
</div>
|
||||
</Hotkeys>
|
||||
);
|
||||
}
|
||||
|
||||
renderPoll (notification, account) {
|
||||
const { intl, unread, status } = this.props;
|
||||
const ownPoll = me === account.get('id');
|
||||
|
@ -467,6 +518,8 @@ class Notification extends ImmutablePureComponent {
|
|||
return this.renderFollowRequest(notification, account, link);
|
||||
case 'mention':
|
||||
return this.renderMention(notification);
|
||||
case 'quote':
|
||||
return this.renderQuote(notification);
|
||||
case 'favourite':
|
||||
return this.renderFavourite(notification, link);
|
||||
case 'reblog':
|
||||
|
@ -475,6 +528,8 @@ class Notification extends ImmutablePureComponent {
|
|||
return this.renderStatus(notification, link);
|
||||
case 'update':
|
||||
return this.renderUpdate(notification, link);
|
||||
case 'quoted_update':
|
||||
return this.renderQuotedUpdate(notification, link);
|
||||
case 'poll':
|
||||
return this.renderPoll(notification, account);
|
||||
case 'severed_relationships':
|
||||
|
|
|
@ -31,21 +31,6 @@ const messages = defineMessages({
|
|||
dismiss: { id: 'notification_requests.dismiss', defaultMessage: 'Dismiss' },
|
||||
});
|
||||
|
||||
const selectChild = (ref, index, alignTop) => {
|
||||
const container = ref.current.node;
|
||||
const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
|
||||
|
||||
if (element) {
|
||||
if (alignTop && container.scrollTop > element.offsetTop) {
|
||||
element.scrollIntoView(true);
|
||||
} else if (!alignTop && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
|
||||
element.scrollIntoView(false);
|
||||
}
|
||||
|
||||
element.focus();
|
||||
}
|
||||
};
|
||||
|
||||
export const NotificationRequest = ({ multiColumn, params: { id } }) => {
|
||||
const columnRef = useRef();
|
||||
const intl = useIntl();
|
||||
|
@ -74,16 +59,6 @@ export const NotificationRequest = ({ multiColumn, params: { id } }) => {
|
|||
dispatch(acceptNotificationRequest({ id }));
|
||||
}, [dispatch, id]);
|
||||
|
||||
const handleMoveUp = useCallback(id => {
|
||||
const elementIndex = notifications.findIndex(item => item !== null && item.get('id') === id) - 1;
|
||||
selectChild(columnRef, elementIndex, true);
|
||||
}, [columnRef, notifications]);
|
||||
|
||||
const handleMoveDown = useCallback(id => {
|
||||
const elementIndex = notifications.findIndex(item => item !== null && item.get('id') === id) + 1;
|
||||
selectChild(columnRef, elementIndex, false);
|
||||
}, [columnRef, notifications]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchNotificationRequest({ id }));
|
||||
}, [dispatch, id]);
|
||||
|
@ -146,8 +121,6 @@ export const NotificationRequest = ({ multiColumn, params: { id } }) => {
|
|||
key={item.get('id')}
|
||||
notification={item}
|
||||
accountId={item.get('account')}
|
||||
onMoveUp={handleMoveUp}
|
||||
onMoveDown={handleMoveDown}
|
||||
/>
|
||||
))}
|
||||
</ScrollableList>
|
||||
|
|
|
@ -15,6 +15,8 @@ import { NotificationFollowRequest } from './notification_follow_request';
|
|||
import { NotificationMention } from './notification_mention';
|
||||
import { NotificationModerationWarning } from './notification_moderation_warning';
|
||||
import { NotificationPoll } from './notification_poll';
|
||||
import { NotificationQuote } from './notification_quote';
|
||||
import { NotificationQuotedUpdate } from './notification_quoted_update';
|
||||
import { NotificationReblog } from './notification_reblog';
|
||||
import { NotificationSeveredRelationships } from './notification_severed_relationships';
|
||||
import { NotificationStatus } from './notification_status';
|
||||
|
@ -23,9 +25,7 @@ import { NotificationUpdate } from './notification_update';
|
|||
export const NotificationGroup: React.FC<{
|
||||
notificationGroupId: NotificationGroupModel['group_key'];
|
||||
unread: boolean;
|
||||
onMoveUp: (groupId: string) => void;
|
||||
onMoveDown: (groupId: string) => void;
|
||||
}> = ({ notificationGroupId, unread, onMoveUp, onMoveDown }) => {
|
||||
}> = ({ notificationGroupId, unread }) => {
|
||||
const notificationGroup = useAppSelector((state) =>
|
||||
state.notificationGroups.groups.find(
|
||||
(item) => item.type !== 'gap' && item.group_key === notificationGroupId,
|
||||
|
@ -41,14 +41,6 @@ export const NotificationGroup: React.FC<{
|
|||
|
||||
const handlers = useMemo(
|
||||
() => ({
|
||||
moveUp: () => {
|
||||
onMoveUp(notificationGroupId);
|
||||
},
|
||||
|
||||
moveDown: () => {
|
||||
onMoveDown(notificationGroupId);
|
||||
},
|
||||
|
||||
openProfile: () => {
|
||||
if (accountId) dispatch(navigateToProfile(accountId));
|
||||
},
|
||||
|
@ -57,7 +49,7 @@ export const NotificationGroup: React.FC<{
|
|||
if (accountId) dispatch(mentionComposeById(accountId));
|
||||
},
|
||||
}),
|
||||
[dispatch, notificationGroupId, accountId, onMoveUp, onMoveDown],
|
||||
[dispatch, accountId],
|
||||
);
|
||||
|
||||
if (!notificationGroup || notificationGroup.type === 'gap') return null;
|
||||
|
@ -91,6 +83,11 @@ export const NotificationGroup: React.FC<{
|
|||
<NotificationMention unread={unread} notification={notificationGroup} />
|
||||
);
|
||||
break;
|
||||
case 'quote':
|
||||
content = (
|
||||
<NotificationQuote unread={unread} notification={notificationGroup} />
|
||||
);
|
||||
break;
|
||||
case 'follow':
|
||||
content = (
|
||||
<NotificationFollow unread={unread} notification={notificationGroup} />
|
||||
|
@ -119,6 +116,14 @@ export const NotificationGroup: React.FC<{
|
|||
<NotificationUpdate unread={unread} notification={notificationGroup} />
|
||||
);
|
||||
break;
|
||||
case 'quoted_update':
|
||||
content = (
|
||||
<NotificationQuotedUpdate
|
||||
unread={unread}
|
||||
notification={notificationGroup}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'admin.sign_up':
|
||||
content = (
|
||||
<NotificationAdminSignUp
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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}
|
||||
/>
|
||||
);
|
|
@ -99,29 +99,6 @@ export const Notifications: React.FC<{
|
|||
|
||||
const columnRef = useRef<ColumnRef>(null);
|
||||
|
||||
const selectChild = useCallback((index: number, alignTop: boolean) => {
|
||||
const container = columnRef.current?.node as HTMLElement | undefined;
|
||||
|
||||
if (!container) return;
|
||||
|
||||
const element = container.querySelector<HTMLElement>(
|
||||
`article:nth-of-type(${index + 1}) .focusable`,
|
||||
);
|
||||
|
||||
if (element) {
|
||||
if (alignTop && container.scrollTop > element.offsetTop) {
|
||||
element.scrollIntoView(true);
|
||||
} else if (
|
||||
!alignTop &&
|
||||
container.scrollTop + container.clientHeight <
|
||||
element.offsetTop + element.offsetHeight
|
||||
) {
|
||||
element.scrollIntoView(false);
|
||||
}
|
||||
element.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Keep track of mounted components for unread notification handling
|
||||
useEffect(() => {
|
||||
void dispatch(mountNotifications());
|
||||
|
@ -187,28 +164,6 @@ export const Notifications: React.FC<{
|
|||
columnRef.current?.scrollTop();
|
||||
}, []);
|
||||
|
||||
const handleMoveUp = useCallback(
|
||||
(id: string) => {
|
||||
const elementIndex =
|
||||
notifications.findIndex(
|
||||
(item) => item.type !== 'gap' && item.group_key === id,
|
||||
) - 1;
|
||||
selectChild(elementIndex, true);
|
||||
},
|
||||
[notifications, selectChild],
|
||||
);
|
||||
|
||||
const handleMoveDown = useCallback(
|
||||
(id: string) => {
|
||||
const elementIndex =
|
||||
notifications.findIndex(
|
||||
(item) => item.type !== 'gap' && item.group_key === id,
|
||||
) + 1;
|
||||
selectChild(elementIndex, false);
|
||||
},
|
||||
[notifications, selectChild],
|
||||
);
|
||||
|
||||
const handleMarkAsRead = useCallback(() => {
|
||||
dispatch(markNotificationsAsRead());
|
||||
void dispatch(submitMarkers({ immediate: true }));
|
||||
|
@ -241,8 +196,6 @@ export const Notifications: React.FC<{
|
|||
<NotificationGroup
|
||||
key={item.group_key}
|
||||
notificationGroupId={item.group_key}
|
||||
onMoveUp={handleMoveUp}
|
||||
onMoveDown={handleMoveDown}
|
||||
unread={
|
||||
lastReadId !== '0' &&
|
||||
!!item.page_max_id &&
|
||||
|
@ -251,15 +204,7 @@ export const Notifications: React.FC<{
|
|||
/>
|
||||
),
|
||||
);
|
||||
}, [
|
||||
notifications,
|
||||
isLoading,
|
||||
hasMore,
|
||||
lastReadId,
|
||||
handleLoadGap,
|
||||
handleMoveUp,
|
||||
handleMoveDown,
|
||||
]);
|
||||
}, [notifications, isLoading, hasMore, lastReadId, handleLoadGap]);
|
||||
|
||||
const prepend = (
|
||||
<>
|
||||
|
|
|
@ -233,7 +233,10 @@ export const Footer: React.FC<{
|
|||
icon='retweet'
|
||||
iconComponent={reblogIconComponent}
|
||||
onClick={handleReblogClick}
|
||||
counter={status.get('reblogs_count') as number}
|
||||
counter={
|
||||
(status.get('reblogs_count') as number) +
|
||||
(status.get('quotes_count') as number)
|
||||
}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
|
|
113
app/javascript/mastodon/features/quotes/index.tsx
Normal file
113
app/javascript/mastodon/features/quotes/index.tsx
Normal 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
Loading…
Reference in New Issue
Block a user