Compare commits

..

2 Commits

Author SHA1 Message Date
Claire
855c3be3d7 Revert "Merge upstream changes up to df72a2dbbec8173515568c02427076ebff5c2297" 2025-09-25 18:29:45 +02:00
Claire
805a19e288 Merge upstream changes up to df72a2dbbe (#3202)
* Refactor emoji GIF animation (#36165)

* New Crowdin Translations (automated) (#36228)

Co-authored-by: GitHub Actions <noreply@github.com>

* Update to puma 7 (#36238)

* Fix unfortunate action button wrapping in admin area (#36247)

* Remove the `outgoing_quotes` feature flag, making the feature unconditional (#36130)

* New Crowdin Translations (automated) (#36246)

Co-authored-by: GitHub Actions <noreply@github.com>

* Highlight newly added replies in thread view (#36237)

* Fix missed event handler (#36248)

* Implement new design for "Refetch all" (#36172)

* Fix Private Messages self-quoting private posts being changed to followers-only (#36249)

* [Glitch] Refactor emoji GIF animation

Port 6bd90940b6 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>

* [Glitch] Fix unfortunate action button wrapping in admin area

Port 6cbc857ee0 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>

* [Glitch] Remove the `outgoing_quotes` feature flag, making the feature unconditional

Port e1f7847b64 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>

* [Glitch] Highlight newly added replies in thread view

Port 059bf1e980 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>

* [Glitch] Fix missed event handler

Port 29d9f81e42 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>

* [Glitch] Implement new design for "Refetch all"

Port 3a81ee8f5b to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>

* Fix newly-highlighted replies not being interactable (#36256)

* [Glitch] Fix newly-highlighted replies not being interactable

Port df72a2dbbe to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>

---------

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
Co-authored-by: Echo <ChaosExAnima@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: GitHub Actions <noreply@github.com>
Co-authored-by: David Roetzel <david@roetzel.de>
Co-authored-by: diondiondion <mail@diondiondion.com>
2025-09-25 10:23:08 +02:00
592 changed files with 5804 additions and 11601 deletions

View File

@@ -321,6 +321,9 @@ SESSION_RETENTION_PERIOD=31556952
# Fetch All Replies Behavior
# --------------------------
# When a user expands a post (DetailedStatus view), fetch all of its replies
# (default: false)
FETCH_REPLIES_ENABLED=false
# Period to wait between fetching replies (in minutes)
FETCH_REPLIES_COOLDOWN_MINUTES=15

View File

@@ -9,7 +9,7 @@ permissions:
jobs:
download-translations-stable:
runs-on: ubuntu-latest
if: github.repository == 'glitch-soc/mastodon'
if: github.repository == 'mastodon/mastodon'
steps:
- name: Checkout

2
.nvmrc
View File

@@ -1 +1 @@
22.20
22.19

View File

@@ -1 +1 @@
3.4.7
3.4.6

View File

@@ -50,13 +50,9 @@ const preview: Preview = {
locale: 'en',
},
decorators: [
(Story, { parameters, globals, args }) => {
// Get the locale from the global toolbar
// and merge it with any parameters or args state.
(Story, { parameters, globals }) => {
const { locale } = globals as { locale: string };
const { state = {} } = parameters;
const { state: argsState = {} } = args;
const reducer = reducerWithInitialState(
{
meta: {
@@ -64,9 +60,7 @@ const preview: Preview = {
},
},
state as Record<string, unknown>,
argsState as Record<string, unknown>,
);
const store = configureStore({
reducer,
middleware(getDefaultMiddleware) {

View File

@@ -2,130 +2,6 @@
All notable changes to this project will be documented in this file.
## [4.5.0] - UNRELEASED
### Added
- **Add support for allowing and authoring quotes** (#35355, #35578, #35614, #35618, #35624, #35626, #35652, #35629, #35665, #35653, #35670, #35677, #35690, #35697, #35689, #35699, #35700, #35701, #35709, #35714, #35713, #35715, #35725, #35749, #35769, #35780, #35762, #35804, #35808, #35805, #35819, #35824, #35828, #35822, #35835, #35865, #35860, #35832, #35891, #35894, #35895, #35820, #35917, #35924, #35925, #35914, #35930, #35941, #35939, #35948, #35955, #35967, #35990, #35991, #35975, #35971, #36002, #35986, #36031, #36034, #36038, #36054, #36052, #36055, #36065, #36068, #36083, #36087, #36080, #36091, #36090, #36118, #36119, #36128, #36094, #36129, #36138, #36132, #36151, #36158, #36171, #36194, #36220, #36169, #36130, #36249, #36153, #36299, #36291, #36301, #36315, #36317, #36364, #36383, #36381, #36459, #36464, and #36461 by @ChaosExAnima, @ClearlyClaire, @Lycolia, @diondiondion, and @tribela)\
This includes a revamp of the composer interface.\
See https://blog.joinmastodon.org/2025/09/introducing-quote-posts/ for a user-centric overview of the feature, and https://docs.joinmastodon.org/client/quotes/ for API documentation.
- **Add support for fetching and refreshing replies to the web UI** (#35210, #35496, #35575, #35500, #35577, #35602, #35603, #35654, #36141, #36237, #36172, #36256, #36271, #36334, #36382, and #36239 by @ClearlyClaire, @Gargron, and @diondiondion)
- **Add ability to block words in usernames** (#35407, #35655, and #35806 by @ClearlyClaire and @Gargron)
- Add support for displaying link previews for Admin UI (#35958 by @ThisIsMissEm)
- Add support for dynamic viewport height (#36272 by @e1berd)
- Add support for numeric-based URIs for new local accounts (#32724, #36304, #36316, and #36365 by @ClearlyClaire)
- Add Traditional Mongolian to posting languages (#36196 by @shimon1024)
- Add example post with manual quote approval policy to `dev:populate_sample_data` (#36099 by @ClearlyClaire)
- Add server-side support for handling posts with a quote policy allowing followers to quote (#36093 and #36127 by @ClearlyClaire)
- Add schema.org markup to SEO-enabled posts (#36075 by @Gargron)
- Add migration to fill unset default quote policy based on default post privacy (#36041 by @ClearlyClaire)
- Add support for exposing conversation context for new public conversations according to FEP-7888 (#35959 and #36064 by @ClearlyClaire and @jesseplusplus)
- Add digest re-check before removing followers in synchronization mechanism (#34273 by @ClearlyClaire)
- Add “Posting defaults” setting page, moving existing settings from “Other” (#35896, #36033, #35966, #35969, and #36084 by @ClearlyClaire and @diondiondion)
- Add support for displaying Valkey version on admin dashboard (#35785 by @ykzts)
- Add delivery failure tracking and handling to FASP jobs (#35625, #35628, and #35723 by @oneiros)
- Add example of quote post with a preview card to development sample data (#35616 by @ClearlyClaire)
- Add second set of blocked text that applies to accounts regardless of account age for spam-blocking (#35563 by @ClearlyClaire)
- Add experimental feature to select custom emoji rendering (#35229, #35282, #35253, #35424, #35473, #35483, #35505, #35568, #35605, #35659, #35664, #35739, #35985, #36051, #36071, #36137, #36165, #36248, #36262, #36275, #36293, #36341, #36342, #36366, #36377, #36378, #36385, #36393, #36397, #36403, #36413, #36410, #36454, and #36402 by @ChaosExAnima and @braddunbar)\
This also completely reworks the processing and rendering of emojis and server-rendered HTML in statuses and other places.
### Changed
- Change confirmation dialogs for follow button actions “unfollow”, “unblock”, and “withdraw request” (#36289 by @diondiondion)
- Change “Follow” button labels (#36264 by @diondiondion)
- Change display of content warnings in Admin UI (#35935 by @ThisIsMissEm)
- Change index on `follows` table to improve performance of some queries (#36374 by @ClearlyClaire)
- Change links to accounts in settings and moderation views to link to local view unless account is suspended (#36340 by @diondiondion)
- Change redirection for denied registration from web app to sign-in page with error message (#36384 by @ClearlyClaire)
- Change `timeline_preview` setting into four more granular settings (#36338 and #36467 by @ClearlyClaire)
- Change wording and design of interaction dialog to simplify it (#36124 by @diondiondion)
- Change dropdown menus to allow disabled items to be focused (#36078 by @diondiondion)
- Change modal background colours in light mode (#36069 by @diondiondion)
- Change “Posting defaults” settings page to enforce `nobody` quote policy for `private` default visibility (#36040 by @ClearlyClaire)
- Change description of “Quiet public” (#36032 by @ClearlyClaire)
- Change “Boost with original visibility” to “Share again with your followers” (#36035 by @ClearlyClaire)
- Change handling of push subscriptions to automatically delete invalid ones on delivery (#35987 by @ThisIsMissEm)
- Change design of quote posts in web UI (#35584 and #35834 by @ClearlyClaire and @Gargron)
- Change auditable accounts to be sorted by username in admin action logs interface (#35272 by @breadtk)
- Change order of translation restoration and service credit on post card (#33619 by @colindean)
- Change position of add more to be inside table toolbar on reports (#35963 by @ThisIsMissEm)
### Fixed
- Fix rendering of poll options in status history modal (#35633 by @ThisIsMissEm)
- Fix “mute” button being displayed to unauthenticated visitors in hashtag dropdown (#36353 by @mkljczk)
- Fix overflow handling of `.more-from-author` (#36310 by @edent)
- Fix unfortunate action button wrapping in admin area (#36247 by @diondiondion)
- Fix translate button width in Safari (#36164 and #36216 by @diondiondion)
- Fix login page linking to other pages within OAuth authorization flow (#36115 by @Gargron)
- Fix stale search results being displayed in Web UI while new query is in progress (#36053 by @ChaosExAnima)
- Fix YouTube iframe not being able to start at a defined time (#26584 by @BrunoViveiros)
- Fix banned text being able to be circumvented via unicode (#35978 by @Gargron)
- Fix batch table toolbar displaying under status media (#35962 by @ThisIsMissEm)
- Fix incorrect RSS feed MIME type in gzip_types directive (#35562 by @iioflow)
- Fix 404 error after deleting status from detail view (#35800) (#35881 by @crafkaz)
- Fix feeds keyboard navigation issues (#35853, #35864, and #36267 by @braddunbar and @diondiondion)
- Fix layout shift caused by “Who to follow” widget (#35861 by @diondiondion)
- Fix Vagrantfile (#35765 by @ClearlyClaire)
- Fix reply indicator displaying wrong avatar in rare cases (#35756 by @ClearlyClaire)
- Fix `Chewy::UndefinedUpdateStrategy` in `dev:populate_sample_data` task when Elasticsearch is enabled (#35615 by @ClearlyClaire)
- Fix unnecessary account note addition for already-muted moved-to users (#35566 by @mjankowski)
- Fix seeded admin user creation failing on specific configurations (#35565 by @oneiros)
- Fix media modal images in Web UI having redundant `title` attribute (#35468 by @mayank99)
- Fix inconsistent default privacy post setting when unset in settings (#35422 by @oneiros)
- Fix glitchy status keyboard navigation (#35455 and #35504 by @diondiondion)
- Fix post being submitted when pressing “Enter” in the CW field (#35445 by @diondiondion)
## [4.4.7] - 2025-10-15
### Fixed
- Fix forwarder being called with `nil` status when quote post is soft-deleted (#36463 by @ClearlyClaire)
- Fix moderation warning e-mails that include posts (#36462 by @ClearlyClaire)
- Fix allow_referrer_origin typo (#36460 by @ShadowJonathan)
## [4.4.6] - 2025-10-13
### Security
- Update dependencies `rack` and `uri`
- Fix streaming server connection not being closed on user suspension (by @ThisIsMissEm, [GHSA-r2fh-jr9c-9pxh](https://github.com/mastodon/mastodon/security/advisories/GHSA-r2fh-jr9c-9pxh))
- Fix password change through admin CLI not invalidating existing sessions and access tokens (by @ThisIsMissEm, [GHSA-f3q3-rmf7-9655](https://github.com/mastodon/mastodon/security/advisories/GHSA-f3q3-rmf7-9655))
- Fix streaming server allowing access to public timelines even without the `read` or `read:statuses` OAuth scopes (by @ThisIsMissEm, [GHSA-7gwh-mw97-qjgp](https://github.com/mastodon/mastodon/security/advisories/GHSA-7gwh-mw97-qjgp))
### Added
- Add support for processing quotes of deleted posts signaled through a `Tombstone` (#36381 by @ClearlyClaire)
### Fixed
- Fix quote post state sometimes not being updated through streaming server (#36408 by @ClearlyClaire)
- Fix inconsistent “pending tags” count on admin dashboard (#36404 by @mjankowski)
- Fix JSON payload being potentially mutated when processing interaction policies (#36392 by @ClearlyClaire)
- Fix quotes not being displayed in email notifications (#36379 by @diondiondion)
- Fix redirect to external object when URL is missing or malformed (#36347 by @ClearlyClaire)
- Fix quotes not being displayed in the featured carousel (#36335 by @diondiondion)
## [4.4.5] - 2025-09-23
### Security
- Update dependencies
### Added
- Add support for `has:quote` in search (#36217 by @ClearlyClaire)
### Changed
- Change quoted posts from silenced accounts to use a click-through rather than being hidden (#36166 and #36167 by @ClearlyClaire)
### Fixed
- Fix processing of out-of-order `Update` as implicit updates (#36190 by @ClearlyClaire)
- Fix getting `Create` and `Update` out of order (#36176 by @ClearlyClaire)
- Fix quotes with Content Warnings but no text being shown without Content Warnings (#36150 by @ClearlyClaire)
## [4.4.4] - 2025-09-16
### Security

View File

@@ -13,7 +13,7 @@ ARG BASE_REGISTRY="docker.io"
# Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.4.x"]
# renovate: datasource=docker depName=docker.io/ruby
ARG RUBY_VERSION="3.4.7"
ARG RUBY_VERSION="3.4.6"
# # 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"

35
Gemfile
View File

@@ -4,12 +4,12 @@ source 'https://rubygems.org'
ruby '>= 3.2.0', '< 3.5.0'
gem 'propshaft'
gem 'puma', '~> 7.0'
gem 'puma', '~> 6.3'
gem 'rails', '~> 8.0'
gem 'thor', '~> 1.2'
gem 'dotenv'
gem 'haml-rails', '~>3.0'
gem 'haml-rails', '~>2.0'
gem 'pg', '~> 1.5'
gem 'pghero'
@@ -105,20 +105,20 @@ gem 'prometheus_exporter', '~> 2.2', require: false
gem 'opentelemetry-api', '~> 1.7.0'
group :opentelemetry do
gem 'opentelemetry-exporter-otlp', '~> 0.31.0', require: false
gem 'opentelemetry-instrumentation-active_job', '~> 0.9.0', require: false
gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.23.0', require: false
gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.23.0', require: false
gem 'opentelemetry-instrumentation-excon', '~> 0.25.0', require: false
gem 'opentelemetry-instrumentation-faraday', '~> 0.29.0', require: false
gem 'opentelemetry-instrumentation-http', '~> 0.26.0', require: false
gem 'opentelemetry-instrumentation-http_client', '~> 0.25.0', require: false
gem 'opentelemetry-instrumentation-net_http', '~> 0.25.0', require: false
gem 'opentelemetry-instrumentation-pg', '~> 0.31.0', require: false
gem 'opentelemetry-instrumentation-rack', '~> 0.28.0', require: false
gem 'opentelemetry-instrumentation-rails', '~> 0.38.0', require: false
gem 'opentelemetry-instrumentation-redis', '~> 0.27.0', require: false
gem 'opentelemetry-instrumentation-sidekiq', '~> 0.27.0', require: false
gem 'opentelemetry-exporter-otlp', '~> 0.30.0', require: false
gem 'opentelemetry-instrumentation-active_job', '~> 0.8.0', require: false
gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.22.0', require: false
gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.22.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.24.0', require: false
gem 'opentelemetry-instrumentation-net_http', '~> 0.24.0', require: false
gem 'opentelemetry-instrumentation-pg', '~> 0.30.0', require: false
gem 'opentelemetry-instrumentation-rack', '~> 0.27.0', require: false
gem 'opentelemetry-instrumentation-rails', '~> 0.37.0', require: false
gem 'opentelemetry-instrumentation-redis', '~> 0.26.0', require: false
gem 'opentelemetry-instrumentation-sidekiq', '~> 0.26.0', require: false
gem 'opentelemetry-sdk', '~> 1.4', require: false
end
@@ -160,9 +160,6 @@ group :test do
# Stub web requests for specs
gem 'webmock', '~> 3.18'
# Websocket driver for testing integration between rails/sidekiq and streaming
gem 'websocket-driver', '~> 0.8', require: false
end
group :development do

View File

@@ -10,29 +10,29 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (8.0.3)
actionpack (= 8.0.3)
activesupport (= 8.0.3)
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.3)
actionpack (= 8.0.3)
activejob (= 8.0.3)
activerecord (= 8.0.3)
activestorage (= 8.0.3)
activesupport (= 8.0.3)
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.3)
actionpack (= 8.0.3)
actionview (= 8.0.3)
activejob (= 8.0.3)
activesupport (= 8.0.3)
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.3)
actionview (= 8.0.3)
activesupport (= 8.0.3)
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 +40,15 @@ GEM
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (8.0.3)
actionpack (= 8.0.3)
activerecord (= 8.0.3)
activestorage (= 8.0.3)
activesupport (= 8.0.3)
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.3)
activesupport (= 8.0.3)
actionview (8.0.2.1)
activesupport (= 8.0.2.1)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
@@ -58,22 +58,22 @@ GEM
activemodel (>= 4.1)
case_transform (>= 0.2)
jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
activejob (8.0.3)
activesupport (= 8.0.3)
activejob (8.0.2.1)
activesupport (= 8.0.2.1)
globalid (>= 0.3.6)
activemodel (8.0.3)
activesupport (= 8.0.3)
activerecord (8.0.3)
activemodel (= 8.0.3)
activesupport (= 8.0.3)
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.3)
actionpack (= 8.0.3)
activejob (= 8.0.3)
activerecord (= 8.0.3)
activesupport (= 8.0.3)
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.3)
activesupport (8.0.2.1)
base64
benchmark (>= 0.3)
bigdecimal
@@ -96,7 +96,7 @@ GEM
ast (2.4.3)
attr_required (1.0.2)
aws-eventstream (1.4.0)
aws-partitions (1.1168.0)
aws-partitions (1.1135.0)
aws-sdk-core (3.215.1)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
@@ -121,7 +121,7 @@ GEM
erubi (>= 1.0.0)
rack (>= 0.9.0)
rouge (>= 1.0.0)
bigdecimal (3.3.1)
bigdecimal (3.2.3)
bindata (2.5.1)
binding_of_caller (1.0.1)
debug_inspector (>= 1.2.0)
@@ -150,7 +150,7 @@ GEM
playwright-ruby-client (>= 1.16.0)
case_transform (0.2)
activesupport
cbor (0.5.10.1)
cbor (0.5.9.8)
cgi (0.4.2)
charlock_holmes (0.7.9)
chewy (7.6.0)
@@ -207,7 +207,7 @@ GEM
railties (>= 5)
dotenv (3.1.8)
drb (2.2.3)
dry-cli (1.3.0)
dry-cli (1.2.0)
elasticsearch (7.17.11)
elasticsearch-api (= 7.17.11)
elasticsearch-transport (= 7.17.11)
@@ -226,18 +226,18 @@ GEM
activemodel
erb (5.0.2)
erubi (1.13.1)
et-orbi (1.4.0)
et-orbi (1.2.11)
tzinfo
excon (1.3.0)
excon (1.2.8)
logger
fabrication (3.0.0)
faker (3.5.2)
i18n (>= 1.8.11, < 2)
faraday (2.14.0)
faraday (2.13.4)
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-follow_redirects (0.4.0)
faraday-follow_redirects (0.3.0)
faraday (>= 1, < 3)
faraday-httpclient (2.0.2)
httpclient (>= 2.2)
@@ -266,24 +266,23 @@ GEM
fog-openstack (1.1.5)
fog-core (~> 2.1)
fog-json (>= 1.0)
formatador (1.2.1)
reline
formatador (1.1.1)
forwardable (1.3.3)
fugit (1.12.0)
et-orbi (~> 1.4)
fugit (1.11.1)
et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4)
globalid (1.3.0)
globalid (1.2.1)
activesupport (>= 6.1)
google-protobuf (4.32.1)
google-protobuf (4.31.1)
bigdecimal
rake (>= 13)
googleapis-common-protos-types (1.22.0)
google-protobuf (~> 4.26)
googleapis-common-protos-types (1.20.0)
google-protobuf (>= 3.18, < 5.a)
haml (6.3.0)
temple (>= 0.8.2)
thor
tilt
haml-rails (3.0.0)
haml-rails (2.1.0)
actionpack (>= 5.1)
activesupport (>= 5.1)
haml (>= 4.0.6)
@@ -294,15 +293,15 @@ GEM
rainbow
rubocop (>= 1.0)
sysexits (~> 1.1)
hashdiff (1.2.1)
hashdiff (1.2.0)
hashie (5.0.0)
hcaptcha (7.1.0)
json
highline (3.1.2)
reline
hiredis (0.6.3)
hiredis-client (0.26.1)
redis-client (= 0.26.1)
hiredis-client (0.25.3)
redis-client (= 0.25.3)
hkdf (0.3.0)
htmlentities (4.3.4)
http (5.3.1)
@@ -310,7 +309,7 @@ GEM
http-cookie (~> 1.0)
http-form_data (~> 2.2)
llhttp-ffi (~> 0.5.0)
http-cookie (1.1.0)
http-cookie (1.0.8)
domain_name (~> 0.5)
http-form_data (2.3.0)
http_accept_language (2.1.1)
@@ -346,9 +345,9 @@ GEM
azure-blob (~> 0.5.2)
hashie (~> 5.0)
jmespath (1.6.2)
json (2.15.1)
json (2.13.2)
json-canonicalization (1.0.0)
json-jwt (1.17.0)
json-jwt (1.16.7)
activesupport (>= 4.2)
aes_key_wrap
base64
@@ -439,7 +438,7 @@ GEM
mime-types (3.7.0)
logger
mime-types-data (~> 3.2025, >= 3.2025.0507)
mime-types-data (3.2025.0924)
mime-types-data (3.2025.0916)
mini_mime (1.1.5)
mini_portile2 (2.8.9)
minitest (5.25.5)
@@ -448,7 +447,7 @@ GEM
mutex_m (0.3.0)
net-http (0.6.0)
uri
net-imap (0.5.12)
net-imap (0.5.9)
date
net-protocol
net-ldap (0.20.0)
@@ -467,9 +466,8 @@ GEM
oj (3.16.11)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
omniauth (2.1.4)
omniauth (2.1.3)
hashie (>= 3.4.6)
logger
rack (>= 2.2.3)
rack-protection
omniauth-cas (3.0.2)
@@ -498,77 +496,102 @@ GEM
tzinfo
validate_url
webfinger (~> 2.0)
openssl (3.3.1)
openssl (3.3.0)
openssl-signature_algorithm (1.3.0)
openssl (> 2.0)
opentelemetry-api (1.7.0)
opentelemetry-common (0.23.0)
opentelemetry-common (0.22.0)
opentelemetry-api (~> 1.0)
opentelemetry-exporter-otlp (0.31.0)
opentelemetry-exporter-otlp (0.30.0)
google-protobuf (>= 3.18)
googleapis-common-protos-types (~> 1.3)
opentelemetry-api (~> 1.1)
opentelemetry-common (~> 0.20)
opentelemetry-sdk (~> 1.2)
opentelemetry-semantic_conventions
opentelemetry-helpers-sql (0.2.0)
opentelemetry-api (~> 1.7)
opentelemetry-helpers-sql (0.1.1)
opentelemetry-api (~> 1.0)
opentelemetry-helpers-sql-obfuscation (0.3.0)
opentelemetry-common (~> 0.21)
opentelemetry-instrumentation-action_mailer (0.5.0)
opentelemetry-instrumentation-action_mailer (0.4.0)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-active_support (~> 0.7)
opentelemetry-instrumentation-action_pack (0.14.1)
opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-action_pack (0.13.0)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-rack (~> 0.21)
opentelemetry-instrumentation-action_view (0.10.0)
opentelemetry-instrumentation-action_view (0.9.0)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-active_support (~> 0.7)
opentelemetry-instrumentation-active_job (0.9.2)
opentelemetry-instrumentation-base (~> 0.24)
opentelemetry-instrumentation-active_model_serializers (0.23.0)
opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-active_job (0.8.0)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-active_model_serializers (0.22.0)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-active_support (>= 0.7.0)
opentelemetry-instrumentation-active_record (0.10.1)
opentelemetry-instrumentation-base (~> 0.24)
opentelemetry-instrumentation-active_storage (0.2.0)
opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-active_record (0.9.0)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-active_storage (0.1.1)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-active_support (~> 0.7)
opentelemetry-instrumentation-active_support (0.9.1)
opentelemetry-instrumentation-base (~> 0.24)
opentelemetry-instrumentation-base (0.24.0)
opentelemetry-api (~> 1.7)
opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-active_support (0.8.0)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-base (0.23.0)
opentelemetry-api (~> 1.0)
opentelemetry-common (~> 0.21)
opentelemetry-registry (~> 0.1)
opentelemetry-instrumentation-concurrent_ruby (0.23.1)
opentelemetry-instrumentation-base (~> 0.24)
opentelemetry-instrumentation-excon (0.25.2)
opentelemetry-instrumentation-base (~> 0.24)
opentelemetry-instrumentation-faraday (0.29.1)
opentelemetry-instrumentation-base (~> 0.24)
opentelemetry-instrumentation-http (0.26.1)
opentelemetry-instrumentation-base (~> 0.24)
opentelemetry-instrumentation-http_client (0.25.1)
opentelemetry-instrumentation-base (~> 0.24)
opentelemetry-instrumentation-net_http (0.25.1)
opentelemetry-instrumentation-base (~> 0.24)
opentelemetry-instrumentation-pg (0.31.1)
opentelemetry-instrumentation-concurrent_ruby (0.22.0)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-excon (0.24.0)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.23.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.24.0)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-net_http (0.24.0)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-pg (0.30.1)
opentelemetry-api (~> 1.0)
opentelemetry-helpers-sql
opentelemetry-helpers-sql-obfuscation
opentelemetry-instrumentation-base (~> 0.24)
opentelemetry-instrumentation-rack (0.28.2)
opentelemetry-instrumentation-base (~> 0.24)
opentelemetry-instrumentation-rails (0.38.0)
opentelemetry-instrumentation-action_mailer (~> 0.4)
opentelemetry-instrumentation-action_pack (~> 0.13)
opentelemetry-instrumentation-action_view (~> 0.9)
opentelemetry-instrumentation-active_job (~> 0.8)
opentelemetry-instrumentation-active_record (~> 0.9)
opentelemetry-instrumentation-active_storage (~> 0.1)
opentelemetry-instrumentation-active_support (~> 0.8)
opentelemetry-instrumentation-concurrent_ruby (~> 0.22)
opentelemetry-instrumentation-redis (0.27.1)
opentelemetry-instrumentation-base (~> 0.24)
opentelemetry-instrumentation-sidekiq (0.27.1)
opentelemetry-instrumentation-base (~> 0.24)
opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-rack (0.27.1)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-rails (0.37.0)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-action_mailer (~> 0.4.0)
opentelemetry-instrumentation-action_pack (~> 0.13.0)
opentelemetry-instrumentation-action_view (~> 0.9.0)
opentelemetry-instrumentation-active_job (~> 0.8.0)
opentelemetry-instrumentation-active_record (~> 0.9.0)
opentelemetry-instrumentation-active_storage (~> 0.1.0)
opentelemetry-instrumentation-active_support (~> 0.8.0)
opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-concurrent_ruby (~> 0.22.0)
opentelemetry-instrumentation-redis (0.26.1)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-sidekiq (0.26.1)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-registry (0.4.0)
opentelemetry-api (~> 1.1)
opentelemetry-sdk (1.10.0)
opentelemetry-sdk (1.9.0)
opentelemetry-api (~> 1.1)
opentelemetry-common (~> 0.20)
opentelemetry-registry (~> 0.2)
@@ -592,7 +615,7 @@ GEM
playwright-ruby-client (1.55.0)
concurrent-ruby (>= 1.1.6)
mime-types (>= 3.0)
pp (0.6.3)
pp (0.6.2)
prettyprint
premailer (1.27.0)
addressable
@@ -603,10 +626,10 @@ GEM
net-smtp
premailer (~> 1.7, >= 1.7.9)
prettyprint (0.2.0)
prism (1.5.1)
prism (1.4.0)
prometheus_exporter (2.3.0)
webrick
propshaft (1.3.1)
propshaft (1.2.1)
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
rack
@@ -614,13 +637,13 @@ GEM
date
stringio
public_suffix (6.0.2)
puma (7.0.4)
puma (6.6.1)
nio4r (~> 2.0)
pundit (2.5.2)
pundit (2.5.1)
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.2.3)
rack (3.1.16)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-cors (3.0.0)
@@ -646,20 +669,20 @@ GEM
rack (>= 1.3)
rackup (2.2.1)
rack (>= 3)
rails (8.0.3)
actioncable (= 8.0.3)
actionmailbox (= 8.0.3)
actionmailer (= 8.0.3)
actionpack (= 8.0.3)
actiontext (= 8.0.3)
actionview (= 8.0.3)
activejob (= 8.0.3)
activemodel (= 8.0.3)
activerecord (= 8.0.3)
activestorage (= 8.0.3)
activesupport (= 8.0.3)
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.3)
railties (= 8.0.2.1)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
@@ -670,14 +693,13 @@ GEM
rails-i18n (8.0.2)
i18n (>= 0.7, < 2)
railties (>= 8.0.0, < 9)
railties (8.0.3)
actionpack (= 8.0.3)
activesupport (= 8.0.3)
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)
thor (~> 1.0, >= 1.2.2)
tsort (>= 0.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.3.0)
@@ -690,17 +712,16 @@ GEM
readline (~> 0.0)
rdf-normalize (0.7.0)
rdf (~> 3.3)
rdoc (6.15.0)
rdoc (6.14.2)
erb
psych (>= 4.0.0)
tsort
readline (0.0.4)
reline
redcarpet (3.6.1)
redis (4.8.1)
redis-client (0.26.1)
redis-client (0.25.3)
connection_pool
regexp_parser (2.11.3)
regexp_parser (2.11.2)
reline (0.6.2)
io-console (~> 0.5)
request_store (1.7.0)
@@ -710,7 +731,7 @@ GEM
railties (>= 5.2)
rexml (3.4.4)
rotp (6.3.0)
rouge (4.6.1)
rouge (4.6.0)
rpam2 (4.0.2)
rqrcode (3.1.0)
chunky_png (~> 1.0)
@@ -743,8 +764,8 @@ GEM
rspec-expectations (~> 3.0)
rspec-mocks (~> 3.0)
sidekiq (>= 5, < 9)
rspec-support (3.13.6)
rubocop (1.81.1)
rspec-support (3.13.4)
rubocop (1.80.2)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@@ -752,10 +773,10 @@ GEM
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.47.1, < 2.0)
rubocop-ast (>= 1.46.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.47.1)
rubocop-ast (1.46.0)
parser (>= 3.3.7.2)
prism (~> 1.4)
rubocop-capybara (2.22.1)
@@ -768,7 +789,7 @@ GEM
lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.44.0, < 2.0)
rubocop-rails (2.33.4)
rubocop-rails (2.33.3)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
@@ -790,11 +811,11 @@ GEM
ruby-vips (2.2.5)
ffi (~> 1.12)
logger
rubyzip (3.1.1)
rubyzip (3.1.0)
rufus-scheduler (3.9.2)
fugit (~> 1.1, >= 1.11.1)
safety_net_attestation (0.5.0)
jwt (>= 2.0, < 4.0)
safety_net_attestation (0.4.0)
jwt (~> 2.0)
sanitize (7.0.0)
crass (~> 1.0.2)
nokogiri (>= 1.16.8)
@@ -804,7 +825,7 @@ GEM
securerandom (0.4.1)
shoulda-matchers (6.5.0)
activesupport (>= 5.2.0)
sidekiq (8.0.8)
sidekiq (8.0.7)
connection_pool (>= 2.5.0)
json (>= 2.9.0)
logger (>= 1.6.2)
@@ -837,7 +858,7 @@ GEM
stoplight (5.3.8)
zeitwerk
stringio (3.1.7)
strong_migrations (2.5.1)
strong_migrations (2.5.0)
activerecord (>= 7.1)
swd (2.0.3)
activesupport (>= 3)
@@ -858,7 +879,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)
@@ -879,10 +899,10 @@ GEM
unf (0.1.4)
unf_ext
unf_ext (0.0.9.1)
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.1.0)
uri (1.0.4)
unicode-display_width (3.1.5)
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
uri (1.0.3)
useragent (0.16.11)
validate_url (1.0.15)
activemodel (>= 3.0.0)
@@ -898,13 +918,13 @@ GEM
zeitwerk (~> 2.2)
warden (1.2.9)
rack (>= 2.0.9)
webauthn (3.4.2)
webauthn (3.4.1)
android_key_attestation (~> 0.3.0)
bindata (~> 2.4)
cbor (~> 0.5.9)
cose (~> 1.1)
openssl (>= 2.2)
safety_net_attestation (~> 0.5.0)
safety_net_attestation (~> 0.4.0)
tpm-key_attestation (~> 0.14.0)
webfinger (2.1.3)
activesupport
@@ -968,7 +988,7 @@ DEPENDENCIES
flatware-rspec
fog-core (<= 2.6.0)
fog-openstack (~> 1.0)
haml-rails (~> 3.0)
haml-rails (~> 2.0)
haml_lint
hcaptcha (~> 7.1)
hiredis (~> 0.6)
@@ -1008,20 +1028,20 @@ DEPENDENCIES
omniauth-saml (~> 2.0)
omniauth_openid_connect (~> 0.8.0)
opentelemetry-api (~> 1.7.0)
opentelemetry-exporter-otlp (~> 0.31.0)
opentelemetry-instrumentation-active_job (~> 0.9.0)
opentelemetry-instrumentation-active_model_serializers (~> 0.23.0)
opentelemetry-instrumentation-concurrent_ruby (~> 0.23.0)
opentelemetry-instrumentation-excon (~> 0.25.0)
opentelemetry-instrumentation-faraday (~> 0.29.0)
opentelemetry-instrumentation-http (~> 0.26.0)
opentelemetry-instrumentation-http_client (~> 0.25.0)
opentelemetry-instrumentation-net_http (~> 0.25.0)
opentelemetry-instrumentation-pg (~> 0.31.0)
opentelemetry-instrumentation-rack (~> 0.28.0)
opentelemetry-instrumentation-rails (~> 0.38.0)
opentelemetry-instrumentation-redis (~> 0.27.0)
opentelemetry-instrumentation-sidekiq (~> 0.27.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.24.0)
opentelemetry-instrumentation-faraday (~> 0.28.0)
opentelemetry-instrumentation-http (~> 0.25.0)
opentelemetry-instrumentation-http_client (~> 0.24.0)
opentelemetry-instrumentation-net_http (~> 0.24.0)
opentelemetry-instrumentation-pg (~> 0.30.0)
opentelemetry-instrumentation-rack (~> 0.27.0)
opentelemetry-instrumentation-rails (~> 0.37.0)
opentelemetry-instrumentation-redis (~> 0.26.0)
opentelemetry-instrumentation-sidekiq (~> 0.26.0)
opentelemetry-sdk (~> 1.4)
ox (~> 2.14)
parslet
@@ -1032,7 +1052,7 @@ DEPENDENCIES
prometheus_exporter (~> 2.2)
propshaft
public_suffix (~> 6.0)
puma (~> 7.0)
puma (~> 6.3)
pundit (~> 2.3)
rack-attack (~> 6.6)
rack-cors
@@ -1080,11 +1100,10 @@ DEPENDENCIES
webauthn (~> 3.0)
webmock (~> 3.18)
webpush!
websocket-driver (~> 0.8)
xorcist (~> 1.1)
RUBY VERSION
ruby 3.4.1p0
BUNDLED WITH
2.7.2
2.7.1

View File

@@ -71,10 +71,6 @@ class AccountsController < ApplicationController
params[:username]
end
def account_id_param
params[:id]
end
def skip_temporary_suspension_response?
request.format == :json
end

View File

@@ -28,7 +28,7 @@ class ActivityPub::LikesController < ActivityPub::BaseController
def likes_collection_presenter
ActivityPub::CollectionPresenter.new(
id: ActivityPub::TagManager.instance.likes_uri_for(@status),
id: account_status_likes_url(@account, @status),
type: :unordered,
size: @status.favourites_count
)

View File

@@ -73,8 +73,6 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController
end
def set_account
return super if params[:account_username].present? || params[:account_id].present?
@account = Account.representative
@account = params[:account_username].present? ? Account.find_local!(username_param) : Account.representative
end
end

View File

@@ -37,7 +37,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController
def replies_collection_presenter
page = ActivityPub::CollectionPresenter.new(
id: ActivityPub::TagManager.instance.replies_uri_for(@status, page_params),
id: account_status_replies_url(@account, @status, page_params),
type: :unordered,
part_of: account_status_replies_url(@account, @status),
next: next_page,
@@ -47,7 +47,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController
return page if page_requested?
ActivityPub::CollectionPresenter.new(
id: ActivityPub::TagManager.instance.replies_uri_for(@status),
id: account_status_replies_url(@account, @status),
type: :unordered,
first: page
)
@@ -66,7 +66,8 @@ class ActivityPub::RepliesController < ActivityPub::BaseController
# Only consider remote accounts
return nil if @replies.size < DESCENDANTS_LIMIT
ActivityPub::TagManager.instance.replies_uri_for(
account_status_replies_url(
@account,
@status,
page: true,
min_id: @replies&.last&.id,
@@ -76,7 +77,8 @@ class ActivityPub::RepliesController < ActivityPub::BaseController
# For now, we're serving only self-replies, but next page might be other accounts
next_only_other_accounts = @replies&.last&.account_id != @account.id || @replies.size < DESCENDANTS_LIMIT
ActivityPub::TagManager.instance.replies_uri_for(
account_status_replies_url(
@account,
@status,
page: true,
min_id: next_only_other_accounts ? nil : @replies&.last&.id,

View File

@@ -28,7 +28,7 @@ class ActivityPub::SharesController < ActivityPub::BaseController
def shares_collection_presenter
ActivityPub::CollectionPresenter.new(
id: ActivityPub::TagManager.instance.shares_uri_for(@status),
id: account_status_shares_url(@account, @status),
type: :unordered,
size: @status.reblogs_count
)

View File

@@ -9,16 +9,10 @@ module Admin
@pending_appeals_count = Appeal.pending.async_count
@pending_reports_count = Report.unresolved.async_count
@pending_tags_count = pending_tags.async_count
@pending_tags_count = Tag.pending_review.async_count
@pending_users_count = User.pending.async_count
@system_checks = Admin::SystemCheck.perform(current_user)
@time_period = (29.days.ago.to_date...Time.now.utc.to_date)
end
private
def pending_tags
::Trends::TagFilter.new(status: :pending_review).results
end
end
end

View File

@@ -4,6 +4,7 @@ class Api::V1::Statuses::InteractionPoliciesController < Api::V1::Statuses::Base
include Api::InteractionPoliciesConcern
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }
before_action -> { check_feature_enabled }
def update
authorize @status, :update?
@@ -21,8 +22,12 @@ class Api::V1::Statuses::InteractionPoliciesController < Api::V1::Statuses::Base
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, 'skip_notifications' => true })
DistributionWorker.perform_async(@status.id, { 'update' => true })
ActivityPub::StatusUpdateDistributionWorker.perform_async(@status.id, { 'updated_at' => Time.now.utc.iso8601 })
end
end

View File

@@ -4,13 +4,13 @@ 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 :set_statuses, only: :index
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
@@ -24,26 +24,18 @@ class Api::V1::Statuses::QuotesController < Api::V1::Statuses::BaseController
private
def check_owner!
authorize @status, :list_quotes?
end
def set_quote
@quote = @status.quotes.find_by!(status_id: params[:id])
end
def set_statuses
def load_statuses
scope = default_statuses
scope = scope.not_excluded_by_account(current_account) unless current_account.nil?
@statuses = scope.merge(paginated_quotes).to_a
# Store next page info before filtering
@records_continue = @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT)
@pagination_since_id = @statuses.first.quote.id unless @statuses.empty?
@pagination_max_id = @statuses.last.quote.id if @records_continue
if current_account&.id != @status.account_id
domains = @statuses.filter_map(&:account_domain).uniq
account_ids = @statuses.map(&:account_id).uniq
relations = current_account&.relations_map(account_ids, domains) || {}
@statuses.reject! { |status| StatusFilter.new(status, current_account, relations).filtered? }
end
scope.merge(paginated_quotes).to_a
end
def default_statuses
@@ -66,9 +58,15 @@ class Api::V1::Statuses::QuotesController < Api::V1::Statuses::BaseController
api_v1_status_quotes_url pagination_params(since_id: pagination_since_id) unless @statuses.empty?
end
attr_reader :pagination_max_id, :pagination_since_id
def pagination_max_id
@statuses.last.quote.id
end
def pagination_since_id
@statuses.first.quote.id
end
def records_continue?
@records_continue
@statuses.size == limit_param(DEFAULT_STATUSES_LIMIT)
end
end

View File

@@ -159,6 +159,8 @@ class Api::V1::StatusesController < Api::BaseController
end
def set_quoted_status
return unless Mastodon::Feature.outgoing_quotes_enabled?
@quoted_status = Status.find(status_params[:quoted_status_id]) if status_params[:quoted_status_id].present?
authorize(@quoted_status, :quote?) if @quoted_status.present?
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError

View File

@@ -3,8 +3,14 @@
class Api::V1::Timelines::BaseController < Api::BaseController
after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
before_action :require_user!, if: :require_auth?
private
def require_auth?
!Setting.timeline_preview
end
def pagination_collection
@statuses
end

View File

@@ -3,8 +3,8 @@
class Api::V1::Timelines::HomeController < Api::V1::Timelines::BaseController
include AsyncRefreshesConcern
before_action -> { doorkeeper_authorize! :read, :'read:statuses' }
before_action :require_user!
before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, only: [:show]
before_action :require_user!, only: [:show]
PERMITTED_PARAMS = %i(local limit).freeze

View File

@@ -1,6 +1,6 @@
# frozen_string_literal: true
class Api::V1::Timelines::LinkController < Api::V1::Timelines::TopicController
class Api::V1::Timelines::LinkController < Api::V1::Timelines::BaseController
before_action -> { authorize_if_got_token! :read, :'read:statuses' }
before_action :set_preview_card
before_action :set_statuses

View File

@@ -2,7 +2,6 @@
class Api::V1::Timelines::PublicController < Api::V1::Timelines::BaseController
before_action -> { authorize_if_got_token! :read, :'read:statuses' }
before_action :require_user!, if: :require_auth?
PERMITTED_PARAMS = %i(local remote limit only_media allow_local_only).freeze
@@ -14,16 +13,6 @@ class Api::V1::Timelines::PublicController < Api::V1::Timelines::BaseController
private
def require_auth?
if truthy_param?(:local)
Setting.local_live_feed_access != 'public'
elsif truthy_param?(:remote)
Setting.remote_live_feed_access != 'public'
else
Setting.local_live_feed_access != 'public' || Setting.remote_live_feed_access != 'public'
end
end
def load_statuses
preloaded_public_statuses_page
end

View File

@@ -1,6 +1,6 @@
# frozen_string_literal: true
class Api::V1::Timelines::TagController < Api::V1::Timelines::TopicController
class Api::V1::Timelines::TagController < Api::V1::Timelines::BaseController
before_action -> { authorize_if_got_token! :read, :'read:statuses' }
before_action :load_tag
@@ -14,6 +14,10 @@ class Api::V1::Timelines::TagController < Api::V1::Timelines::TopicController
private
def require_auth?
!Setting.timeline_preview
end
def load_tag
@tag = Tag.find_normalized(params[:id])
end

View File

@@ -1,17 +0,0 @@
# frozen_string_literal: true
class Api::V1::Timelines::TopicController < Api::V1::Timelines::BaseController
before_action :require_user!, if: :require_auth?
private
def require_auth?
if truthy_param?(:local)
Setting.local_topic_feed_access != 'public'
elsif truthy_param?(:remote)
Setting.remote_topic_feed_access != 'public'
else
Setting.local_topic_feed_access != 'public' || Setting.remote_topic_feed_access != 'public'
end
end
end

View File

@@ -89,7 +89,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
end
def check_enabled_registrations
redirect_to new_user_session_path, alert: I18n.t('devise.failure.closed_registrations', email: Setting.site_contact_email) unless allowed_registration?(request.remote_ip, @invite)
redirect_to root_path unless allowed_registration?(request.remote_ip, @invite)
end
def invite_code

View File

@@ -18,11 +18,7 @@ module AccountOwnedConcern
end
def set_account
@account = username_param.present? ? Account.find_local!(username_param) : Account.local.find(account_id_param)
end
def account_id_param
params[:account_id]
@account = Account.find_local!(username_param)
end
def username_param

View File

@@ -4,6 +4,8 @@ module Api::InteractionPoliciesConcern
extend ActiveSupport::Concern
def quote_approval_policy
return nil unless Mastodon::Feature.outgoing_quotes_enabled?
case status_params[:quote_approval_policy].presence || current_user.setting_default_quote_policy
when 'public'
Status::QUOTE_APPROVAL_POLICY_FLAGS[:public] << 16

View File

@@ -6,9 +6,6 @@ module AsyncRefreshesConcern
def add_async_refresh_header(async_refresh, retry_seconds: 3)
return unless async_refresh.running?
value = "id=\"#{async_refresh.id}\", retry=#{retry_seconds}"
value += ", result_count=#{async_refresh.result_count}" unless async_refresh.result_count.nil?
response.headers['Mastodon-Async-Refresh'] = value
response.headers['Mastodon-Async-Refresh'] = "id=\"#{async_refresh.id}\", retry=#{retry_seconds}"
end
end

View File

@@ -58,22 +58,20 @@ class FollowerAccountsController < ApplicationController
end
def collection_presenter
options = {}
options = { type: :ordered }
options[:size] = @account.followers_count unless Setting.hide_followers_count || @account.user&.setting_hide_followers_count
if page_requested?
ActivityPub::CollectionPresenter.new(
id: page_url(params.fetch(:page, 1)),
type: :ordered,
id: account_followers_url(@account, page: params.fetch(:page, 1)),
items: follows.map { |follow| ActivityPub::TagManager.instance.uri_for(follow.account) },
part_of: ActivityPub::TagManager.instance.followers_uri_for(@account),
part_of: account_followers_url(@account),
next: next_page_url,
prev: prev_page_url,
**options
)
else
ActivityPub::CollectionPresenter.new(
id: ActivityPub::TagManager.instance.followers_uri_for(@account),
type: :ordered,
id: account_followers_url(@account),
first: page_url(1),
**options
)

View File

@@ -49,7 +49,7 @@ class FollowingAccountsController < ApplicationController
end
def page_url(page)
ActivityPub::TagManager.instance.following_uri_for(@account, page: page) unless page.nil?
account_following_index_url(@account, page: page) unless page.nil?
end
def next_page_url
@@ -63,17 +63,17 @@ class FollowingAccountsController < ApplicationController
def collection_presenter
if page_requested?
ActivityPub::CollectionPresenter.new(
id: page_url(params.fetch(:page, 1)),
id: account_following_index_url(@account, page: params.fetch(:page, 1)),
type: :ordered,
size: @account.following_count,
items: follows.map { |follow| ActivityPub::TagManager.instance.uri_for(follow.target_account) },
part_of: ActivityPub::TagManager.instance.following_uri_for(@account),
part_of: account_following_index_url(@account),
next: next_page_url,
prev: prev_page_url
)
else
ActivityPub::CollectionPresenter.new(
id: ActivityPub::TagManager.instance.following_uri_for(@account),
id: account_following_index_url(@account),
type: :ordered,
size: @account.following_count,
first: page_url(1)

View File

@@ -21,13 +21,7 @@ module HomeHelper
end
end
else
account_url = if account.suspended?
ActivityPub::TagManager.instance.url_for(account)
else
web_url("@#{account.pretty_acct}")
end
link_to(path || account_url, class: 'account__display-name') do
link_to(path || ActivityPub::TagManager.instance.url_for(account), class: 'account__display-name') do
content_tag(:div, class: 'account__avatar-wrapper') do
image_tag(full_asset_url(current_account&.user&.setting_auto_play_gif ? account.avatar_original_url : account.avatar_static_url), class: 'account__avatar', width: 46, height: 46)
end +

View File

@@ -57,20 +57,6 @@ module StatusesHelper
components.compact_blank.join("\n\n")
end
# This logic should be kept in sync with https://github.com/mastodon/mastodon/blob/425311e1d95c8a64ddac6c724fca247b8b893a82/app/javascript/mastodon/features/status/components/card.jsx#L160
def preview_card_aspect_ratio_classname(preview_card)
interactive = preview_card.type == 'video'
large_image = (preview_card.image.present? && preview_card.width > preview_card.height) || interactive
if large_image && interactive
'status-card__image--video'
elsif large_image
'status-card__image--large'
else
'status-card__image--normal'
end
end
def visibility_icon(status)
VISIBLITY_ICONS[status.visibility.to_sym]
end

View File

@@ -1,78 +0,0 @@
{
"global": {
"class": "className",
"id": true,
"title": true,
"dir": true,
"lang": true
},
"tags": {
"p": {},
"br": {
"children": false
},
"span": {
"attributes": {
"translate": true
}
},
"a": {
"attributes": {
"href": true,
"rel": true,
"translate": true,
"target": true,
"title": true
}
},
"abbr": {
"attributes": {
"title": true
}
},
"del": {},
"s": {},
"pre": {},
"blockquote": {
"attributes": {
"cite": true
}
},
"code": {},
"b": {},
"strong": {},
"u": {},
"sub": {},
"sup": {},
"i": {},
"img": {
"children": false,
"attributes": {
"src": true,
"alt": true,
"title": true
}
},
"em": {},
"h1": {},
"h2": {},
"h3": {},
"h4": {},
"h5": {},
"ul": {},
"ol": {
"attributes": {
"start": true,
"reversed": true
}
},
"li": {
"attributes": {
"value": true
}
},
"ruby": {},
"rt": {},
"rp": {}
}
}

View File

@@ -1,7 +1,6 @@
import { createRoot } from 'react-dom/client';
import Rails from '@rails/ujs';
import { decode, ValidationError } from 'blurhash';
import ready from '../mastodon/ready';
@@ -363,46 +362,6 @@ ready(() => {
document.querySelectorAll('[data-admin-component]').forEach((element) => {
void mountReactComponent(element);
});
document
.querySelectorAll<HTMLCanvasElement>('canvas[data-blurhash]')
.forEach((canvas) => {
const blurhash = canvas.dataset.blurhash;
if (blurhash) {
try {
// decode returns a Uint8ClampedArray<ArrayBufferLike> not Uint8ClampedArray<ArrayBuffer>
const pixels = decode(
blurhash,
32,
32,
) as Uint8ClampedArray<ArrayBuffer>;
const ctx = canvas.getContext('2d');
const imageData = new ImageData(pixels, 32, 32);
ctx?.putImageData(imageData, 0, 0);
} catch (err) {
if (err instanceof ValidationError) {
// ignore blurhash validation errors
return;
}
throw err;
}
}
});
document
.querySelectorAll<HTMLDivElement>('.preview-card')
.forEach((previewCard) => {
const spoilerButton = previewCard.querySelector('.spoiler-button');
if (!spoilerButton) {
return;
}
spoilerButton.addEventListener('click', () => {
previewCard.classList.toggle('preview-card--image-visible');
});
});
}).catch((reason: unknown) => {
throw reason;
});

View File

@@ -70,7 +70,7 @@ function loaded() {
};
document.querySelectorAll('.emojify').forEach((content) => {
content.innerHTML = emojify(content.innerHTML, {}, true); // Force emojify as public doesn't load the new emoji system.
content.innerHTML = emojify(content.innerHTML);
});
document

View File

@@ -206,11 +206,10 @@ export function submitCompose(overridePrivacy = null, successCallback = undefine
let status = getState().getIn(['compose', 'text'], '');
const media = getState().getIn(['compose', 'media_attachments']);
const statusId = getState().getIn(['compose', 'id'], null);
const hasQuote = !!getState().getIn(['compose', 'quoted_status_id']);
const spoilers = getState().getIn(['compose', 'spoiler']) || getState().getIn(['local_settings', 'always_show_spoilers_field']);
const spoiler_text = spoilers ? getState().getIn(['compose', 'spoiler_text'], '') : '';
let spoilerText = spoilers ? getState().getIn(['compose', 'spoiler_text'], '') : '';
if (!(status?.length || media.size !== 0 || (hasQuote && spoiler_text?.length))) {
if ((!status || !status.length) && media.size === 0) {
return;
}
@@ -246,12 +245,12 @@ export function submitCompose(overridePrivacy = null, successCallback = undefine
method: statusId === null ? 'post' : 'put',
data: {
status,
spoiler_text,
content_type: getState().getIn(['compose', 'content_type']),
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
media_ids: media.map(item => item.get('id')),
media_attributes,
sensitive: getState().getIn(['compose', 'sensitive']) || (spoiler_text.length > 0 && media.size !== 0),
sensitive: getState().getIn(['compose', 'sensitive']) || (spoilerText.length > 0 && media.size !== 0),
spoiler_text: spoilerText,
visibility: visibility,
poll: getState().getIn(['compose', 'poll'], null),
language: getState().getIn(['compose', 'language']),

View File

@@ -4,7 +4,6 @@ import { createAction } from '@reduxjs/toolkit';
import type { List as ImmutableList, Map as ImmutableMap } from 'immutable';
import { apiUpdateMedia } from 'flavours/glitch/api/compose';
import { apiGetSearch } from 'flavours/glitch/api/search';
import type { ApiMediaAttachmentJSON } from 'flavours/glitch/api_types/media_attachments';
import type { MediaAttachment } from 'flavours/glitch/models/media_attachment';
import {
@@ -17,14 +16,9 @@ import type { Status } from '../models/status';
import { showAlert } from './alerts';
import { focusCompose } from './compose';
import { importFetchedStatuses } from './importer';
import { openModal } from './modal';
const messages = defineMessages({
quoteErrorEdit: {
id: 'quote_error.edit',
defaultMessage: 'Quotes cannot be added when editing a post.',
},
quoteErrorUpload: {
id: 'quote_error.upload',
defaultMessage: 'Quoting is not allowed with media attachments.',
@@ -128,9 +122,7 @@ export const quoteComposeByStatus = createAppThunk(
false,
);
if (composeState.get('id')) {
dispatch(showAlert({ message: messages.quoteErrorEdit }));
} else if (composeState.get('poll')) {
if (composeState.get('poll')) {
dispatch(showAlert({ message: messages.quoteErrorPoll }));
} else if (
composeState.get('is_uploading') ||
@@ -173,42 +165,6 @@ export const quoteComposeById = createAppThunk(
},
);
export const pasteLinkCompose = createDataLoadingThunk(
'compose/pasteLink',
async ({ url }: { url: string }) => {
return await apiGetSearch({
q: url,
type: 'statuses',
resolve: true,
limit: 2,
});
},
(data, { dispatch, getState }) => {
const composeState = getState().compose;
if (
composeState.get('quoted_status_id') ||
composeState.get('is_submitting') ||
composeState.get('poll') ||
composeState.get('is_uploading') ||
composeState.get('id')
)
return;
dispatch(importFetchedStatuses(data.statuses));
if (
data.statuses.length === 1 &&
data.statuses[0] &&
['automatic', 'manual'].includes(
data.statuses[0].quote_approval?.current_user ?? 'denied',
)
) {
dispatch(quoteComposeById(data.statuses[0].id));
}
},
);
export const quoteComposeCancel = createAction('compose/quoteComposeCancel');
export const setComposeQuotePolicy = createAction<ApiQuotePolicy>(

View File

@@ -9,9 +9,8 @@ import { importFetchedStatuses } from './importer';
export const fetchContext = createDataLoadingThunk(
'status/context',
({ statusId }: { statusId: string; prefetchOnly?: boolean }) =>
apiGetContext(statusId),
({ context, refresh }, { dispatch, actionArg: { prefetchOnly = false } }) => {
({ statusId }: { statusId: string }) => apiGetContext(statusId),
({ context, refresh }, { dispatch }) => {
const statuses = context.ancestors.concat(context.descendants);
dispatch(importFetchedStatuses(statuses));
@@ -19,7 +18,6 @@ export const fetchContext = createDataLoadingThunk(
return {
context,
refresh,
prefetchOnly,
};
},
);
@@ -28,14 +26,6 @@ export const completeContextRefresh = createAction<{ statusId: string }>(
'status/context/complete',
);
export const showPendingReplies = createAction<{ statusId: string }>(
'status/context/showPendingReplies',
);
export const clearPendingReplies = createAction<{ statusId: string }>(
'status/context/clearPendingReplies',
);
export const setStatusQuotePolicy = createDataLoadingThunk(
'status/setQuotePolicy',
({ statusId, policy }: { statusId: string; policy: ApiQuotePolicy }) => {

View File

@@ -1,28 +0,0 @@
// See app/serializers/rest/announcement_serializer.rb
import type { ApiCustomEmojiJSON } from './custom_emoji';
import type { ApiMentionJSON, ApiStatusJSON, ApiTagJSON } from './statuses';
export interface ApiAnnouncementJSON {
id: string;
content: string;
starts_at: null | string;
ends_at: null | string;
all_day: boolean;
published_at: string;
updated_at: null | string;
read: boolean;
mentions: ApiMentionJSON[];
statuses: ApiStatusJSON[];
tags: ApiTagJSON[];
emojis: ApiCustomEmojiJSON[];
reactions: ApiAnnouncementReactionJSON[];
}
export interface ApiAnnouncementReactionJSON {
name: string;
count: number;
me: boolean;
url?: string;
static_url?: string;
}

View File

@@ -4,7 +4,6 @@ import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { EmojiHTML } from '@/flavours/glitch/components/emoji/html';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import {
blockAccount,
@@ -34,7 +33,7 @@ import { me } from 'flavours/glitch/initial_state';
import type { MenuItem } from 'flavours/glitch/models/dropdown_menu';
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
import { Permalink } from '../permalink';
import { Permalink } from './permalink';
const messages = defineMessages({
follow: { id: 'account.follow', defaultMessage: 'Follow' },
@@ -334,10 +333,9 @@ export const Account: React.FC<AccountProps> = ({
{account &&
withBio &&
(account.note.length > 0 ? (
<EmojiHTML
<div
className='account__note translate'
htmlString={account.note_emojified}
extraEmojis={account.emojis}
dangerouslySetInnerHTML={{ __html: account.note_emojified }}
/>
) : (
<div className='account__note account__note--missing'>

View File

@@ -1,15 +1,11 @@
import { useCallback } from 'react';
import classNames from 'classnames';
import { useLinks } from 'flavours/glitch/hooks/useLinks';
import { EmojiHTML } from '../features/emoji/emoji_html';
import { useAppSelector } from '../store';
import { isModernEmojiEnabled } from '../utils/environment';
import { EmojiHTML } from './emoji/html';
import { useElementHandledLink } from './status/handled_link';
interface AccountBioProps {
className: string;
accountId: string;
@@ -24,29 +20,19 @@ export const AccountBio: React.FC<AccountBioProps> = ({
const handleClick = useLinks(showDropdown);
const handleNodeChange = useCallback(
(node: HTMLDivElement | null) => {
if (
!showDropdown ||
!node ||
node.childNodes.length === 0 ||
isModernEmojiEnabled()
) {
if (!showDropdown || !node || node.childNodes.length === 0) {
return;
}
addDropdownToHashtags(node, accountId);
},
[showDropdown, accountId],
);
const htmlHandlers = useElementHandledLink({
hashtagAccountId: showDropdown ? accountId : undefined,
});
const note = useAppSelector((state) => {
const account = state.accounts.get(accountId);
if (!account) {
return '';
}
return account.note_emojified;
return isModernEmojiEnabled() ? account.note : account.note_emojified;
});
const extraEmojis = useAppSelector((state) => {
const account = state.accounts.get(accountId);
@@ -58,14 +44,13 @@ export const AccountBio: React.FC<AccountBioProps> = ({
}
return (
<EmojiHTML
htmlString={note}
extraEmojis={extraEmojis}
className={classNames(className, 'translate')}
<div
className={`${className} translate`}
onClickCapture={handleClick}
ref={handleNodeChange}
{...htmlHandlers}
/>
>
<EmojiHTML htmlString={note} extraEmojis={extraEmojis} />
</div>
);
};

View File

@@ -1,70 +1,42 @@
import { useIntl } from 'react-intl';
import classNames from 'classnames';
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
import { Icon } from 'flavours/glitch/components/icon';
import { useLinks } from 'flavours/glitch/hooks/useLinks';
import type { Account } from 'flavours/glitch/models/account';
import { CustomEmojiProvider } from './emoji/context';
import { EmojiHTML } from './emoji/html';
import { useElementHandledLink } from './status/handled_link';
export const AccountFields: React.FC<Pick<Account, 'fields' | 'emojis'>> = ({
fields,
emojis,
}) => {
const intl = useIntl();
const htmlHandlers = useElementHandledLink();
export const AccountFields: React.FC<{
fields: Account['fields'];
limit: number;
}> = ({ fields, limit = -1 }) => {
const handleClick = useLinks();
if (fields.size === 0) {
return null;
}
return (
<CustomEmojiProvider emojis={emojis}>
{fields.map((pair, i) => (
<dl key={i} className={classNames({ verified: pair.verified_at })}>
<EmojiHTML
as='dt'
htmlString={pair.name_emojified}
<div className='account-fields' onClickCapture={handleClick}>
{fields.take(limit).map((pair, i) => (
<dl
key={i}
className={classNames({ verified: pair.get('verified_at') })}
>
<dt
dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }}
className='translate'
{...htmlHandlers}
/>
<dd className='translate' title={pair.value_plain ?? ''}>
{pair.verified_at && (
<span
title={intl.formatMessage(
{
id: 'account.link_verified_on',
defaultMessage:
'Ownership of this link was checked on {date}',
},
{
date: intl.formatDate(pair.verified_at, dateFormatOptions),
},
)}
>
<Icon id='check' icon={CheckIcon} className='verified__mark' />
</span>
)}{' '}
<EmojiHTML
as='span'
htmlString={pair.value_emojified}
{...htmlHandlers}
<dd className='translate' title={pair.get('value_plain') ?? ''}>
{pair.get('verified_at') && (
<Icon id='check' icon={CheckIcon} className='verified__mark' />
)}
<span
dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }}
/>
</dd>
</dl>
))}
</CustomEmojiProvider>
</div>
);
};
const dateFormatOptions: Intl.DateTimeFormatOptions = {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
};

View File

@@ -8,7 +8,6 @@ const meta = {
component: Alert,
args: {
isActive: true,
isLoading: false,
animateFrom: 'side',
title: '',
message: '',
@@ -21,12 +20,6 @@ const meta = {
type: 'boolean',
description: 'Animate to the active (displayed) state of the alert',
},
isLoading: {
control: 'boolean',
type: 'boolean',
description:
'Display a loading indicator in the alert, replacing the dismiss button if present',
},
animateFrom: {
control: 'radio',
type: 'string',
@@ -115,11 +108,3 @@ export const InSizedContainer: Story = {
</div>
),
};
export const WithLoadingIndicator: Story = {
args: {
...WithDismissButton.args,
isLoading: true,
},
render: InSizedContainer.render,
};

View File

@@ -3,7 +3,6 @@ import { useIntl } from 'react-intl';
import classNames from 'classnames';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
import { IconButton } from '../icon_button';
@@ -11,23 +10,21 @@ import { IconButton } from '../icon_button';
* Snackbar/Toast-style notification component.
*/
export const Alert: React.FC<{
isActive?: boolean;
animateFrom?: 'side' | 'below';
title?: string;
message: string;
action?: string;
onActionClick?: () => void;
onDismiss?: () => void;
isActive?: boolean;
isLoading?: boolean;
animateFrom?: 'side' | 'below';
}> = ({
isActive,
animateFrom = 'side',
title,
message,
action,
onActionClick,
onDismiss,
isActive,
isLoading,
animateFrom = 'side',
}) => {
const intl = useIntl();
@@ -54,13 +51,7 @@ export const Alert: React.FC<{
</button>
)}
{isLoading && (
<span className='notification-bar__loading-indicator'>
<LoadingIndicator />
</span>
)}
{onDismiss && !isLoading && (
{onDismiss && (
<IconButton
title={intl.formatMessage({
id: 'dismissable_banner.dismiss',

View File

@@ -150,7 +150,10 @@ const AutosuggestTextarea = forwardRef(({
}, [suggestions, onSuggestionSelected, textareaRef]);
const handlePaste = useCallback((e) => {
onPaste(e);
if (e.clipboardData && e.clipboardData.files.length === 1) {
onPaste(e.clipboardData.files);
e.preventDefault();
}
}, [onPaste]);
// Show the suggestions again whenever they change and the textarea is focused

View File

@@ -30,12 +30,9 @@ const Blurhash: React.FC<Props> = ({
try {
const pixels = decode(hash, width, height);
const ctx = canvas.getContext('2d');
const imageData = ctx?.createImageData(width, height);
imageData?.data.set(pixels);
const imageData = new ImageData(pixels, width, height);
if (imageData) {
ctx?.putImageData(imageData, 0, 0);
}
ctx?.putImageData(imageData, 0, 0);
} catch (err) {
console.error('Blurhash decoding failure', { err, hash });
}

View File

@@ -1,48 +1,25 @@
import type { List } from 'immutable';
import type { CustomEmoji } from '../models/custom_emoji';
import type { Status } from '../models/status';
import { EmojiHTML } from './emoji/html';
import type { IconName } from './media_icon';
import { MediaIcon } from './media_icon';
import { StatusBanner, BannerVariant } from './status_banner';
export const ContentWarning: React.FC<{
status: Status;
text: string;
expanded?: boolean;
onClick?: () => void;
icons?: IconName[];
}> = ({ status, expanded, onClick, icons }) => {
const hasSpoiler = !!status.get('spoiler_text');
if (!hasSpoiler) {
return null;
}
const text =
status.getIn(['translation', 'spoilerHtml']) || status.get('spoilerHtml');
if (typeof text !== 'string' || text.length === 0) {
return null;
}
return (
<StatusBanner
expanded={expanded}
onClick={onClick}
variant={BannerVariant.Warning}
>
{icons?.map((icon) => (
<MediaIcon
className='status__content__spoiler-icon'
icon={icon}
key={`icon-${icon}`}
/>
))}
<EmojiHTML
as='span'
htmlString={text}
extraEmojis={status.get('emoji') as List<CustomEmoji>}
}> = ({ text, expanded, onClick, icons }) => (
<StatusBanner
expanded={expanded}
onClick={onClick}
variant={BannerVariant.Warning}
>
{icons?.map((icon) => (
<MediaIcon
className='status__content__spoiler-icon'
icon={icon}
key={`icon-${icon}`}
/>
</StatusBanner>
);
};
))}
<span dangerouslySetInnerHTML={{ __html: text }} />
</StatusBanner>
);

View File

@@ -2,8 +2,9 @@ import type { ComponentPropsWithoutRef, FC } from 'react';
import classNames from 'classnames';
import { AnimateEmojiProvider } from '../emoji/context';
import { EmojiHTML } from '../emoji/html';
import { EmojiHTML } from '@/flavours/glitch/features/emoji/emoji_html';
import { isModernEmojiEnabled } from '@/flavours/glitch/utils/environment';
import { Skeleton } from '../skeleton';
import type { DisplayNameProps } from './index';
@@ -13,18 +14,18 @@ export const DisplayNameWithoutDomain: FC<
ComponentPropsWithoutRef<'span'>
> = ({ account, className, children, ...props }) => {
return (
<AnimateEmojiProvider
{...props}
as='span'
className={classNames('display-name', className)}
>
<span {...props} className={classNames('display-name', className)}>
<bdi>
{account ? (
<EmojiHTML
className='display-name__html'
htmlString={account.get('display_name_html')}
htmlString={
isModernEmojiEnabled()
? account.get('display_name')
: account.get('display_name_html')
}
shallow
as='strong'
extraEmojis={account.get('emojis')}
/>
) : (
<strong className='display-name__html'>
@@ -33,6 +34,6 @@ export const DisplayNameWithoutDomain: FC<
)}
</bdi>
{children}
</AnimateEmojiProvider>
</span>
);
};

View File

@@ -1,6 +1,7 @@
import type { ComponentPropsWithoutRef, FC } from 'react';
import { EmojiHTML } from '../emoji/html';
import { EmojiHTML } from '@/flavours/glitch/features/emoji/emoji_html';
import { isModernEmojiEnabled } from '@/flavours/glitch/utils/environment';
import type { DisplayNameProps } from './index';
@@ -11,15 +12,12 @@ export const DisplayNameSimple: FC<
if (!account) {
return null;
}
const accountName = isModernEmojiEnabled()
? account.get('display_name')
: account.get('display_name_html');
return (
<bdi>
<EmojiHTML
{...props}
as='span'
htmlString={account.get('display_name_html')}
extraEmojis={account.get('emojis')}
/>
<EmojiHTML {...props} htmlString={accountName} shallow as='span' />
</bdi>
);
};

View File

@@ -1,108 +0,0 @@
import type { MouseEventHandler, PropsWithChildren } from 'react';
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
} from 'react';
import classNames from 'classnames';
import { cleanExtraEmojis } from '@/flavours/glitch/features/emoji/normalize';
import { autoPlayGif } from '@/flavours/glitch/initial_state';
import { polymorphicForwardRef } from '@/types/polymorphic';
import type {
CustomEmojiMapArg,
ExtraCustomEmojiMap,
} from 'flavours/glitch/features/emoji/types';
// Animation context
export const AnimateEmojiContext = createContext<boolean | null>(null);
// Polymorphic provider component
type AnimateEmojiProviderProps = Required<PropsWithChildren> & {
className?: string;
};
export const AnimateEmojiProvider = polymorphicForwardRef<
'div',
AnimateEmojiProviderProps
>(
(
{
children,
as: Wrapper = 'div',
className,
onMouseEnter,
onMouseLeave,
...props
},
ref,
) => {
const [animate, setAnimate] = useState(autoPlayGif ?? false);
const handleEnter: MouseEventHandler<HTMLDivElement> = useCallback(
(event) => {
onMouseEnter?.(event);
if (!autoPlayGif) {
setAnimate(true);
}
},
[onMouseEnter],
);
const handleLeave: MouseEventHandler<HTMLDivElement> = useCallback(
(event) => {
onMouseLeave?.(event);
if (!autoPlayGif) {
setAnimate(false);
}
},
[onMouseLeave],
);
// If there's a parent context or GIFs autoplay, we don't need handlers.
const parentContext = useContext(AnimateEmojiContext);
if (parentContext !== null || autoPlayGif === true) {
return (
<Wrapper
{...props}
className={classNames(className, 'animate-parent')}
ref={ref}
>
{children}
</Wrapper>
);
}
return (
<Wrapper
{...props}
className={classNames(className, 'animate-parent')}
onMouseEnter={handleEnter}
onMouseLeave={handleLeave}
ref={ref}
>
<AnimateEmojiContext.Provider value={animate}>
{children}
</AnimateEmojiContext.Provider>
</Wrapper>
);
},
);
AnimateEmojiProvider.displayName = 'AnimateEmojiProvider';
// Handle custom emoji
export const CustomEmojiContext = createContext<ExtraCustomEmojiMap>({});
export const CustomEmojiProvider = ({
children,
emojis: rawEmojis,
}: PropsWithChildren<{ emojis?: CustomEmojiMapArg }>) => {
const emojis = useMemo(() => cleanExtraEmojis(rawEmojis) ?? {}, [rawEmojis]);
return (
<CustomEmojiContext.Provider value={emojis}>
{children}
</CustomEmojiContext.Provider>
);
};

View File

@@ -1,56 +0,0 @@
import type { ComponentProps } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { importCustomEmojiData } from '@/flavours/glitch/features/emoji/loader';
import { Emoji } from './index';
type EmojiProps = ComponentProps<typeof Emoji> & { state: string };
const meta = {
title: 'Components/Emoji',
component: Emoji,
args: {
code: '🖤',
state: 'auto',
},
argTypes: {
code: {
name: 'Emoji',
},
state: {
control: {
type: 'select',
labels: {
auto: 'Auto',
native: 'Native',
twemoji: 'Twemoji',
},
},
options: ['auto', 'native', 'twemoji'],
name: 'Emoji Style',
mapping: {
auto: { meta: { emoji_style: 'auto' } },
native: { meta: { emoji_style: 'native' } },
twemoji: { meta: { emoji_style: 'twemoji' } },
},
},
},
render(args) {
void importCustomEmojiData();
return <Emoji {...args} />;
},
} satisfies Meta<EmojiProps>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const CustomEmoji: Story = {
args: {
code: ':custom:',
},
};

View File

@@ -1,90 +0,0 @@
import { useMemo } from 'react';
import classNames from 'classnames';
import type { CustomEmojiMapArg } from '@/flavours/glitch/features/emoji/types';
import { isModernEmojiEnabled } from '@/flavours/glitch/utils/environment';
import type {
OnAttributeHandler,
OnElementHandler,
} from '@/flavours/glitch/utils/html';
import { htmlStringToComponents } from '@/flavours/glitch/utils/html';
import { polymorphicForwardRef } from '@/types/polymorphic';
import { AnimateEmojiProvider, CustomEmojiProvider } from './context';
import { textToEmojis } from './index';
export interface EmojiHTMLProps {
htmlString: string;
extraEmojis?: CustomEmojiMapArg;
className?: string;
onElement?: OnElementHandler;
onAttribute?: OnAttributeHandler;
}
export const ModernEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
(
{
extraEmojis,
htmlString,
as: asProp = 'div', // Rename for syntax highlighting
className = '',
onElement,
onAttribute,
...props
},
ref,
) => {
const contents = useMemo(
() =>
htmlStringToComponents(htmlString, {
onText: textToEmojis,
onElement,
onAttribute,
}),
[htmlString, onAttribute, onElement],
);
return (
<CustomEmojiProvider emojis={extraEmojis}>
<AnimateEmojiProvider
{...props}
as={asProp}
className={className}
ref={ref}
>
{contents}
</AnimateEmojiProvider>
</CustomEmojiProvider>
);
},
);
ModernEmojiHTML.displayName = 'ModernEmojiHTML';
export const LegacyEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
(props, ref) => {
const {
as: asElement,
htmlString,
extraEmojis,
className,
onElement,
onAttribute,
...rest
} = props;
const Wrapper = asElement ?? 'div';
return (
<Wrapper
{...rest}
ref={ref}
dangerouslySetInnerHTML={{ __html: htmlString }}
className={classNames(className, 'animate-parent')}
/>
);
},
);
LegacyEmojiHTML.displayName = 'LegacyEmojiHTML';
export const EmojiHTML = isModernEmojiEnabled()
? ModernEmojiHTML
: LegacyEmojiHTML;

View File

@@ -1,99 +0,0 @@
import type { FC } from 'react';
import { useContext, useEffect, useState } from 'react';
import { EMOJI_TYPE_CUSTOM } from '@/flavours/glitch/features/emoji/constants';
import { useEmojiAppState } from '@/flavours/glitch/features/emoji/mode';
import { unicodeHexToUrl } from '@/flavours/glitch/features/emoji/normalize';
import {
isStateLoaded,
loadEmojiDataToState,
shouldRenderImage,
stringToEmojiState,
tokenizeText,
} from '@/flavours/glitch/features/emoji/render';
import { AnimateEmojiContext, CustomEmojiContext } from './context';
interface EmojiProps {
code: string;
showFallback?: boolean;
showLoading?: boolean;
}
export const Emoji: FC<EmojiProps> = ({
code,
showFallback = true,
showLoading = true,
}) => {
const customEmoji = useContext(CustomEmojiContext);
// First, set the emoji state based on the input code.
const [state, setState] = useState(() =>
stringToEmojiState(code, customEmoji),
);
// If we don't have data, then load emoji data asynchronously.
const appState = useEmojiAppState();
useEffect(() => {
if (state !== null) {
void loadEmojiDataToState(state, appState.currentLocale).then(setState);
}
}, [appState.currentLocale, state]);
const animate = useContext(AnimateEmojiContext);
const fallback = showFallback ? code : null;
// If the code is invalid or we otherwise know it's not valid, show the fallback.
if (!state) {
return fallback;
}
if (!shouldRenderImage(state, appState.mode)) {
return code;
}
if (!isStateLoaded(state)) {
if (showLoading) {
return <span className='emojione emoji-loading' title={code} />;
}
return fallback;
}
if (state.type === EMOJI_TYPE_CUSTOM) {
const shortcode = `:${state.code}:`;
return (
<img
src={animate ? state.data.url : state.data.static_url}
alt={shortcode}
title={shortcode}
className='emojione custom-emoji'
loading='lazy'
/>
);
}
const src = unicodeHexToUrl(state.code, appState.darkTheme);
return (
<img
src={src}
alt={state.data.unicode}
title={state.data.label}
className='emojione'
loading='lazy'
/>
);
};
/**
* Takes a text string and converts it to an array of React nodes.
* @param text The text to be tokenized and converted.
*/
export function textToEmojis(text: string) {
return tokenizeText(text).map((token, index) => {
if (typeof token === 'string') {
return token;
}
return <Emoji code={token.code} key={`emoji-${token.code}-${index}`} />;
});
}

View File

@@ -1,53 +0,0 @@
import { useEffect, useState } from 'react';
/**
* A helper component for managing the rendering of components that
* need to stay in the DOM a bit longer to finish their CSS exit animation.
*
* In the future, replace this component with plain CSS once that is feasible.
* This will require broader support for `transition-behavior: allow-discrete`
* and https://developer.mozilla.org/en-US/docs/Web/CSS/overlay.
*/
export const ExitAnimationWrapper: React.FC<{
/**
* Set this to true to indicate that the nested component should be rendered
*/
isActive: boolean;
/**
* How long the component should be rendered after `isActive` was set to `false`
*/
delayMs?: number;
/**
* Set this to true to also delay the entry of the nested component until after
* another one has exited full.
*/
withEntryDelay?: boolean;
/**
* Render prop that provides the nested component with the `delayedIsActive` flag
*/
children: (delayedIsActive: boolean) => React.ReactNode;
}> = ({ isActive = false, delayMs = 500, withEntryDelay, children }) => {
const [delayedIsActive, setDelayedIsActive] = useState(false);
useEffect(() => {
if (isActive && !withEntryDelay) {
setDelayedIsActive(true);
return () => '';
} else {
const timeout = setTimeout(() => {
setDelayedIsActive(isActive);
}, delayMs);
return () => {
clearTimeout(timeout);
};
}
}, [isActive, delayMs, withEntryDelay]);
if (!isActive && !delayedIsActive) {
return null;
}
return children(isActive && delayedIsActive);
};

View File

@@ -20,7 +20,7 @@ import { useDrag } from '@use-gesture/react';
import { expandAccountFeaturedTimeline } from '@/flavours/glitch/actions/timelines';
import { Icon } from '@/flavours/glitch/components/icon';
import { IconButton } from '@/flavours/glitch/components/icon_button';
import { StatusQuoteManager } from '@/flavours/glitch/components/status_quoted';
import StatusContainer from '@/flavours/glitch/containers/status_container';
import { usePrevious } from '@/flavours/glitch/hooks/usePrevious';
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
@@ -218,7 +218,12 @@ const FeaturedCarouselItem: React.FC<
ref={handleRef}
{...props}
>
<StatusQuoteManager id={statusId} contextType='account' withCounters />
<StatusContainer
// @ts-expect-error inferred props are wrong
id={statusId}
contextType='account'
withCounters
/>
</animated.div>
);
};

View File

@@ -8,7 +8,6 @@ import { useIdentity } from '@/flavours/glitch/identity_context';
import {
fetchRelationships,
followAccount,
unmuteAccount,
} from 'flavours/glitch/actions/accounts';
import { openModal } from 'flavours/glitch/actions/modal';
import { Button } from 'flavours/glitch/components/button';
@@ -16,50 +15,17 @@ import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
import { me } from 'flavours/glitch/initial_state';
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
import { useBreakpoint } from '../features/ui/hooks/useBreakpoint';
const longMessages = defineMessages({
const messages = defineMessages({
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' },
unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' },
follow: { id: 'account.follow', defaultMessage: 'Follow' },
followBack: { id: 'account.follow_back', defaultMessage: 'Follow back' },
followRequest: {
id: 'account.follow_request',
defaultMessage: 'Request to follow',
},
followRequestCancel: {
id: 'account.follow_request_cancel',
defaultMessage: 'Cancel request',
},
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
});
const shortMessages = {
...longMessages, // Align type signature of shortMessages and longMessages
...defineMessages({
followBack: {
id: 'account.follow_back_short',
defaultMessage: 'Follow back',
},
followRequest: {
id: 'account.follow_request_short',
defaultMessage: 'Request',
},
followRequestCancel: {
id: 'account.follow_request_cancel_short',
defaultMessage: 'Cancel',
},
editProfile: { id: 'account.edit_profile_short', defaultMessage: 'Edit' },
}),
};
export const FollowButton: React.FC<{
accountId?: string;
compact?: boolean;
labelLength?: 'auto' | 'short' | 'long';
className?: string;
}> = ({ accountId, compact, labelLength = 'auto', className }) => {
}> = ({ accountId, compact }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const { signedIn } = useIdentity();
@@ -94,60 +60,29 @@ export const FollowButton: React.FC<{
if (accountId === me) {
return;
} else if (relationship.muting) {
dispatch(unmuteAccount(accountId));
} else if (account && relationship.following) {
} else if (account && (relationship.following || relationship.requested)) {
dispatch(
openModal({ modalType: 'CONFIRM_UNFOLLOW', modalProps: { account } }),
);
} else if (account && relationship.requested) {
dispatch(
openModal({
modalType: 'CONFIRM_WITHDRAW_REQUEST',
modalProps: { account },
}),
);
} else if (relationship.blocking) {
dispatch(
openModal({
modalType: 'CONFIRM_UNBLOCK',
modalProps: { account },
}),
);
} else {
dispatch(followAccount(accountId));
}
}, [dispatch, accountId, relationship, account, signedIn]);
const isNarrow = useBreakpoint('narrow');
const useShortLabel =
labelLength === 'short' || (labelLength === 'auto' && isNarrow);
const messages = useShortLabel ? shortMessages : longMessages;
const followMessage = account?.locked
? messages.followRequest
: messages.follow;
let label;
if (!signedIn) {
label = intl.formatMessage(followMessage);
label = intl.formatMessage(messages.follow);
} else if (accountId === me) {
label = intl.formatMessage(messages.edit_profile);
} else if (!relationship) {
label = <LoadingIndicator />;
} else if (relationship.muting) {
label = intl.formatMessage(messages.unmute);
} else if (relationship.following) {
} else if (relationship.following || relationship.requested) {
label = intl.formatMessage(messages.unfollow);
} else if (relationship.blocking) {
label = intl.formatMessage(messages.unblock);
} else if (relationship.requested) {
label = intl.formatMessage(messages.followRequestCancel);
} else if (relationship.followed_by && !account?.locked) {
} else if (relationship.followed_by) {
label = intl.formatMessage(messages.followBack);
} else {
label = intl.formatMessage(followMessage);
label = intl.formatMessage(messages.follow);
}
if (accountId === me) {
@@ -156,7 +91,7 @@ export const FollowButton: React.FC<{
href='/settings/profile'
target='_blank'
rel='noopener'
className={classNames(className, 'button button-secondary', {
className={classNames('button button-secondary', {
'button--compact': compact,
})}
>
@@ -170,12 +105,13 @@ export const FollowButton: React.FC<{
onClick={handleClick}
disabled={
relationship?.blocked_by ||
relationship?.blocking ||
(!(relationship?.following || relationship?.requested) &&
(account?.suspended || !!account?.moved))
}
secondary={following}
compact={compact}
className={classNames(className, { 'button--destructive': following })}
className={following ? 'button--destructive' : undefined}
>
{label}
</Button>

View File

@@ -33,7 +33,7 @@ function isNodeLinkHashtag(element: Node): element is HTMLLinkElement {
return (
element instanceof HTMLAnchorElement &&
// it may be a <a> starting with a hashtag
(element.textContent.startsWith('#') ||
(element.textContent?.[0] === '#' ||
// or a #<a>
element.previousSibling?.textContent?.[
element.previousSibling.textContent.length - 1

View File

@@ -23,8 +23,6 @@ import { domain } from 'flavours/glitch/initial_state';
import { getAccountHidden } from 'flavours/glitch/selectors/accounts';
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
import { useLinks } from '../hooks/useLinks';
export const HoverCardAccount = forwardRef<
HTMLDivElement,
{ accountId?: string }
@@ -66,8 +64,6 @@ export const HoverCardAccount = forwardRef<
!isMutual &&
!isFollower;
const handleClick = useLinks();
return (
<div
ref={ref}
@@ -113,14 +109,7 @@ export const HoverCardAccount = forwardRef<
accountId={account.id}
className='hover-card__bio'
/>
<div className='account-fields' onClickCapture={handleClick}>
<AccountFields
fields={account.fields.take(2)}
emojis={account.emojis}
/>
</div>
<AccountFields fields={account.fields} limit={2} />
{note && note.length > 0 && (
<dl className='hover-card__note'>
<dt className='hover-card__note-label'>

View File

@@ -1,66 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect } from 'storybook/test';
import { HTMLBlock } from './index';
const meta = {
title: 'Components/HTMLBlock',
component: HTMLBlock,
args: {
htmlString: `<p>Hello, world!</p>
<p><a href="#">A link</a></p>
<p>This should be filtered out: <button>Bye!</button></p>
<p>This also has emoji: 🖤</p>`,
},
argTypes: {
extraEmojis: {
table: {
disable: true,
},
},
onElement: {
table: {
disable: true,
},
},
onAttribute: {
table: {
disable: true,
},
},
},
render(args) {
return (
// Just for visual clarity in Storybook.
<HTMLBlock
{...args}
style={{
border: '1px solid black',
padding: '1rem',
minWidth: '300px',
}}
/>
);
},
// Force Twemoji to demonstrate emoji rendering.
parameters: {
state: {
meta: {
emoji_style: 'twemoji',
},
},
},
} satisfies Meta<typeof HTMLBlock>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
async play({ canvas }) {
const link = canvas.queryByRole('link');
await expect(link).toBeInTheDocument();
const button = canvas.queryByRole('button');
await expect(button).not.toBeInTheDocument();
},
};

View File

@@ -1,30 +0,0 @@
import { useCallback } from 'react';
import type { OnElementHandler } from '@/flavours/glitch/utils/html';
import { polymorphicForwardRef } from '@/types/polymorphic';
import type { EmojiHTMLProps } from '../emoji/html';
import { ModernEmojiHTML } from '../emoji/html';
import { useElementHandledLink } from '../status/handled_link';
export const HTMLBlock = polymorphicForwardRef<
'div',
EmojiHTMLProps & Parameters<typeof useElementHandledLink>[0]
>(
({
onElement: onParentElement,
hrefToMention,
hashtagAccountId,
...props
}) => {
const { onElement: onLinkElement } = useElementHandledLink({
hrefToMention,
hashtagAccountId,
});
const onElement: OnElementHandler = useCallback(
(...args) => onParentElement?.(...args) ?? onLinkElement(...args),
[onLinkElement, onParentElement],
);
return <ModernEmojiHTML {...props} onElement={onElement} />;
},
);

View File

@@ -8,7 +8,6 @@ import classNames from 'classnames';
import { animated, useSpring } from '@react-spring/web';
import escapeTextContentForBrowser from 'escape-html';
import { EmojiHTML } from '@/flavours/glitch/components/emoji/html';
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
import { openModal } from 'flavours/glitch/actions/modal';
import { fetchPoll, vote } from 'flavours/glitch/actions/polls';
@@ -306,11 +305,10 @@ const PollOption: React.FC<PollOptionProps> = (props) => {
</span>
)}
<EmojiHTML
<span
className='poll__option__text translate'
lang={lang}
htmlString={titleHtml}
extraEmojis={poll.emojis}
dangerouslySetInnerHTML={{ __html: titleHtml }}
/>
{!!voted && (

View File

@@ -1,7 +1,6 @@
import type { PropsWithChildren } from 'react';
import type React from 'react';
import type { useLocation } from 'react-router';
import { Router as OriginalRouter, useHistory } from 'react-router';
import type {
@@ -19,9 +18,7 @@ interface MastodonLocationState {
mastodonModalKey?: string;
}
export type LocationState = MastodonLocationState | null | undefined;
export type MastodonLocation = ReturnType<typeof useLocation<LocationState>>;
type LocationState = MastodonLocationState | null | undefined;
type HistoryPath = Path | LocationDescriptor<LocationState>;

View File

@@ -10,7 +10,7 @@ import { connect } from 'react-redux';
import { supportsPassiveEvents } from 'detect-passive-events';
import { throttle } from 'lodash';
import { ScrollContainer } from 'flavours/glitch/containers/scroll_container';
import ScrollContainer from 'flavours/glitch/containers/scroll_container';
import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container';
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../features/ui/util/fullscreen';
@@ -399,7 +399,7 @@ class ScrollableList extends PureComponent {
if (trackScroll) {
return (
<ScrollContainer scrollKey={scrollKey} childRef={this.setRef}>
<ScrollContainer scrollKey={scrollKey}>
{scrollableArea}
</ScrollContainer>
);

View File

@@ -118,7 +118,6 @@ class Status extends ImmutablePureComponent {
prepend: PropTypes.string,
withDismiss: PropTypes.bool,
isQuotedPost: PropTypes.bool,
shouldHighlightOnMount: PropTypes.bool,
getScrollPosition: PropTypes.func,
updateScrollBottom: PropTypes.func,
expanded: PropTypes.bool,
@@ -706,7 +705,6 @@ class Status extends ImmutablePureComponent {
muted: this.props.muted,
'status--is-quote': isQuotedPost,
'status--has-quote': !!status.get('quote'),
'status--highlighted-entry': this.props.shouldHighlightOnMount,
})
}
data-id={status.get('id')}
@@ -739,7 +737,7 @@ class Status extends ImmutablePureComponent {
</header>
)}
<ContentWarning status={status} expanded={expanded} onClick={this.handleExpandedToggle} icons={mediaIcons} />
{status.get('spoiler_text').length > 0 && <ContentWarning text={status.getIn(['translation', 'spoilerHtml']) || status.get('spoilerHtml')} expanded={expanded} onClick={this.handleExpandedToggle} icons={mediaIcons} />}
{expanded && (
<>

View File

@@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
import type { StatusVisibility } from '@/flavours/glitch/api_types/statuses';
import { statusFactoryState } from '@/testing/factories';
import { BoostButton } from './boost_button';
import { LegacyReblogButton, StatusBoostButton } from './boost_button';
interface StoryProps {
visibility: StatusVisibility;
@@ -38,7 +38,10 @@ const meta = {
},
},
render: (args) => (
<BoostButton status={argsToStatus(args)} counters={args.reblogCount > 0} />
<StatusBoostButton
status={argsToStatus(args)}
counters={args.reblogCount > 0}
/>
),
} satisfies Meta<StoryProps>;
@@ -75,3 +78,12 @@ export const Mine: Story = {
},
},
};
export const Legacy: Story = {
render: (args) => (
<LegacyReblogButton
status={argsToStatus(args)}
counters={args.reblogCount > 0}
/>
),
};

View File

@@ -1,5 +1,5 @@
import { useCallback, useMemo } from 'react';
import type { FC, KeyboardEvent, MouseEvent } from 'react';
import type { FC, KeyboardEvent, MouseEvent, MouseEventHandler } from 'react';
import { useIntl } from 'react-intl';
@@ -11,6 +11,7 @@ import { openModal } from '@/flavours/glitch/actions/modal';
import type { ActionMenuItem } from '@/flavours/glitch/models/dropdown_menu';
import type { Status } from '@/flavours/glitch/models/status';
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
import { isFeatureEnabled } from '@/flavours/glitch/utils/environment';
import type { SomeRequired } from '@/flavours/glitch/utils/types';
import type { RenderItemFn, RenderItemFnHandlers } from '../dropdown_menu';
@@ -46,7 +47,10 @@ interface ReblogButtonProps {
type ActionMenuItemWithIcon = SomeRequired<ActionMenuItem, 'icon'>;
export const BoostButton: FC<ReblogButtonProps> = ({ status, counters }) => {
export const StatusBoostButton: FC<ReblogButtonProps> = ({
status,
counters,
}) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const statusState = useAppSelector((state) =>
@@ -188,3 +192,65 @@ const ReblogMenuItem: FC<ReblogMenuItemProps> = ({
</li>
);
};
// Legacy helpers
// Switch between the legacy and new reblog button based on feature flag.
export const BoostButton: FC<ReblogButtonProps> = (props) => {
if (isFeatureEnabled('outgoing_quotes')) {
return <StatusBoostButton {...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(
() => boostItemState(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: {
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
}
/>
);
};

View File

@@ -1,102 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { HashtagMenuController } from '@/flavours/glitch/features/ui/components/hashtag_menu_controller';
import { accountFactoryState } from '@/testing/factories';
import { HoverCardController } from '../hover_card_controller';
import type { HandledLinkProps } from './handled_link';
import { HandledLink } from './handled_link';
type HandledLinkStoryProps = Pick<
HandledLinkProps,
'href' | 'text' | 'prevText'
> & {
mentionAccount: 'local' | 'remote' | 'none';
hashtagAccount: boolean;
};
const meta = {
title: 'Components/Status/HandledLink',
render({ mentionAccount, hashtagAccount, ...args }) {
let mention: HandledLinkProps['mention'] | undefined;
if (mentionAccount === 'local') {
mention = { id: '1', acct: 'testuser' };
} else if (mentionAccount === 'remote') {
mention = { id: '2', acct: 'remoteuser@mastodon.social' };
}
return (
<>
<HandledLink
{...args}
mention={mention}
hashtagAccountId={hashtagAccount ? '1' : undefined}
>
<span>{args.text}</span>
</HandledLink>
<HashtagMenuController />
<HoverCardController />
</>
);
},
args: {
href: 'https://example.com/path/subpath?query=1#hash',
text: 'https://example.com',
mentionAccount: 'none',
hashtagAccount: false,
},
argTypes: {
mentionAccount: {
control: { type: 'select' },
options: ['local', 'remote', 'none'],
defaultValue: 'none',
},
},
parameters: {
state: {
accounts: {
'1': accountFactoryState({ id: '1', acct: 'hashtaguser' }),
},
},
},
} satisfies Meta<HandledLinkStoryProps>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Simple: Story = {
args: {
href: 'https://example.com/test',
},
};
export const Hashtag: Story = {
args: {
text: '#example',
hashtagAccount: true,
},
};
export const Mention: Story = {
args: {
text: '@user',
mentionAccount: 'local',
},
};
export const InternalLink: Story = {
args: {
href: '/about',
text: 'About',
},
};
export const InvalidURL: Story = {
args: {
href: 'ht!tp://invalid-url',
text: 'ht!tp://invalid-url -- invalid!',
},
};

View File

@@ -1,109 +0,0 @@
import { useCallback } from 'react';
import type { ComponentProps, FC } from 'react';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import type { ApiMentionJSON } from '@/flavours/glitch/api_types/statuses';
import type { OnElementHandler } from '@/flavours/glitch/utils/html';
export interface HandledLinkProps {
href: string;
text: string;
prevText?: string;
hashtagAccountId?: string;
mention?: Pick<ApiMentionJSON, 'id' | 'acct'>;
}
export const HandledLink: FC<HandledLinkProps & ComponentProps<'a'>> = ({
href,
text,
prevText,
hashtagAccountId,
mention,
className,
children,
...props
}) => {
// Handle hashtags
if (text.startsWith('#') || prevText?.endsWith('#')) {
const hashtag = text.slice(1).trim();
return (
<Link
className={classNames('mention hashtag', className)}
to={`/tags/${hashtag}`}
rel='tag'
data-menu-hashtag={hashtagAccountId}
>
{children}
</Link>
);
} else if ((text.startsWith('@') || prevText?.endsWith('@')) && mention) {
// Handle mentions
return (
<Link
className={classNames('mention', className)}
to={`/@${mention.acct}`}
title={`@${mention.acct}`}
data-hover-card-account={mention.id}
>
{children}
</Link>
);
}
// Non-absolute paths treated as internal links. This shouldn't happen, but just in case.
if (href.startsWith('/')) {
return (
<Link className={classNames('unhandled-link', className)} to={href}>
{children}
</Link>
);
}
return (
<a
{...props}
href={href}
title={href}
className={classNames('unhandled-link', className)}
target='_blank'
rel='noreferrer noopener'
translate='no'
>
{children}
</a>
);
};
export const useElementHandledLink = ({
hashtagAccountId,
hrefToMention,
}: {
hashtagAccountId?: string;
hrefToMention?: (href: string) => ApiMentionJSON | undefined;
} = {}) => {
const onElement = useCallback<OnElementHandler>(
(element, { key, ...props }, children) => {
if (element instanceof HTMLAnchorElement) {
const mention = hrefToMention?.(element.href);
return (
<HandledLink
{...props}
key={key as string} // React requires keys to not be part of spread props.
href={element.href}
text={element.innerText}
prevText={element.previousSibling?.textContent ?? undefined}
hashtagAccountId={hashtagAccountId}
mention={mention}
>
{children}
</HandledLink>
);
}
return undefined;
},
[hashtagAccountId, hrefToMention],
);
return { onElement };
};

View File

@@ -26,6 +26,7 @@ import { me } from '../../initial_state';
import { IconButton } from '../icon_button';
import { RelativeTimestamp } from '../relative_timestamp';
import { isFeatureEnabled } from '../../utils/environment';
import { BoostButton } from '../status/boost_button';
import { RemoveQuoteHint } from './remove_quote_hint';
@@ -253,7 +254,7 @@ class StatusActionBar extends ImmutablePureComponent {
if (writtenByMe || withDismiss) {
menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
if (writtenByMe && !['private', 'direct'].includes(status.get('visibility'))) {
if (writtenByMe && isFeatureEnabled('outgoing_quotes') && !['private', 'direct'].includes(status.get('visibility'))) {
menu.push({ text: intl.formatMessage(messages.quotePolicyChange), action: this.handleQuotePolicyChange });
}
menu.push(null);

View File

@@ -3,8 +3,6 @@ import { useCallback, useRef, useId } from 'react';
import { FormattedMessage } from 'react-intl';
import { AnimateEmojiProvider } from './emoji/context';
export enum BannerVariant {
Warning = 'warning',
Filter = 'filter',
@@ -36,7 +34,8 @@ export const StatusBanner: React.FC<{
return (
// Element clicks are passed on to button
<AnimateEmojiProvider
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div
className={
variant === BannerVariant.Warning
? 'content-warning'
@@ -70,6 +69,6 @@ export const StatusBanner: React.FC<{
/>
)}
</button>
</AnimateEmojiProvider>
</div>
);
};

View File

@@ -13,14 +13,11 @@ import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'
import { Icon } from 'flavours/glitch/components/icon';
import { Poll } from 'flavours/glitch/components/poll';
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
import { languages as preloadedLanguages } from 'flavours/glitch/initial_state';
import { autoPlayGif, languages as preloadedLanguages } from 'flavours/glitch/initial_state';
import { decode as decodeIDNA } from 'flavours/glitch/utils/idna';
import { EmojiHTML } from '../features/emoji/emoji_html';
import { isModernEmojiEnabled } from '../utils/environment';
import { EmojiHTML } from './emoji/html';
import { HandledLink } from './status/handled_link';
const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
const textMatchesTarget = (text, origin, host) => {
@@ -84,6 +81,9 @@ const isLinkMisleading = (link) => {
* @returns {string}
*/
export function getStatusContent(status) {
if (isModernEmojiEnabled()) {
return status.getIn(['translation', 'content']) || status.get('content');
}
return status.getIn(['translation', 'contentHtml']) || status.get('contentHtml');
}
@@ -161,23 +161,6 @@ class StatusContent extends PureComponent {
}
const { status, onCollapsedToggle } = this.props;
if (status.get('collapsed', null) === null && onCollapsedToggle) {
const { collapsible, onClick } = this.props;
const collapsed =
collapsible
&& onClick
&& node.clientHeight > MAX_HEIGHT
&& status.get('spoiler_text').length === 0;
onCollapsedToggle(collapsed);
}
// Exit if modern emoji is enabled, as it handles links using the HandledLink component.
if (isModernEmojiEnabled()) {
return;
}
const links = node.querySelectorAll('a');
let link, mention;
@@ -240,8 +223,46 @@ class StatusContent extends PureComponent {
}
}
}
if (status.get('collapsed', null) === null && onCollapsedToggle) {
const { collapsible, onClick } = this.props;
const collapsed =
collapsible
&& onClick
&& node.clientHeight > MAX_HEIGHT
&& status.get('spoiler_text').length === 0;
onCollapsedToggle(collapsed);
}
}
handleMouseEnter = ({ currentTarget }) => {
if (autoPlayGif) {
return;
}
const emojis = currentTarget.querySelectorAll('.custom-emoji');
for (var i = 0; i < emojis.length; i++) {
let emoji = emojis[i];
emoji.src = emoji.getAttribute('data-original');
}
};
handleMouseLeave = ({ currentTarget }) => {
if (autoPlayGif) {
return;
}
const emojis = currentTarget.querySelectorAll('.custom-emoji');
for (var i = 0; i < emojis.length; i++) {
let emoji = emojis[i];
emoji.src = emoji.getAttribute('data-static');
}
};
componentDidMount () {
this._updateStatusLinks();
}
@@ -301,27 +322,6 @@ class StatusContent extends PureComponent {
this.node = c;
};
handleElement = (element, { key, ...props }, children) => {
if (element instanceof HTMLAnchorElement) {
const mention = this.props.status.get('mentions').find(item => element.href === item.get('url'));
return (
<HandledLink
{...props}
href={element.href}
text={element.innerText}
hashtagAccountId={this.props.status.getIn(['account', 'id'])}
mention={mention?.toJSON()}
key={key}
>
{children}
</HandledLink>
);
} else if (element instanceof HTMLParagraphElement && element.classList.contains('quote-inline')) {
return null;
}
return undefined;
}
render () {
const { status, intl, statusContent } = this.props;
@@ -354,19 +354,12 @@ class StatusContent extends PureComponent {
if (this.props.onClick) {
return (
<>
<div
className={classNames}
ref={this.setRef}
onMouseDown={this.handleMouseDown}
onMouseUp={this.handleMouseUp}
key='status-content'
>
<div className={classNames} ref={this.setRef} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
<EmojiHTML
className='status__content__text status__content__text--visible translate'
lang={language}
htmlString={content}
extraEmojis={status.get('emojis')}
onElement={this.handleElement.bind(this)}
/>
{poll}
@@ -378,13 +371,12 @@ class StatusContent extends PureComponent {
);
} else {
return (
<div className={classNames} ref={this.setRef}>
<div className={classNames} ref={this.setRef} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
<EmojiHTML
className='status__content__text status__content__text--visible translate'
lang={language}
htmlString={content}
extraEmojis={status.get('emojis')}
onElement={this.handleElement.bind(this)}
/>
{poll}

View File

@@ -1,17 +1,10 @@
import { EmojiHTML } from '@/flavours/glitch/components/emoji/html';
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
import { isModernEmojiEnabled } from '../utils/environment';
import type { OnAttributeHandler } from '../utils/html';
import { Icon } from './icon';
const domParser = new DOMParser();
const stripRelMe = (html: string) => {
if (isModernEmojiEnabled()) {
return html;
}
const document = domParser.parseFromString(html, 'text/html').documentElement;
document.querySelectorAll<HTMLAnchorElement>('a[rel]').forEach((link) => {
@@ -22,23 +15,7 @@ const stripRelMe = (html: string) => {
});
const body = document.querySelector('body');
return body?.innerHTML ?? '';
};
const onAttribute: OnAttributeHandler = (name, value, tagName) => {
if (name === 'rel' && tagName === 'a') {
if (value === 'me') {
return null;
}
return [
name,
value
.split(' ')
.filter((x) => x !== 'me')
.join(' '),
];
}
return undefined;
return body ? { __html: body.innerHTML } : undefined;
};
interface Props {
@@ -47,10 +24,6 @@ interface Props {
export const VerifiedBadge: React.FC<Props> = ({ link }) => (
<span className='verified-badge'>
<Icon id='check' icon={CheckIcon} className='verified-badge__mark' />
<EmojiHTML
as='span'
htmlString={stripRelMe(link)}
onAttribute={onAttribute}
/>
<span dangerouslySetInnerHTML={stripRelMe(link)} />
</span>
);

View File

@@ -5,7 +5,7 @@ import { fetchServer } from 'flavours/glitch/actions/server';
import { hydrateStore } from 'flavours/glitch/actions/store';
import { Router } from 'flavours/glitch/components/router';
import Compose from 'flavours/glitch/features/standalone/compose';
import { initialState } from 'flavours/glitch/initial_state';
import initialState from 'flavours/glitch/initial_state';
import { IntlProvider } from 'flavours/glitch/locales';
import { store } from 'flavours/glitch/store';

View File

@@ -5,6 +5,7 @@ import { Route } from 'react-router-dom';
import { Provider as ReduxProvider } from 'react-redux';
import { ScrollContext } from 'react-router-scroll-4';
import { fetchCustomEmojis } from 'flavours/glitch/actions/custom_emojis';
import { checkDeprecatedLocalSettings } from 'flavours/glitch/actions/local_settings';
@@ -14,14 +15,12 @@ import ErrorBoundary from 'flavours/glitch/components/error_boundary';
import { Router } from 'flavours/glitch/components/router';
import UI from 'flavours/glitch/features/ui';
import { IdentityContext, createIdentityContext } from 'flavours/glitch/identity_context';
import { initialState, title as siteTitle } from 'flavours/glitch/initial_state';
import initialState, { title as siteTitle } from 'flavours/glitch/initial_state';
import { IntlProvider } from 'flavours/glitch/locales';
import { store } from 'flavours/glitch/store';
import { isProduction } from 'flavours/glitch/utils/environment';
import { BodyScrollLock } from 'flavours/glitch/features/ui/components/body_scroll_lock';
import { ScrollContext } from './scroll_container/scroll_context';
const title = isProduction() ? siteTitle : `${siteTitle} (Dev)`;
const hydrateAction = hydrateStore(initialState);
@@ -51,6 +50,10 @@ export default class Mastodon extends PureComponent {
}
}
shouldUpdateScroll (prevRouterProps, { location }) {
return !(location.state?.mastodonModalKey && location.state?.mastodonModalKey !== prevRouterProps?.location?.state?.mastodonModalKey);
}
render () {
return (
<IdentityContext.Provider value={this.identity}>
@@ -58,7 +61,7 @@ export default class Mastodon extends PureComponent {
<ReduxProvider store={store}>
<ErrorBoundary>
<Router>
<ScrollContext>
<ScrollContext shouldUpdateScroll={this.shouldUpdateScroll}>
<Route path='/' component={UI} />
</ScrollContext>
<BodyScrollLock />

View File

@@ -0,0 +1,18 @@
import { ScrollContainer as OriginalScrollContainer } from 'react-router-scroll-4';
// ScrollContainer is used to automatically scroll to the top when pushing a
// new history state and remembering the scroll position when going back.
// There are a few things we need to do differently, though.
const defaultShouldUpdateScroll = (prevRouterProps, { location }) => {
// If the change is caused by opening a modal, do not scroll to top
return !(location.state?.mastodonModalKey && location.state?.mastodonModalKey !== prevRouterProps?.location?.state?.mastodonModalKey);
};
export default
class ScrollContainer extends OriginalScrollContainer {
static defaultProps = {
shouldUpdateScroll: defaultShouldUpdateScroll,
};
}

View File

@@ -1,25 +0,0 @@
import type { MastodonLocation } from 'flavours/glitch/components/router';
export type ShouldUpdateScrollFn = (
prevLocationContext: MastodonLocation | null,
locationContext: MastodonLocation,
) => boolean;
/**
* ScrollBehavior will automatically scroll to the top on navigations
* or restore saved scroll positions, but on some location changes we
* need to prevent this.
*/
export const defaultShouldUpdateScroll: ShouldUpdateScrollFn = (
prevLocation,
location,
) => {
// If the change is caused by opening a modal, do not scroll to top
const shouldUpdateScroll = !(
location.state?.mastodonModalKey &&
location.state.mastodonModalKey !== prevLocation?.state?.mastodonModalKey
);
return shouldUpdateScroll;
};

View File

@@ -1,76 +0,0 @@
import React, {
useContext,
useEffect,
useImperativeHandle,
useRef,
} from 'react';
import { defaultShouldUpdateScroll } from './default_should_update_scroll';
import type { ShouldUpdateScrollFn } from './default_should_update_scroll';
import { ScrollBehaviorContext } from './scroll_context';
interface ScrollContainerProps {
/**
* This key must be static for the element & not change
* while the component is mounted.
*/
scrollKey: string;
shouldUpdateScroll?: ShouldUpdateScrollFn;
childRef?: React.ForwardedRef<HTMLElement | undefined>;
children: React.ReactElement;
}
/**
* `ScrollContainer` is used to manage the scroll position of elements on the page
* that can be scrolled independently of the page body.
* This component is a port of the unmaintained https://github.com/ytase/react-router-scroll/
*/
export const ScrollContainer: React.FC<ScrollContainerProps> = ({
children,
scrollKey,
childRef,
shouldUpdateScroll = defaultShouldUpdateScroll,
}) => {
const scrollBehaviorContext = useContext(ScrollBehaviorContext);
const containerRef = useRef<HTMLElement>();
/**
* If a childRef is passed, sync it with the containerRef. This
* is necessary because in this component's return statement,
* we're overwriting the immediate child component's ref prop.
*/
useImperativeHandle(childRef, () => containerRef.current, []);
/**
* Register/unregister scrollable element with ScrollBehavior
*/
useEffect(() => {
if (!scrollBehaviorContext || !containerRef.current) {
return;
}
scrollBehaviorContext.registerElement(
scrollKey,
containerRef.current,
(prevLocation, location) => {
// Hack to allow accessing scrollBehavior._stateStorage
return shouldUpdateScroll.call(
scrollBehaviorContext.scrollBehavior,
prevLocation,
location,
);
},
);
return () => {
scrollBehaviorContext.unregisterElement(scrollKey);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return React.Children.only(
React.cloneElement(children, { ref: containerRef }),
);
};

View File

@@ -1,141 +0,0 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useHistory } from 'react-router-dom';
import type { LocationBase } from 'scroll-behavior';
import ScrollBehavior from 'scroll-behavior';
import type {
LocationState,
MastodonLocation,
} from 'flavours/glitch/components/router';
import { usePrevious } from 'flavours/glitch/hooks/usePrevious';
import { defaultShouldUpdateScroll } from './default_should_update_scroll';
import type { ShouldUpdateScrollFn } from './default_should_update_scroll';
import { SessionStorage } from './state_storage';
type ScrollBehaviorInstance = InstanceType<
typeof ScrollBehavior<LocationBase, MastodonLocation>
>;
export interface ScrollBehaviorContextType {
registerElement: (
key: string,
element: HTMLElement,
shouldUpdateScroll: (
prevLocationContext: MastodonLocation | null,
locationContext: MastodonLocation,
) => boolean,
) => void;
unregisterElement: (key: string) => void;
scrollBehavior?: ScrollBehaviorInstance;
}
export const ScrollBehaviorContext =
React.createContext<ScrollBehaviorContextType | null>(null);
interface ScrollContextProps {
shouldUpdateScroll?: ShouldUpdateScrollFn;
children: React.ReactElement;
}
/**
* A top-level wrapper that provides the app with an instance of the
* ScrollBehavior object. scroll-behavior is a library for managing the
* scroll position of a single-page app in the same way the browser would
* normally do for a multi-page app. This means it'll scroll back to top
* when navigating to a new page, and will restore the scroll position
* when navigating e.g. using `history.back`.
* The library keeps a record of scroll positions in session storage.
*
* This component is a port of the unmaintained https://github.com/ytase/react-router-scroll/
*/
export const ScrollContext: React.FC<ScrollContextProps> = ({
children,
shouldUpdateScroll = defaultShouldUpdateScroll,
}) => {
const location = useLocation<LocationState>();
const history = useHistory<LocationState>();
/**
* Keep the current location in a mutable ref so that ScrollBehavior's
* `getCurrentLocation` can access it without having to recreate the
* whole ScrollBehavior object
*/
const currentLocationRef = useRef(location);
useEffect(() => {
currentLocationRef.current = location;
}, [location]);
/**
* Initialise ScrollBehavior object once using state rather
* than a ref to simplify the types and ensure it's defined immediately.
*/
const [scrollBehavior] = useState(
(): ScrollBehaviorInstance =>
new ScrollBehavior({
addNavigationListener: history.listen.bind(history),
stateStorage: new SessionStorage(),
getCurrentLocation: () =>
currentLocationRef.current as unknown as LocationBase,
shouldUpdateScroll: (
prevLocationContext: MastodonLocation | null,
locationContext: MastodonLocation,
) =>
// Hack to allow accessing scrollBehavior._stateStorage
shouldUpdateScroll.call(
scrollBehavior,
prevLocationContext,
locationContext,
),
}),
);
/**
* Handle scroll update when location changes
*/
const prevLocation = usePrevious(location) ?? null;
useEffect(() => {
scrollBehavior.updateScroll(prevLocation, location);
}, [location, prevLocation, scrollBehavior]);
/**
* Stop Scrollbehavior on unmount
*/
useEffect(() => {
return () => {
scrollBehavior.stop();
};
}, [scrollBehavior]);
/**
* Provide the app with a way to register separately scrollable
* elements to also be tracked by ScrollBehavior. (By default
* ScrollBehavior only handles scrolling on the main document body.)
*/
const contextValue = useMemo<ScrollBehaviorContextType>(
() => ({
registerElement: (key, element, shouldUpdateScroll) => {
scrollBehavior.registerElement(
key,
element,
shouldUpdateScroll,
location,
);
},
unregisterElement: (key) => {
scrollBehavior.unregisterElement(key);
},
scrollBehavior,
}),
[location, scrollBehavior],
);
return (
<ScrollBehaviorContext.Provider value={contextValue}>
{React.Children.only(children)}
</ScrollBehaviorContext.Provider>
);
};

View File

@@ -1,46 +0,0 @@
import type { LocationBase, ScrollPosition } from 'scroll-behavior';
const STATE_KEY_PREFIX = '@@scroll|';
interface LocationBaseWithKey extends LocationBase {
key?: string;
}
/**
* This module is part of our port of https://github.com/ytase/react-router-scroll/
* and handles storing scroll positions in SessionStorage.
* Stored positions (`[x, y]`) are keyed by the location key and an optional
* `scrollKey` that's used for to track separately scrollable elements other
* than the document body.
*/
export class SessionStorage {
read(
location: LocationBaseWithKey,
key: string | null,
): ScrollPosition | null {
const stateKey = this.getStateKey(location, key);
try {
const value = sessionStorage.getItem(stateKey);
return value ? (JSON.parse(value) as ScrollPosition) : null;
} catch {
return null;
}
}
save(location: LocationBaseWithKey, key: string | null, value: unknown) {
const stateKey = this.getStateKey(location, key);
const storedValue = JSON.stringify(value);
try {
sessionStorage.setItem(stateKey, storedValue);
} catch {}
}
getStateKey(location: LocationBaseWithKey, key: string | null) {
const locationKey = location.key;
const stateKeyBase = `${STATE_KEY_PREFIX}${locationKey}`;
return key == null ? stateKeyBase : `${stateKeyBase}|${key}`;
}
}

View File

@@ -35,6 +35,7 @@ import {
import Status from 'flavours/glitch/components/status';
import { deleteModal } from 'flavours/glitch/initial_state';
import { makeGetStatus, makeGetPictureInPicture } from 'flavours/glitch/selectors';
import { isFeatureEnabled } from 'flavours/glitch/utils/environment';
import { setStatusQuotePolicy } from '../actions/statuses_typed';
@@ -84,7 +85,9 @@ const mapDispatchToProps = (dispatch, { contextType }) => ({
},
onQuote (status) {
dispatch(quoteComposeById(status.get('id')));
if (isFeatureEnabled('outgoing_quotes')) {
dispatch(quoteComposeById(status.get('id')));
}
},
onReblog (status, e) {

View File

@@ -1,7 +1,6 @@
import { createRoot } from 'react-dom/client';
import Rails from '@rails/ujs';
import { decode, ValidationError } from 'blurhash';
import ready from 'flavours/glitch/ready';
@@ -363,46 +362,6 @@ ready(() => {
document.querySelectorAll('[data-admin-component]').forEach((element) => {
void mountReactComponent(element);
});
document
.querySelectorAll<HTMLCanvasElement>('canvas[data-blurhash]')
.forEach((canvas) => {
const blurhash = canvas.dataset.blurhash;
if (blurhash) {
try {
// decode returns a Uint8ClampedArray<ArrayBufferLike> not Uint8ClampedArray<ArrayBuffer>
const pixels = decode(
blurhash,
32,
32,
) as Uint8ClampedArray<ArrayBuffer>;
const ctx = canvas.getContext('2d');
const imageData = new ImageData(pixels, 32, 32);
ctx?.putImageData(imageData, 0, 0);
} catch (err) {
if (err instanceof ValidationError) {
// ignore blurhash validation errors
return;
}
throw err;
}
}
});
document
.querySelectorAll<HTMLDivElement>('.preview-card')
.forEach((previewCard) => {
const spoilerButton = previewCard.querySelector('.spoiler-button');
if (!spoilerButton) {
return;
}
spoilerButton.addEventListener('click', () => {
previewCard.classList.toggle('preview-card--image-visible');
});
});
}).catch((reason: unknown) => {
throw reason;
});

View File

@@ -70,7 +70,7 @@ function loaded() {
};
document.querySelectorAll('.emojify').forEach((content) => {
content.innerHTML = emojify(content.innerHTML, {}, true); // Force emojify as public doesn't load the new emoji system.
content.innerHTML = emojify(content.innerHTML);
});
document

View File

@@ -7,9 +7,8 @@ import { Helmet } from 'react-helmet';
import { NavLink } from 'react-router-dom';
import { AccountBio } from '@/flavours/glitch/components/account_bio';
import { AccountFields } from '@/flavours/glitch/components/account_fields';
import { DisplayName } from '@/flavours/glitch/components/display_name';
import { AnimateEmojiProvider } from '@/flavours/glitch/components/emoji/context';
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react';
@@ -38,6 +37,7 @@ import {
AutomatedBadge,
GroupBadge,
} from 'flavours/glitch/components/badge';
import { Button } from 'flavours/glitch/components/button';
import { CopyIconButton } from 'flavours/glitch/components/copy_icon_button';
import { Dropdown } from 'flavours/glitch/components/dropdown_menu';
import { FollowButton } from 'flavours/glitch/components/follow_button';
@@ -190,6 +190,14 @@ const titleFromAccount = (account: Account) => {
return `${prefix} (@${acct})`;
};
const dateFormatOptions: Intl.DateTimeFormatOptions = {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
};
export const AccountHeader: React.FC<{
accountId: string;
hideTabs?: boolean;
@@ -375,11 +383,41 @@ export const AccountHeader: React.FC<{
});
}, [account]);
const handleMouseEnter = useCallback(
({ currentTarget }: React.MouseEvent) => {
if (autoPlayGif) {
return;
}
currentTarget
.querySelectorAll<HTMLImageElement>('.custom-emoji')
.forEach((emoji) => {
emoji.src = emoji.getAttribute('data-original') ?? '';
});
},
[],
);
const handleMouseLeave = useCallback(
({ currentTarget }: React.MouseEvent) => {
if (autoPlayGif) {
return;
}
currentTarget
.querySelectorAll<HTMLImageElement>('.custom-emoji')
.forEach((emoji) => {
emoji.src = emoji.getAttribute('data-static') ?? '';
});
},
[],
);
const suspended = account?.suspended;
const isRemote = account?.acct !== account?.username;
const remoteDomain = isRemote ? account?.acct.split('@')[1] : null;
const menuItems = useMemo(() => {
const menu = useMemo(() => {
const arr: MenuItem[] = [];
if (!account) {
@@ -601,15 +639,6 @@ export const AccountHeader: React.FC<{
handleUnblockDomain,
]);
const menu = accountId !== me && (
<Dropdown
disabled={menuItems.length === 0}
items={menuItems}
icon='ellipsis-v'
iconComponent={MoreHorizIcon}
/>
);
if (!account) {
return null;
}
@@ -723,16 +752,21 @@ export const AccountHeader: React.FC<{
);
}
const isMovedAndUnfollowedAccount = account.moved && !relationship?.following;
if (!isMovedAndUnfollowedAccount) {
if (relationship?.blocking) {
actionBtn = (
<FollowButton
accountId={accountId}
className='account__header__follow-button'
labelLength='long'
<Button
text={intl.formatMessage(messages.unblock, {
name: account.username,
})}
onClick={handleBlock}
/>
);
} else {
actionBtn = <FollowButton accountId={accountId} />;
}
if (account.moved && !relationship?.following) {
actionBtn = '';
}
if (account.locked) {
@@ -777,10 +811,12 @@ export const AccountHeader: React.FC<{
<MovedNote accountId={account.id} targetAccountId={account.moved} />
)}
<AnimateEmojiProvider
<div
className={classNames('account__header', {
inactive: !!account.moved,
})}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{!(suspended || hidden || account.moved) &&
relationship?.requested_by && (
@@ -814,11 +850,18 @@ export const AccountHeader: React.FC<{
/>
</a>
<div className='account__header__buttons account__header__buttons--desktop'>
{!hidden && actionBtn}
<div className='account__header__tabs__buttons'>
{!hidden && bellBtn}
{!hidden && shareBtn}
{menu}
{accountId !== me && (
<Dropdown
disabled={menu.length === 0}
items={menu}
icon='ellipsis-v'
iconComponent={MoreHorizIcon}
/>
)}
{!hidden && actionBtn}
</div>
</div>
@@ -848,12 +891,6 @@ export const AccountHeader: React.FC<{
<FamiliarFollowers accountId={accountId} />
)}
<div className='account__header__buttons account__header__buttons--mobile'>
{!hidden && actionBtn}
{!hidden && bellBtn}
{menu}
</div>
{!(suspended || hidden) && (
<div className='account__header__extra'>
<div
@@ -887,13 +924,52 @@ export const AccountHeader: React.FC<{
</dd>
</dl>
<AccountFields fields={fields} emojis={account.emojis} />
{fields.map((pair, i) => (
<dl
key={i}
className={classNames({
verified: pair.verified_at,
})}
>
<dt
dangerouslySetInnerHTML={{
__html: pair.name_emojified,
}}
title={pair.name}
className='translate'
/>
<dd className='translate' title={pair.value_plain ?? ''}>
{pair.verified_at && (
<span
title={intl.formatMessage(messages.linkVerifiedOn, {
date: intl.formatDate(
pair.verified_at,
dateFormatOptions,
),
})}
>
<Icon
id='check'
icon={CheckIcon}
className='verified__mark'
/>
</span>
)}{' '}
<span
dangerouslySetInnerHTML={{
__html: pair.value_emojified,
}}
/>
</dd>
</dl>
))}
</div>
</div>
</div>
)}
</div>
</AnimateEmojiProvider>
</div>
<ActionBar account={account} />

View File

@@ -49,7 +49,9 @@ export const EditIndicator = () => {
<EmbeddedStatusContent
className='edit-indicator__content translate'
status={status}
content={status.get('contentHtml')}
language={status.get('language')}
mentions={status.get('mentions')}
/>
{(status.get('poll') || status.get('media_attachments').size > 0) && (

View File

@@ -34,7 +34,9 @@ export const ReplyIndicator = () => {
<EmbeddedStatusContent
className='reply-indicator__content translate'
status={status}
content={status.get('contentHtml')}
language={status.get('language')}
mentions={status.get('mentions')}
/>
{(status.get('poll') || status.get('media_attachments').size > 0) && (

View File

@@ -12,12 +12,14 @@ import type { ApiQuotePolicy } from '@/flavours/glitch/api_types/quotes';
import type { StatusVisibility } from '@/flavours/glitch/api_types/statuses';
import { Icon } from '@/flavours/glitch/components/icon';
import { useAppSelector, useAppDispatch } from '@/flavours/glitch/store';
import { isFeatureEnabled } from '@/flavours/glitch/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';
@@ -41,6 +43,9 @@ interface PrivacyDropdownProps {
}
export const VisibilityButton: FC<PrivacyDropdownProps> = (props) => {
if (!isFeatureEnabled('outgoing_quotes')) {
return <PrivacyDropdownContainer {...props} />;
}
return <PrivacyModalButton {...props} />;
};

View File

@@ -10,14 +10,11 @@ import {
insertEmojiCompose,
uploadCompose,
} from 'flavours/glitch/actions/compose';
import { pasteLinkCompose } from 'flavours/glitch/actions/compose_typed';
import { openModal } from 'flavours/glitch/actions/modal';
import { privacyPreference } from 'flavours/glitch/utils/privacy_preference';
import ComposeForm from '../components/compose_form';
const urlLikeRegex = /^https?:\/\/[^\s]+\/[^\s]+$/i;
const sideArmPrivacy = state => {
const inReplyTo = state.getIn(['compose', 'in_reply_to']);
const replyPrivacy = inReplyTo ? state.getIn(['statuses', inReplyTo, 'visibility']) : null;
@@ -96,21 +93,8 @@ const mapDispatchToProps = (dispatch, props) => ({
dispatch(changeComposeSpoilerText(checked));
},
onPaste (e) {
if (e.clipboardData && e.clipboardData.files.length === 1) {
dispatch(uploadCompose(e.clipboardData.files));
e.preventDefault();
} else if (e.clipboardData && e.clipboardData.files.length === 0) {
const data = e.clipboardData.getData('text/plain');
if (!data.match(urlLikeRegex)) return;
try {
const url = new URL(data);
dispatch(pasteLinkCompose({ url }));
} catch {
return;
}
}
onPaste (files) {
dispatch(uploadCompose(files));
},
onPickEmoji (position, data, needsSpace) {

View File

@@ -23,9 +23,9 @@ import { IconButton } from 'flavours/glitch/components/icon_button';
import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp';
import StatusContent from 'flavours/glitch/components/status_content';
import { Dropdown } from 'flavours/glitch/components/dropdown_menu';
import { autoPlayGif } from 'flavours/glitch/initial_state';
import { makeGetStatus } from 'flavours/glitch/selectors';
import { LinkedDisplayName } from '@/flavours/glitch/components/display_name';
import { AnimateEmojiProvider } from '@/flavours/glitch/components/emoji/context';
const messages = defineMessages({
more: { id: 'status.more', defaultMessage: 'More' },
@@ -61,6 +61,32 @@ export const Conversation = ({ conversation, scrollKey }) => {
const sharedCWState = useSelector(state => state.getIn(['state', 'content_warnings', 'shared_state']));
const [expanded, setExpanded] = useState(undefined);
const handleMouseEnter = useCallback(({ currentTarget }) => {
if (autoPlayGif) {
return;
}
const emojis = currentTarget.querySelectorAll('.custom-emoji');
for (var i = 0; i < emojis.length; i++) {
let emoji = emojis[i];
emoji.src = emoji.getAttribute('data-original');
}
}, []);
const handleMouseLeave = useCallback(({ currentTarget }) => {
if (autoPlayGif) {
return;
}
const emojis = currentTarget.querySelectorAll('.custom-emoji');
for (var i = 0; i < emojis.length; i++) {
let emoji = emojis[i];
emoji.src = emoji.getAttribute('data-static');
}
}, []);
const handleClick = useCallback(() => {
if (unread) {
dispatch(markConversationRead(id));
@@ -145,9 +171,9 @@ export const Conversation = ({ conversation, scrollKey }) => {
{unread && <span className='conversation__unread' />} <RelativeTimestamp timestamp={lastStatus.get('created_at')} />
</div>
<AnimateEmojiProvider className='conversation__content__names'>
<div className='conversation__content__names' onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
<FormattedMessage id='conversation.with' defaultMessage='With {names}' values={{ names: <span>{names}</span> }} />
</AnimateEmojiProvider>
</div>
</div>
<StatusContent

View File

@@ -1,23 +1,168 @@
import { FormattedMessage } from 'react-intl';
import type { MouseEventHandler } from 'react';
import { useCallback } from 'react';
import { EmojiHTML } from '@/flavours/glitch/components/emoji/html';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import classNames from 'classnames';
import {
followAccount,
unblockAccount,
unmuteAccount,
} from 'flavours/glitch/actions/accounts';
import { openModal } from 'flavours/glitch/actions/modal';
import { Avatar } from 'flavours/glitch/components/avatar';
import { Button } from 'flavours/glitch/components/button';
import { DisplayName } from 'flavours/glitch/components/display_name';
import { FollowButton } from 'flavours/glitch/components/follow_button';
import { Permalink } from 'flavours/glitch/components/permalink';
import { ShortNumber } from 'flavours/glitch/components/short_number';
import { autoPlayGif } from 'flavours/glitch/initial_state';
import { autoPlayGif, me } from 'flavours/glitch/initial_state';
import type { Account } from 'flavours/glitch/models/account';
import { makeGetAccount } from 'flavours/glitch/selectors';
import { useAppSelector } from 'flavours/glitch/store';
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
const messages = defineMessages({
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
follow: { id: 'account.follow', defaultMessage: 'Follow' },
cancel_follow_request: {
id: 'account.cancel_follow_request',
defaultMessage: 'Withdraw follow request',
},
requested: {
id: 'account.requested',
defaultMessage: 'Awaiting approval. Click to cancel follow request',
},
unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' },
unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' },
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
});
const getAccount = makeGetAccount();
export const AccountCard: React.FC<{ accountId: string }> = ({ accountId }) => {
const intl = useIntl();
const account = useAppSelector((s) => getAccount(s, accountId));
const dispatch = useAppDispatch();
const handleMouseEnter = useCallback<MouseEventHandler>(
({ currentTarget }) => {
if (autoPlayGif) {
return;
}
const emojis =
currentTarget.querySelectorAll<HTMLImageElement>('.custom-emoji');
emojis.forEach((emoji) => {
const original = emoji.getAttribute('data-original');
if (original) emoji.src = original;
});
},
[],
);
const handleMouseLeave = useCallback<MouseEventHandler>(
({ currentTarget }) => {
if (autoPlayGif) {
return;
}
const emojis =
currentTarget.querySelectorAll<HTMLImageElement>('.custom-emoji');
emojis.forEach((emoji) => {
const staticUrl = emoji.getAttribute('data-static');
if (staticUrl) emoji.src = staticUrl;
});
},
[],
);
const handleFollow = useCallback(() => {
if (!account) return;
if (
account.getIn(['relationship', 'following']) ||
account.getIn(['relationship', 'requested'])
) {
dispatch(
openModal({ modalType: 'CONFIRM_UNFOLLOW', modalProps: { account } }),
);
} else {
dispatch(followAccount(account.get('id')));
}
}, [account, dispatch]);
const handleBlock = useCallback(() => {
if (account?.relationship?.blocking) {
dispatch(unblockAccount(account.get('id')));
}
}, [account, dispatch]);
const handleMute = useCallback(() => {
if (account?.relationship?.muting) {
dispatch(unmuteAccount(account.get('id')));
}
}, [account, dispatch]);
const handleEditProfile = useCallback(() => {
window.open('/settings/profile', '_blank');
}, []);
if (!account) return null;
let actionBtn;
if (me !== account.get('id')) {
if (!account.get('relationship')) {
// Wait until the relationship is loaded
actionBtn = '';
} else if (account.getIn(['relationship', 'requested'])) {
actionBtn = (
<Button
text={intl.formatMessage(messages.cancel_follow_request)}
title={intl.formatMessage(messages.requested)}
onClick={handleFollow}
/>
);
} else if (account.getIn(['relationship', 'muting'])) {
actionBtn = (
<Button
text={intl.formatMessage(messages.unmute)}
onClick={handleMute}
/>
);
} else if (!account.getIn(['relationship', 'blocking'])) {
actionBtn = (
<Button
disabled={account.relationship?.blocked_by}
className={classNames({
'button--destructive': account.getIn(['relationship', 'following']),
})}
text={intl.formatMessage(
account.getIn(['relationship', 'following'])
? messages.unfollow
: messages.follow,
)}
onClick={handleFollow}
/>
);
} else if (account.getIn(['relationship', 'blocking'])) {
actionBtn = (
<Button
text={intl.formatMessage(messages.unblock)}
onClick={handleBlock}
/>
);
}
} else {
actionBtn = (
<Button
text={intl.formatMessage(messages.edit_profile)}
onClick={handleEditProfile}
/>
);
}
return (
<div className='account-card'>
<Permalink
@@ -43,10 +188,11 @@ export const AccountCard: React.FC<{ accountId: string }> = ({ accountId }) => {
</Permalink>
{account.get('note').length > 0 && (
<EmojiHTML
<div
className='account-card__bio translate'
htmlString={account.get('note_emojified')}
extraEmojis={account.get('emojis')}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }}
/>
)}
@@ -80,9 +226,7 @@ export const AccountCard: React.FC<{ accountId: string }> = ({ accountId }) => {
</div>
</div>
<div className='account-card__actions__button'>
<FollowButton accountId={account.get('id')} />
</div>
<div className='account-card__actions__button'>{actionBtn}</div>
</div>
</div>
);

View File

@@ -24,7 +24,7 @@ import { ColumnHeader } from 'flavours/glitch/components/column_header';
import { LoadMore } from 'flavours/glitch/components/load_more';
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
import { RadioButton } from 'flavours/glitch/components/radio_button';
import { ScrollContainer } from 'flavours/glitch/containers/scroll_container';
import ScrollContainer from 'flavours/glitch/containers/scroll_container';
import { useSearchParam } from 'flavours/glitch/hooks/useSearchParam';
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
@@ -209,6 +209,7 @@ export const Directory: React.FC<{
/>
{multiColumn && !pinned ? (
// @ts-expect-error ScrollContainer is not properly typed yet
<ScrollContainer scrollKey='directory'>
{scrollableArea}
</ScrollContainer>

View File

@@ -23,6 +23,8 @@ export const EMOJI_MODE_TWEMOJI = 'twemoji';
export const EMOJI_TYPE_UNICODE = 'unicode';
export const EMOJI_TYPE_CUSTOM = 'custom';
export const EMOJI_STATE_MISSING = 'missing';
export const EMOJIS_WITH_DARK_BORDER = [
'🎱', // 1F3B1
'🐜', // 1F41C

View File

@@ -197,18 +197,11 @@ function toLoadedLocale(localeString: string) {
log(`Locale ${locale} is different from provided ${localeString}`);
}
if (!loadedLocales.has(locale)) {
throw new LocaleNotLoadedError(locale);
throw new Error(`Locale ${locale} is not loaded in emoji database`);
}
return locale;
}
export class LocaleNotLoadedError extends Error {
constructor(locale: Locale) {
super(`Locale ${locale} is not loaded in emoji database`);
this.name = 'LocaleNotLoadedError';
}
}
async function hasLocale(locale: Locale, db: Database): Promise<boolean> {
if (loadedLocales.has(locale)) {
return true;

View File

@@ -1,6 +1,5 @@
import Trie from 'substring-trie';
import { isModernEmojiEnabled } from '@/flavours/glitch/utils/environment';
import { assetHost } from 'flavours/glitch/utils/config';
import { autoPlayGif, useSystemEmojiFont } from '../../initial_state';
@@ -149,17 +148,7 @@ const emojifyNode = (node, customEmojis) => {
}
};
/**
* Legacy emoji processing function.
* @param {string} str
* @param {object} customEmojis
* @param {boolean} force If true, always emojify even if modern emoji is enabled
* @returns {string}
*/
const emojify = (str, customEmojis = {}, force = false) => {
if (isModernEmojiEnabled() && !force) {
return str;
}
const emojify = (str, customEmojis = {}) => {
const wrapper = document.createElement('div');
wrapper.innerHTML = str;

View File

@@ -0,0 +1,49 @@
import type { ComponentPropsWithoutRef, ElementType } from 'react';
import { isModernEmojiEnabled } from '@/flavours/glitch/utils/environment';
import { useEmojify } from './hooks';
import type { CustomEmojiMapArg } from './types';
type EmojiHTMLProps<Element extends ElementType = 'div'> = Omit<
ComponentPropsWithoutRef<Element>,
'dangerouslySetInnerHTML'
> & {
htmlString: string;
extraEmojis?: CustomEmojiMapArg;
as?: Element;
shallow?: boolean;
};
export const ModernEmojiHTML = ({
extraEmojis,
htmlString,
as: Wrapper = 'div', // Rename for syntax highlighting
shallow,
...props
}: EmojiHTMLProps<ElementType>) => {
const emojifiedHtml = useEmojify({
text: htmlString,
extraEmojis,
deep: !shallow,
});
if (emojifiedHtml === null) {
return null;
}
return (
<Wrapper {...props} dangerouslySetInnerHTML={{ __html: emojifiedHtml }} />
);
};
export const EmojiHTML = <Element extends ElementType>(
props: EmojiHTMLProps<Element>,
) => {
if (isModernEmojiEnabled()) {
return <ModernEmojiHTML {...props} />;
}
const { as: asElement, htmlString, extraEmojis, ...rest } = props;
const Wrapper = asElement ?? 'div';
return <Wrapper {...rest} dangerouslySetInnerHTML={{ __html: htmlString }} />;
};

View File

@@ -2,12 +2,9 @@ import type { EmojiProps, PickerProps } from 'emoji-mart';
import EmojiRaw from 'emoji-mart/dist-es/components/emoji/nimble-emoji';
import PickerRaw from 'emoji-mart/dist-es/components/picker/nimble-picker';
import { isModernEmojiEnabled } from '@/flavours/glitch/utils/environment';
import { assetHost } from 'flavours/glitch/utils/config';
import { EMOJI_MODE_NATIVE } from './constants';
import EmojiData from './emoji_data.json';
import { useEmojiAppState } from './mode';
const backgroundImageFnDefault = () => `${assetHost}/emoji/sheet_15_1.png`;
@@ -19,7 +16,6 @@ const Emoji = ({
backgroundImageFn = backgroundImageFnDefault,
...props
}: EmojiProps) => {
const { mode } = useEmojiAppState();
return (
<EmojiRaw
data={EmojiData}
@@ -27,7 +23,6 @@ const Emoji = ({
sheetSize={sheetSize}
sheetColumns={sheetColumns}
sheetRows={sheetRows}
native={mode === EMOJI_MODE_NATIVE && isModernEmojiEnabled()}
backgroundImageFn={backgroundImageFn}
{...props}
/>
@@ -42,7 +37,6 @@ const Picker = ({
backgroundImageFn = backgroundImageFnDefault,
...props
}: PickerProps) => {
const { mode } = useEmojiAppState();
return (
<PickerRaw
data={EmojiData}
@@ -51,7 +45,6 @@ const Picker = ({
sheetColumns={sheetColumns}
sheetRows={sheetRows}
backgroundImageFn={backgroundImageFn}
native={mode === EMOJI_MODE_NATIVE && isModernEmojiEnabled()}
{...props}
/>
);

View File

@@ -1,61 +0,0 @@
import { autoPlayGif } from '@/flavours/glitch/initial_state';
const PARENT_MAX_DEPTH = 10;
export function handleAnimateGif(event: MouseEvent) {
// We already check this in ui/index.jsx, but just to be sure.
if (autoPlayGif) {
return;
}
const { target, type } = event;
const animate = type === 'mouseover'; // Mouse over = animate, mouse out = don't animate.
if (target instanceof HTMLImageElement) {
setAnimateGif(target, animate);
} else if (!(target instanceof HTMLElement) || target === document.body) {
return;
}
let parent: HTMLElement | null = null;
let iter = 0;
if (target.classList.contains('animate-parent')) {
parent = target;
} else {
// Iterate up to PARENT_MAX_DEPTH levels up the DOM tree to find a parent with the class 'animate-parent'.
let current: HTMLElement | null = target;
while (current) {
if (iter >= PARENT_MAX_DEPTH) {
return; // We can just exit right now.
}
current = current.parentElement;
if (current?.classList.contains('animate-parent')) {
parent = current;
break;
}
iter++;
}
}
// Affect all animated children within the parent.
if (parent) {
const animatedChildren =
parent.querySelectorAll<HTMLImageElement>('img.custom-emoji');
for (const child of animatedChildren) {
setAnimateGif(child, animate);
}
}
}
function setAnimateGif(image: HTMLImageElement, animate: boolean) {
const { classList, dataset } = image;
if (
!classList.contains('custom-emoji') ||
!dataset.static ||
!dataset.original
) {
return;
}
image.src = animate ? dataset.original : dataset.static;
}

View File

@@ -0,0 +1,94 @@
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
import { isList } from 'immutable';
import type { ApiCustomEmojiJSON } from '@/flavours/glitch/api_types/custom_emoji';
import { useAppSelector } from '@/flavours/glitch/store';
import { isModernEmojiEnabled } from '@/flavours/glitch/utils/environment';
import { toSupportedLocale } from './locale';
import { determineEmojiMode } from './mode';
import { emojifyElement, emojifyText } from './render';
import type {
CustomEmojiMapArg,
EmojiAppState,
ExtraCustomEmojiMap,
} from './types';
import { stringHasAnyEmoji } from './utils';
interface UseEmojifyOptions {
text: string;
extraEmojis?: CustomEmojiMapArg;
deep?: boolean;
}
export function useEmojify({
text,
extraEmojis,
deep = true,
}: UseEmojifyOptions) {
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) => {
let result: string | null = null;
if (deep) {
const wrapper = document.createElement('div');
wrapper.innerHTML = input;
if (await emojifyElement(wrapper, appState, extra)) {
result = wrapper.innerHTML;
}
} else {
result = await emojifyText(text, appState, extra);
}
if (result) {
setEmojifiedText(result);
} else {
setEmojifiedText(input);
}
},
[appState, deep, extra, text],
);
useLayoutEffect(() => {
if (isModernEmojiEnabled() && !!text.trim() && stringHasAnyEmoji(text)) {
void emojify(text);
} else {
// If no emoji or we don't want to render, fall back.
setEmojifiedText(text);
}
}, [emojify, text]);
return emojifiedText;
}
export function useEmojiAppState(): EmojiAppState {
const locale = useAppSelector((state) =>
toSupportedLocale(state.meta.get('locale') as string),
);
const mode = useAppSelector((state) =>
determineEmojiMode(state.meta.get('emoji_style') as string),
);
return {
currentLocale: locale,
locales: [locale],
mode,
darkTheme: document.body.classList.contains('theme-default'),
};
}

View File

@@ -1,4 +1,4 @@
import { initialState } from '@/flavours/glitch/initial_state';
import initialState from '@/flavours/glitch/initial_state';
import { loadWorker } from '@/flavours/glitch/utils/workers';
import { toSupportedLocale } from './locale';
@@ -10,8 +10,6 @@ let worker: Worker | null = null;
const log = emojiLogger('index');
const WORKER_TIMEOUT = 1_000; // 1 second
export function initializeEmoji() {
log('initializing emojis');
if (!worker && 'Worker' in window) {
@@ -31,7 +29,7 @@ export function initializeEmoji() {
log('worker is not ready after timeout');
worker = null;
void fallbackLoad();
}, WORKER_TIMEOUT);
}, 500);
thisWorker.addEventListener('message', (event: MessageEvent<string>) => {
const { data: message } = event;
if (message === 'ready') {

View File

@@ -1,6 +1,8 @@
import { flattenEmojiData } from 'emojibase';
import type { CompactEmoji, FlatCompactEmoji } from 'emojibase';
import type { ApiCustomEmojiJSON } from '@/flavours/glitch/api_types/custom_emoji';
import {
putEmojiData,
putCustomEmojiData,
@@ -8,7 +10,7 @@ import {
putLatestEtag,
} from './database';
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale';
import type { CustomEmojiData, LocaleOrCustom } from './types';
import type { LocaleOrCustom } from './types';
import { emojiLogger } from './utils';
const log = emojiLogger('loader');
@@ -25,7 +27,7 @@ export async function importEmojiData(localeString: string) {
}
export async function importCustomEmojiData() {
const emojis = await fetchAndCheckEtag<CustomEmojiData[]>('custom');
const emojis = await fetchAndCheckEtag<ApiCustomEmojiJSON[]>('custom');
if (!emojis) {
return;
}

View File

@@ -1,7 +1,6 @@
// Credit to Nolan Lawson for the original implementation.
// See: https://github.com/nolanlawson/emoji-picker-element/blob/master/src/picker/utils/testColorEmojiSupported.js
import { createAppSelector, useAppSelector } from '@/flavours/glitch/store';
import { isDevelopment } from '@/flavours/glitch/utils/environment';
import {
@@ -9,27 +8,7 @@ import {
EMOJI_MODE_NATIVE_WITH_FLAGS,
EMOJI_MODE_TWEMOJI,
} from './constants';
import { toSupportedLocale } from './locale';
import type { EmojiAppState, EmojiMode } from './types';
const modeSelector = createAppSelector(
[(state) => state.meta.get('emoji_style') as string],
(emoji_style) => determineEmojiMode(emoji_style),
);
export function useEmojiAppState(): EmojiAppState {
const locale = useAppSelector((state) =>
toSupportedLocale(state.meta.get('locale') as string),
);
const mode = useAppSelector(modeSelector);
return {
currentLocale: locale,
locales: [locale],
mode,
darkTheme: document.body.classList.contains('theme-default'),
};
}
import type { EmojiMode } from './types';
type Feature = Uint8ClampedArray;

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