mirror of
https://github.com/mastodon/mastodon.git
synced 2025-11-27 18:10:58 +00:00
Merge branch 'main' into compose-language-detection
This commit is contained in:
commit
4de59cc3ff
|
|
@ -91,9 +91,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
3.4.6
|
||||
3.4.7
|
||||
|
|
|
|||
|
|
@ -50,9 +50,13 @@ const preview: Preview = {
|
|||
locale: 'en',
|
||||
},
|
||||
decorators: [
|
||||
(Story, { parameters, globals }) => {
|
||||
(Story, { parameters, globals, args }) => {
|
||||
// Get the locale from the global toolbar
|
||||
// and merge it with any parameters or args state.
|
||||
const { locale } = globals as { locale: string };
|
||||
const { state = {} } = parameters;
|
||||
const { state: argsState = {} } = args;
|
||||
|
||||
const reducer = reducerWithInitialState(
|
||||
{
|
||||
meta: {
|
||||
|
|
@ -60,7 +64,9 @@ const preview: Preview = {
|
|||
},
|
||||
},
|
||||
state as Record<string, unknown>,
|
||||
argsState as Record<string, unknown>,
|
||||
);
|
||||
|
||||
const store = configureStore({
|
||||
reducer,
|
||||
middleware(getDefaultMiddleware) {
|
||||
|
|
|
|||
124
CHANGELOG.md
124
CHANGELOG.md
|
|
@ -2,6 +2,130 @@
|
|||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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.6"
|
||||
ARG RUBY_VERSION="3.4.7"
|
||||
# # 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"
|
||||
|
|
@ -208,12 +208,12 @@ FROM build AS ffmpeg
|
|||
# renovate: datasource=repology depName=ffmpeg packageName=openpkg_current/ffmpeg
|
||||
ARG FFMPEG_VERSION=8.0
|
||||
# ffmpeg download URL, change with [--build-arg FFMPEG_URL="https://ffmpeg.org/releases"]
|
||||
ARG FFMPEG_URL=https://ffmpeg.org/releases
|
||||
ARG FFMPEG_URL=https://github.com/FFmpeg/FFmpeg/archive/refs/tags
|
||||
|
||||
WORKDIR /usr/local/ffmpeg/src
|
||||
# Download and extract ffmpeg source code
|
||||
ADD ${FFMPEG_URL}/ffmpeg-${FFMPEG_VERSION}.tar.xz /usr/local/ffmpeg/src/
|
||||
RUN tar xf ffmpeg-${FFMPEG_VERSION}.tar.xz;
|
||||
ADD ${FFMPEG_URL}/n${FFMPEG_VERSION}.tar.gz /usr/local/ffmpeg/src/
|
||||
RUN tar xf n${FFMPEG_VERSION}.tar.gz && mv FFmpeg-n${FFMPEG_VERSION} ffmpeg-${FFMPEG_VERSION};
|
||||
|
||||
WORKDIR /usr/local/ffmpeg/src/ffmpeg-${FFMPEG_VERSION}
|
||||
|
||||
|
|
|
|||
28
Gemfile
28
Gemfile
|
|
@ -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.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-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-sdk', '~> 1.4', require: false
|
||||
end
|
||||
|
||||
|
|
|
|||
212
Gemfile.lock
212
Gemfile.lock
|
|
@ -96,7 +96,7 @@ GEM
|
|||
ast (2.4.3)
|
||||
attr_required (1.0.2)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1135.0)
|
||||
aws-partitions (1.1168.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.2.3)
|
||||
bigdecimal (3.3.1)
|
||||
bindata (2.5.1)
|
||||
binding_of_caller (1.0.1)
|
||||
debug_inspector (>= 1.2.0)
|
||||
|
|
@ -207,7 +207,7 @@ GEM
|
|||
railties (>= 5)
|
||||
dotenv (3.1.8)
|
||||
drb (2.2.3)
|
||||
dry-cli (1.2.0)
|
||||
dry-cli (1.3.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.2.11)
|
||||
et-orbi (1.4.0)
|
||||
tzinfo
|
||||
excon (1.2.8)
|
||||
excon (1.3.0)
|
||||
logger
|
||||
fabrication (3.0.0)
|
||||
faker (3.5.2)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.13.4)
|
||||
faraday (2.14.0)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
faraday-follow_redirects (0.3.0)
|
||||
faraday-follow_redirects (0.4.0)
|
||||
faraday (>= 1, < 3)
|
||||
faraday-httpclient (2.0.2)
|
||||
httpclient (>= 2.2)
|
||||
|
|
@ -266,18 +266,19 @@ GEM
|
|||
fog-openstack (1.1.5)
|
||||
fog-core (~> 2.1)
|
||||
fog-json (>= 1.0)
|
||||
formatador (1.1.1)
|
||||
formatador (1.2.1)
|
||||
reline
|
||||
forwardable (1.3.3)
|
||||
fugit (1.11.1)
|
||||
et-orbi (~> 1, >= 1.2.11)
|
||||
fugit (1.12.0)
|
||||
et-orbi (~> 1.4)
|
||||
raabro (~> 1.4)
|
||||
globalid (1.2.1)
|
||||
globalid (1.3.0)
|
||||
activesupport (>= 6.1)
|
||||
google-protobuf (4.31.1)
|
||||
google-protobuf (4.32.1)
|
||||
bigdecimal
|
||||
rake (>= 13)
|
||||
googleapis-common-protos-types (1.20.0)
|
||||
google-protobuf (>= 3.18, < 5.a)
|
||||
googleapis-common-protos-types (1.22.0)
|
||||
google-protobuf (~> 4.26)
|
||||
haml (6.3.0)
|
||||
temple (>= 0.8.2)
|
||||
thor
|
||||
|
|
@ -293,7 +294,7 @@ GEM
|
|||
rainbow
|
||||
rubocop (>= 1.0)
|
||||
sysexits (~> 1.1)
|
||||
hashdiff (1.2.0)
|
||||
hashdiff (1.2.1)
|
||||
hashie (5.0.0)
|
||||
hcaptcha (7.1.0)
|
||||
json
|
||||
|
|
@ -309,7 +310,7 @@ GEM
|
|||
http-cookie (~> 1.0)
|
||||
http-form_data (~> 2.2)
|
||||
llhttp-ffi (~> 0.5.0)
|
||||
http-cookie (1.0.8)
|
||||
http-cookie (1.1.0)
|
||||
domain_name (~> 0.5)
|
||||
http-form_data (2.3.0)
|
||||
http_accept_language (2.1.1)
|
||||
|
|
@ -345,9 +346,9 @@ GEM
|
|||
azure-blob (~> 0.5.2)
|
||||
hashie (~> 5.0)
|
||||
jmespath (1.6.2)
|
||||
json (2.15.0)
|
||||
json (2.15.1)
|
||||
json-canonicalization (1.0.0)
|
||||
json-jwt (1.16.7)
|
||||
json-jwt (1.17.0)
|
||||
activesupport (>= 4.2)
|
||||
aes_key_wrap
|
||||
base64
|
||||
|
|
@ -438,7 +439,7 @@ GEM
|
|||
mime-types (3.7.0)
|
||||
logger
|
||||
mime-types-data (~> 3.2025, >= 3.2025.0507)
|
||||
mime-types-data (3.2025.0916)
|
||||
mime-types-data (3.2025.0924)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.9)
|
||||
minitest (5.25.5)
|
||||
|
|
@ -447,7 +448,7 @@ GEM
|
|||
mutex_m (0.3.0)
|
||||
net-http (0.6.0)
|
||||
uri
|
||||
net-imap (0.5.10)
|
||||
net-imap (0.5.12)
|
||||
date
|
||||
net-protocol
|
||||
net-ldap (0.20.0)
|
||||
|
|
@ -466,8 +467,9 @@ GEM
|
|||
oj (3.16.11)
|
||||
bigdecimal (>= 3.0)
|
||||
ostruct (>= 0.2)
|
||||
omniauth (2.1.3)
|
||||
omniauth (2.1.4)
|
||||
hashie (>= 3.4.6)
|
||||
logger
|
||||
rack (>= 2.2.3)
|
||||
rack-protection
|
||||
omniauth-cas (3.0.2)
|
||||
|
|
@ -496,102 +498,77 @@ GEM
|
|||
tzinfo
|
||||
validate_url
|
||||
webfinger (~> 2.0)
|
||||
openssl (3.3.0)
|
||||
openssl (3.3.1)
|
||||
openssl-signature_algorithm (1.3.0)
|
||||
openssl (> 2.0)
|
||||
opentelemetry-api (1.7.0)
|
||||
opentelemetry-common (0.22.0)
|
||||
opentelemetry-common (0.23.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-exporter-otlp (0.30.0)
|
||||
opentelemetry-exporter-otlp (0.31.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.1.1)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-helpers-sql (0.2.0)
|
||||
opentelemetry-api (~> 1.7)
|
||||
opentelemetry-helpers-sql-obfuscation (0.3.0)
|
||||
opentelemetry-common (~> 0.21)
|
||||
opentelemetry-instrumentation-action_mailer (0.4.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-action_mailer (0.5.0)
|
||||
opentelemetry-instrumentation-active_support (~> 0.7)
|
||||
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-action_pack (0.14.1)
|
||||
opentelemetry-instrumentation-rack (~> 0.21)
|
||||
opentelemetry-instrumentation-action_view (0.9.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-action_view (0.10.0)
|
||||
opentelemetry-instrumentation-active_support (~> 0.7)
|
||||
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_job (0.9.2)
|
||||
opentelemetry-instrumentation-base (~> 0.24)
|
||||
opentelemetry-instrumentation-active_model_serializers (0.23.0)
|
||||
opentelemetry-instrumentation-active_support (>= 0.7.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_record (0.10.1)
|
||||
opentelemetry-instrumentation-base (~> 0.24)
|
||||
opentelemetry-instrumentation-active_storage (0.2.0)
|
||||
opentelemetry-instrumentation-active_support (~> 0.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-instrumentation-active_support (0.9.1)
|
||||
opentelemetry-instrumentation-base (~> 0.24)
|
||||
opentelemetry-instrumentation-base (0.24.0)
|
||||
opentelemetry-api (~> 1.7)
|
||||
opentelemetry-common (~> 0.21)
|
||||
opentelemetry-registry (~> 0.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-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-helpers-sql
|
||||
opentelemetry-helpers-sql-obfuscation
|
||||
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-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-registry (0.4.0)
|
||||
opentelemetry-api (~> 1.1)
|
||||
opentelemetry-sdk (1.9.0)
|
||||
opentelemetry-sdk (1.10.0)
|
||||
opentelemetry-api (~> 1.1)
|
||||
opentelemetry-common (~> 0.20)
|
||||
opentelemetry-registry (~> 0.2)
|
||||
|
|
@ -615,7 +592,7 @@ GEM
|
|||
playwright-ruby-client (1.55.0)
|
||||
concurrent-ruby (>= 1.1.6)
|
||||
mime-types (>= 3.0)
|
||||
pp (0.6.2)
|
||||
pp (0.6.3)
|
||||
prettyprint
|
||||
premailer (1.27.0)
|
||||
addressable
|
||||
|
|
@ -643,7 +620,7 @@ GEM
|
|||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.2.1)
|
||||
rack (3.2.3)
|
||||
rack-attack (6.7.0)
|
||||
rack (>= 1.0, < 4)
|
||||
rack-cors (3.0.0)
|
||||
|
|
@ -713,9 +690,10 @@ GEM
|
|||
readline (~> 0.0)
|
||||
rdf-normalize (0.7.0)
|
||||
rdf (~> 3.3)
|
||||
rdoc (6.14.2)
|
||||
rdoc (6.15.0)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
tsort
|
||||
readline (0.0.4)
|
||||
reline
|
||||
redcarpet (3.6.1)
|
||||
|
|
@ -732,7 +710,7 @@ GEM
|
|||
railties (>= 5.2)
|
||||
rexml (3.4.4)
|
||||
rotp (6.3.0)
|
||||
rouge (4.6.0)
|
||||
rouge (4.6.1)
|
||||
rpam2 (4.0.2)
|
||||
rqrcode (3.1.0)
|
||||
chunky_png (~> 1.0)
|
||||
|
|
@ -765,7 +743,7 @@ GEM
|
|||
rspec-expectations (~> 3.0)
|
||||
rspec-mocks (~> 3.0)
|
||||
sidekiq (>= 5, < 9)
|
||||
rspec-support (3.13.4)
|
||||
rspec-support (3.13.6)
|
||||
rubocop (1.81.1)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
|
|
@ -826,7 +804,7 @@ GEM
|
|||
securerandom (0.4.1)
|
||||
shoulda-matchers (6.5.0)
|
||||
activesupport (>= 5.2.0)
|
||||
sidekiq (8.0.7)
|
||||
sidekiq (8.0.8)
|
||||
connection_pool (>= 2.5.0)
|
||||
json (>= 2.9.0)
|
||||
logger (>= 1.6.2)
|
||||
|
|
@ -859,7 +837,7 @@ GEM
|
|||
stoplight (5.3.8)
|
||||
zeitwerk
|
||||
stringio (3.1.7)
|
||||
strong_migrations (2.5.0)
|
||||
strong_migrations (2.5.1)
|
||||
activerecord (>= 7.1)
|
||||
swd (2.0.3)
|
||||
activesupport (>= 3)
|
||||
|
|
@ -904,7 +882,7 @@ GEM
|
|||
unicode-display_width (3.2.0)
|
||||
unicode-emoji (~> 4.1)
|
||||
unicode-emoji (4.1.0)
|
||||
uri (1.0.3)
|
||||
uri (1.0.4)
|
||||
useragent (0.16.11)
|
||||
validate_url (1.0.15)
|
||||
activemodel (>= 3.0.0)
|
||||
|
|
@ -1030,20 +1008,20 @@ DEPENDENCIES
|
|||
omniauth-saml (~> 2.0)
|
||||
omniauth_openid_connect (~> 0.8.0)
|
||||
opentelemetry-api (~> 1.7.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-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-sdk (~> 1.4)
|
||||
ox (~> 2.14)
|
||||
parslet
|
||||
|
|
@ -1109,4 +1087,4 @@ RUBY VERSION
|
|||
ruby 3.4.1p0
|
||||
|
||||
BUNDLED WITH
|
||||
2.7.1
|
||||
2.7.2
|
||||
|
|
|
|||
|
|
@ -9,10 +9,16 @@ module Admin
|
|||
|
||||
@pending_appeals_count = Appeal.pending.async_count
|
||||
@pending_reports_count = Report.unresolved.async_count
|
||||
@pending_tags_count = Tag.pending_review.async_count
|
||||
@pending_tags_count = pending_tags.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
|
||||
|
|
|
|||
|
|
@ -3,14 +3,8 @@
|
|||
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
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
class Api::V1::Timelines::HomeController < Api::V1::Timelines::BaseController
|
||||
include AsyncRefreshesConcern
|
||||
|
||||
before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, only: [:show]
|
||||
before_action :require_user!, only: [:show]
|
||||
before_action -> { doorkeeper_authorize! :read, :'read:statuses' }
|
||||
before_action :require_user!
|
||||
|
||||
PERMITTED_PARAMS = %i(local limit).freeze
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Timelines::LinkController < Api::V1::Timelines::BaseController
|
||||
class Api::V1::Timelines::LinkController < Api::V1::Timelines::TopicController
|
||||
before_action -> { authorize_if_got_token! :read, :'read:statuses' }
|
||||
before_action :set_preview_card
|
||||
before_action :set_statuses
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
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).freeze
|
||||
|
||||
|
|
@ -13,6 +14,16 @@ 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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Timelines::TagController < Api::V1::Timelines::BaseController
|
||||
class Api::V1::Timelines::TagController < Api::V1::Timelines::TopicController
|
||||
before_action -> { authorize_if_got_token! :read, :'read:statuses' }
|
||||
before_action :load_tag
|
||||
|
||||
|
|
@ -14,10 +14,6 @@ class Api::V1::Timelines::TagController < Api::V1::Timelines::BaseController
|
|||
|
||||
private
|
||||
|
||||
def require_auth?
|
||||
!Setting.timeline_preview
|
||||
end
|
||||
|
||||
def load_tag
|
||||
@tag = Tag.find_normalized(params[:id])
|
||||
end
|
||||
|
|
|
|||
17
app/controllers/api/v1/timelines/topic_controller.rb
Normal file
17
app/controllers/api/v1/timelines/topic_controller.rb
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# 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
|
||||
|
|
@ -89,7 +89,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
|
|||
end
|
||||
|
||||
def check_enabled_registrations
|
||||
redirect_to root_path unless allowed_registration?(request.remote_ip, @invite)
|
||||
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)
|
||||
end
|
||||
|
||||
def invite_code
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ module AsyncRefreshesConcern
|
|||
def add_async_refresh_header(async_refresh, retry_seconds: 3)
|
||||
return unless async_refresh.running?
|
||||
|
||||
response.headers['Mastodon-Async-Refresh'] = "id=\"#{async_refresh.id}\", retry=#{retry_seconds}"
|
||||
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
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -21,7 +21,13 @@ module HomeHelper
|
|||
end
|
||||
end
|
||||
else
|
||||
link_to(path || ActivityPub::TagManager.instance.url_for(account), class: 'account__display-name') do
|
||||
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
|
||||
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 +
|
||||
|
|
|
|||
|
|
@ -57,6 +57,20 @@ 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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import Rails from '@rails/ujs';
|
||||
import { decode, ValidationError } from 'blurhash';
|
||||
|
||||
import ready from '../mastodon/ready';
|
||||
|
||||
|
|
@ -362,6 +363,46 @@ 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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ function loaded() {
|
|||
};
|
||||
|
||||
document.querySelectorAll('.emojify').forEach((content) => {
|
||||
content.innerHTML = emojify(content.innerHTML);
|
||||
content.innerHTML = emojify(content.innerHTML, {}, true); // Force emojify as public doesn't load the new emoji system.
|
||||
});
|
||||
|
||||
document
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { createAction } from '@reduxjs/toolkit';
|
|||
import type { List as ImmutableList, Map as ImmutableMap } from 'immutable';
|
||||
|
||||
import { apiUpdateMedia } from 'mastodon/api/compose';
|
||||
import { apiGetSearch } from 'mastodon/api/search';
|
||||
import type { ApiMediaAttachmentJSON } from 'mastodon/api_types/media_attachments';
|
||||
import type { MediaAttachment } from 'mastodon/models/media_attachment';
|
||||
import {
|
||||
|
|
@ -16,9 +17,14 @@ 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.',
|
||||
|
|
@ -122,7 +128,9 @@ export const quoteComposeByStatus = createAppThunk(
|
|||
false,
|
||||
);
|
||||
|
||||
if (composeState.get('poll')) {
|
||||
if (composeState.get('id')) {
|
||||
dispatch(showAlert({ message: messages.quoteErrorEdit }));
|
||||
} else if (composeState.get('poll')) {
|
||||
dispatch(showAlert({ message: messages.quoteErrorPoll }));
|
||||
} else if (
|
||||
composeState.get('is_uploading') ||
|
||||
|
|
@ -165,6 +173,42 @@ 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>(
|
||||
|
|
|
|||
|
|
@ -9,8 +9,9 @@ import { importFetchedStatuses } from './importer';
|
|||
|
||||
export const fetchContext = createDataLoadingThunk(
|
||||
'status/context',
|
||||
({ statusId }: { statusId: string }) => apiGetContext(statusId),
|
||||
({ context, refresh }, { dispatch }) => {
|
||||
({ statusId }: { statusId: string; prefetchOnly?: boolean }) =>
|
||||
apiGetContext(statusId),
|
||||
({ context, refresh }, { dispatch, actionArg: { prefetchOnly = false } }) => {
|
||||
const statuses = context.ancestors.concat(context.descendants);
|
||||
|
||||
dispatch(importFetchedStatuses(statuses));
|
||||
|
|
@ -18,6 +19,7 @@ export const fetchContext = createDataLoadingThunk(
|
|||
return {
|
||||
context,
|
||||
refresh,
|
||||
prefetchOnly,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
|
@ -26,6 +28,14 @@ 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 }) => {
|
||||
|
|
|
|||
28
app/javascript/mastodon/api_types/announcements.ts
Normal file
28
app/javascript/mastodon/api_types/announcements.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
// 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;
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
|||
import classNames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { EmojiHTML } from '@/mastodon/components/emoji/html';
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
import {
|
||||
blockAccount,
|
||||
|
|
@ -331,9 +332,10 @@ export const Account: React.FC<AccountProps> = ({
|
|||
{account &&
|
||||
withBio &&
|
||||
(account.note.length > 0 ? (
|
||||
<div
|
||||
<EmojiHTML
|
||||
className='account__note translate'
|
||||
dangerouslySetInnerHTML={{ __html: account.note_emojified }}
|
||||
htmlString={account.note_emojified}
|
||||
extraEmojis={account.emojis}
|
||||
/>
|
||||
) : (
|
||||
<div className='account__note account__note--missing'>
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ import { useLinks } from 'mastodon/hooks/useLinks';
|
|||
import { useAppSelector } from '../store';
|
||||
import { isModernEmojiEnabled } from '../utils/environment';
|
||||
|
||||
import { AnimateEmojiProvider } from './emoji/context';
|
||||
import { EmojiHTML } from './emoji/html';
|
||||
import { useElementHandledLink } from './status/handled_link';
|
||||
|
||||
interface AccountBioProps {
|
||||
className: string;
|
||||
|
|
@ -24,19 +24,29 @@ export const AccountBio: React.FC<AccountBioProps> = ({
|
|||
const handleClick = useLinks(showDropdown);
|
||||
const handleNodeChange = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
if (!showDropdown || !node || node.childNodes.length === 0) {
|
||||
if (
|
||||
!showDropdown ||
|
||||
!node ||
|
||||
node.childNodes.length === 0 ||
|
||||
isModernEmojiEnabled()
|
||||
) {
|
||||
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 isModernEmojiEnabled() ? account.note : account.note_emojified;
|
||||
return account.note_emojified;
|
||||
});
|
||||
const extraEmojis = useAppSelector((state) => {
|
||||
const account = state.accounts.get(accountId);
|
||||
|
|
@ -48,13 +58,14 @@ export const AccountBio: React.FC<AccountBioProps> = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<AnimateEmojiProvider
|
||||
<EmojiHTML
|
||||
htmlString={note}
|
||||
extraEmojis={extraEmojis}
|
||||
className={classNames(className, 'translate')}
|
||||
onClickCapture={handleClick}
|
||||
ref={handleNodeChange}
|
||||
>
|
||||
<EmojiHTML htmlString={note} extraEmojis={extraEmojis} />
|
||||
</AnimateEmojiProvider>
|
||||
{...htmlHandlers}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,42 +1,70 @@
|
|||
import { useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { useLinks } from 'mastodon/hooks/useLinks';
|
||||
import type { Account } from 'mastodon/models/account';
|
||||
|
||||
export const AccountFields: React.FC<{
|
||||
fields: Account['fields'];
|
||||
limit: number;
|
||||
}> = ({ fields, limit = -1 }) => {
|
||||
const handleClick = useLinks();
|
||||
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();
|
||||
|
||||
if (fields.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<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') }}
|
||||
<CustomEmojiProvider emojis={emojis}>
|
||||
{fields.map((pair, i) => (
|
||||
<dl key={i} className={classNames({ verified: pair.verified_at })}>
|
||||
<EmojiHTML
|
||||
as='dt'
|
||||
htmlString={pair.name_emojified}
|
||||
className='translate'
|
||||
{...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 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>
|
||||
</dl>
|
||||
))}
|
||||
</div>
|
||||
</CustomEmojiProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const dateFormatOptions: Intl.DateTimeFormatOptions = {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -150,10 +150,7 @@ const AutosuggestTextarea = forwardRef(({
|
|||
}, [suggestions, onSuggestionSelected, textareaRef]);
|
||||
|
||||
const handlePaste = useCallback((e) => {
|
||||
if (e.clipboardData && e.clipboardData.files.length === 1) {
|
||||
onPaste(e.clipboardData.files);
|
||||
e.preventDefault();
|
||||
}
|
||||
onPaste(e);
|
||||
}, [onPaste]);
|
||||
|
||||
// Show the suggestions again whenever they change and the textarea is focused
|
||||
|
|
|
|||
|
|
@ -1,15 +1,38 @@
|
|||
import type { List } from 'immutable';
|
||||
|
||||
import type { CustomEmoji } from '../models/custom_emoji';
|
||||
import type { Status } from '../models/status';
|
||||
|
||||
import { EmojiHTML } from './emoji/html';
|
||||
import { StatusBanner, BannerVariant } from './status_banner';
|
||||
|
||||
export const ContentWarning: React.FC<{
|
||||
text: string;
|
||||
status: Status;
|
||||
expanded?: boolean;
|
||||
onClick?: () => void;
|
||||
}> = ({ text, expanded, onClick }) => (
|
||||
<StatusBanner
|
||||
expanded={expanded}
|
||||
onClick={onClick}
|
||||
variant={BannerVariant.Warning}
|
||||
>
|
||||
<span dangerouslySetInnerHTML={{ __html: text }} />
|
||||
</StatusBanner>
|
||||
);
|
||||
}> = ({ status, expanded, onClick }) => {
|
||||
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}
|
||||
>
|
||||
<EmojiHTML
|
||||
as='span'
|
||||
htmlString={text}
|
||||
extraEmojis={status.get('emoji') as List<CustomEmoji>}
|
||||
/>
|
||||
</StatusBanner>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@ import type { ComponentPropsWithoutRef, FC } from 'react';
|
|||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
|
||||
|
||||
import { AnimateEmojiProvider } from '../emoji/context';
|
||||
import { EmojiHTML } from '../emoji/html';
|
||||
import { Skeleton } from '../skeleton';
|
||||
|
|
@ -24,11 +22,7 @@ export const DisplayNameWithoutDomain: FC<
|
|||
{account ? (
|
||||
<EmojiHTML
|
||||
className='display-name__html'
|
||||
htmlString={
|
||||
isModernEmojiEnabled()
|
||||
? account.get('display_name')
|
||||
: account.get('display_name_html')
|
||||
}
|
||||
htmlString={account.get('display_name_html')}
|
||||
as='strong'
|
||||
extraEmojis={account.get('emojis')}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import type { ComponentPropsWithoutRef, FC } from 'react';
|
||||
|
||||
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
|
||||
|
||||
import { EmojiHTML } from '../emoji/html';
|
||||
|
||||
import type { DisplayNameProps } from './index';
|
||||
|
|
@ -19,11 +17,7 @@ export const DisplayNameSimple: FC<
|
|||
<EmojiHTML
|
||||
{...props}
|
||||
as='span'
|
||||
htmlString={
|
||||
isModernEmojiEnabled()
|
||||
? account.get('display_name')
|
||||
: account.get('display_name_html')
|
||||
}
|
||||
htmlString={account.get('display_name_html')}
|
||||
extraEmojis={account.get('emojis')}
|
||||
/>
|
||||
</bdi>
|
||||
|
|
|
|||
56
app/javascript/mastodon/components/emoji/emoji.stories.tsx
Normal file
56
app/javascript/mastodon/components/emoji/emoji.stories.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import type { ComponentProps } from 'react';
|
||||
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { importCustomEmojiData } from '@/mastodon/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:',
|
||||
},
|
||||
};
|
||||
|
|
@ -1,60 +1,89 @@
|
|||
import { useMemo } from 'react';
|
||||
import type { ComponentPropsWithoutRef, ElementType } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import type { CustomEmojiMapArg } from '@/mastodon/features/emoji/types';
|
||||
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
|
||||
import type {
|
||||
OnAttributeHandler,
|
||||
OnElementHandler,
|
||||
} from '@/mastodon/utils/html';
|
||||
import { htmlStringToComponents } from '@/mastodon/utils/html';
|
||||
import { polymorphicForwardRef } from '@/types/polymorphic';
|
||||
|
||||
import { AnimateEmojiProvider, CustomEmojiProvider } from './context';
|
||||
import { textToEmojis } from './index';
|
||||
|
||||
type EmojiHTMLProps<Element extends ElementType = 'div'> = Omit<
|
||||
ComponentPropsWithoutRef<Element>,
|
||||
'dangerouslySetInnerHTML' | 'className'
|
||||
> & {
|
||||
export interface EmojiHTMLProps {
|
||||
htmlString: string;
|
||||
extraEmojis?: CustomEmojiMapArg;
|
||||
as?: Element;
|
||||
className?: string;
|
||||
};
|
||||
onElement?: OnElementHandler;
|
||||
onAttribute?: OnAttributeHandler;
|
||||
}
|
||||
|
||||
export const ModernEmojiHTML = ({
|
||||
extraEmojis,
|
||||
htmlString,
|
||||
as: asProp = 'div', // Rename for syntax highlighting
|
||||
shallow,
|
||||
className = '',
|
||||
...props
|
||||
}: EmojiHTMLProps<ElementType>) => {
|
||||
const contents = useMemo(
|
||||
() => htmlStringToComponents(htmlString, { onText: textToEmojis }),
|
||||
[htmlString],
|
||||
);
|
||||
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}>
|
||||
{contents}
|
||||
</AnimateEmojiProvider>
|
||||
</CustomEmojiProvider>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<CustomEmojiProvider emojis={extraEmojis}>
|
||||
<AnimateEmojiProvider
|
||||
{...props}
|
||||
as={asProp}
|
||||
className={className}
|
||||
ref={ref}
|
||||
>
|
||||
{contents}
|
||||
</AnimateEmojiProvider>
|
||||
</CustomEmojiProvider>
|
||||
);
|
||||
},
|
||||
);
|
||||
ModernEmojiHTML.displayName = 'ModernEmojiHTML';
|
||||
|
||||
export const LegacyEmojiHTML = <Element extends ElementType>(
|
||||
props: EmojiHTMLProps<Element>,
|
||||
) => {
|
||||
const { as: asElement, htmlString, extraEmojis, className, ...rest } = props;
|
||||
const Wrapper = asElement ?? 'div';
|
||||
return (
|
||||
<Wrapper
|
||||
{...rest}
|
||||
dangerouslySetInnerHTML={{ __html: htmlString }}
|
||||
className={classNames(className, 'animate-parent')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
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
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { FC } from 'react';
|
|||
import { useContext, useEffect, useState } from 'react';
|
||||
|
||||
import { EMOJI_TYPE_CUSTOM } from '@/mastodon/features/emoji/constants';
|
||||
import { useEmojiAppState } from '@/mastodon/features/emoji/hooks';
|
||||
import { useEmojiAppState } from '@/mastodon/features/emoji/mode';
|
||||
import { unicodeHexToUrl } from '@/mastodon/features/emoji/normalize';
|
||||
import {
|
||||
isStateLoaded,
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import { useDrag } from '@use-gesture/react';
|
|||
import { expandAccountFeaturedTimeline } from '@/mastodon/actions/timelines';
|
||||
import { Icon } from '@/mastodon/components/icon';
|
||||
import { IconButton } from '@/mastodon/components/icon_button';
|
||||
import StatusContainer from '@/mastodon/containers/status_container';
|
||||
import { StatusQuoteManager } from '@/mastodon/components/status_quoted';
|
||||
import { usePrevious } from '@/mastodon/hooks/usePrevious';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
|
||||
|
|
@ -218,12 +218,7 @@ const FeaturedCarouselItem: React.FC<
|
|||
ref={handleRef}
|
||||
{...props}
|
||||
>
|
||||
<StatusContainer
|
||||
// @ts-expect-error inferred props are wrong
|
||||
id={statusId}
|
||||
contextType='account'
|
||||
withCounters
|
||||
/>
|
||||
<StatusQuoteManager id={statusId} contextType='account' withCounters />
|
||||
</animated.div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ import { domain } from 'mastodon/initial_state';
|
|||
import { getAccountHidden } from 'mastodon/selectors/accounts';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
import { useLinks } from '../hooks/useLinks';
|
||||
|
||||
export const HoverCardAccount = forwardRef<
|
||||
HTMLDivElement,
|
||||
{ accountId?: string }
|
||||
|
|
@ -64,6 +66,8 @@ export const HoverCardAccount = forwardRef<
|
|||
!isMutual &&
|
||||
!isFollower;
|
||||
|
||||
const handleClick = useLinks();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
|
|
@ -105,7 +109,14 @@ export const HoverCardAccount = forwardRef<
|
|||
accountId={account.id}
|
||||
className='hover-card__bio'
|
||||
/>
|
||||
<AccountFields fields={account.fields} limit={2} />
|
||||
|
||||
<div className='account-fields' onClickCapture={handleClick}>
|
||||
<AccountFields
|
||||
fields={account.fields.take(2)}
|
||||
emojis={account.emojis}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{note && note.length > 0 && (
|
||||
<dl className='hover-card__note'>
|
||||
<dt className='hover-card__note-label'>
|
||||
|
|
|
|||
|
|
@ -7,23 +7,49 @@ const meta = {
|
|||
title: 'Components/HTMLBlock',
|
||||
component: HTMLBlock,
|
||||
args: {
|
||||
contents:
|
||||
'<p>Hello, world!</p>\n<p><a href="#">A link</a></p>\n<p>This should be filtered out: <button>Bye!</button></p>',
|
||||
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.
|
||||
<div
|
||||
<HTMLBlock
|
||||
{...args}
|
||||
style={{
|
||||
border: '1px solid black',
|
||||
padding: '1rem',
|
||||
minWidth: '300px',
|
||||
}}
|
||||
>
|
||||
<HTMLBlock {...args} />
|
||||
</div>
|
||||
/>
|
||||
);
|
||||
},
|
||||
// Force Twemoji to demonstrate emoji rendering.
|
||||
parameters: {
|
||||
state: {
|
||||
meta: {
|
||||
emoji_style: 'twemoji',
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof HTMLBlock>;
|
||||
|
||||
export default meta;
|
||||
|
|
|
|||
|
|
@ -1,50 +1,30 @@
|
|||
import type { FC, ReactNode } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { cleanExtraEmojis } from '@/mastodon/features/emoji/normalize';
|
||||
import type { CustomEmojiMapArg } from '@/mastodon/features/emoji/types';
|
||||
import { createLimitedCache } from '@/mastodon/utils/cache';
|
||||
import type { OnElementHandler } from '@/mastodon/utils/html';
|
||||
import { polymorphicForwardRef } from '@/types/polymorphic';
|
||||
|
||||
import { htmlStringToComponents } from '../../utils/html';
|
||||
import type { EmojiHTMLProps } from '../emoji/html';
|
||||
import { ModernEmojiHTML } from '../emoji/html';
|
||||
import { useElementHandledLink } from '../status/handled_link';
|
||||
|
||||
// Use a module-level cache to avoid re-rendering the same HTML multiple times.
|
||||
const cache = createLimitedCache<ReactNode>({ maxSize: 1000 });
|
||||
|
||||
interface HTMLBlockProps {
|
||||
contents: string;
|
||||
extraEmojis?: CustomEmojiMapArg;
|
||||
}
|
||||
|
||||
export const HTMLBlock: FC<HTMLBlockProps> = ({
|
||||
contents: raw,
|
||||
extraEmojis,
|
||||
}) => {
|
||||
const customEmojis = useMemo(
|
||||
() => cleanExtraEmojis(extraEmojis),
|
||||
[extraEmojis],
|
||||
);
|
||||
const contents = useMemo(() => {
|
||||
const key = JSON.stringify({ raw, customEmojis });
|
||||
if (cache.has(key)) {
|
||||
return cache.get(key);
|
||||
}
|
||||
|
||||
const rendered = htmlStringToComponents(raw, {
|
||||
onText,
|
||||
extraArgs: { customEmojis },
|
||||
export const HTMLBlock = polymorphicForwardRef<
|
||||
'div',
|
||||
EmojiHTMLProps & Parameters<typeof useElementHandledLink>[0]
|
||||
>(
|
||||
({
|
||||
onElement: onParentElement,
|
||||
hrefToMention,
|
||||
hashtagAccountId,
|
||||
...props
|
||||
}) => {
|
||||
const { onElement: onLinkElement } = useElementHandledLink({
|
||||
hrefToMention,
|
||||
hashtagAccountId,
|
||||
});
|
||||
|
||||
cache.set(key, rendered);
|
||||
return rendered;
|
||||
}, [raw, customEmojis]);
|
||||
|
||||
return contents;
|
||||
};
|
||||
|
||||
function onText(
|
||||
text: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- Doesn't do anything, just showing how typing would work.
|
||||
{ customEmojis }: { customEmojis: CustomEmojiMapArg | null },
|
||||
) {
|
||||
return text;
|
||||
}
|
||||
const onElement: OnElementHandler = useCallback(
|
||||
(...args) => onParentElement?.(...args) ?? onLinkElement(...args),
|
||||
[onLinkElement, onParentElement],
|
||||
);
|
||||
return <ModernEmojiHTML {...props} onElement={onElement} />;
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import classNames from 'classnames';
|
|||
import { animated, useSpring } from '@react-spring/web';
|
||||
import escapeTextContentForBrowser from 'escape-html';
|
||||
|
||||
import { EmojiHTML } from '@/mastodon/components/emoji/html';
|
||||
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { fetchPoll, vote } from 'mastodon/actions/polls';
|
||||
|
|
@ -305,10 +306,11 @@ const PollOption: React.FC<PollOptionProps> = (props) => {
|
|||
</span>
|
||||
)}
|
||||
|
||||
<span
|
||||
<EmojiHTML
|
||||
className='poll__option__text translate'
|
||||
lang={lang}
|
||||
dangerouslySetInnerHTML={{ __html: titleHtml }}
|
||||
htmlString={titleHtml}
|
||||
extraEmojis={poll.emojis}
|
||||
/>
|
||||
|
||||
{!!voted && (
|
||||
|
|
|
|||
|
|
@ -600,7 +600,7 @@ class Status extends ImmutablePureComponent {
|
|||
|
||||
{matchedFilters && <FilterWarning title={matchedFilters.join(', ')} expanded={this.state.showDespiteFilter} onClick={this.handleFilterToggle} />}
|
||||
|
||||
{(status.get('spoiler_text').length > 0 && (!matchedFilters || this.state.showDespiteFilter)) && <ContentWarning text={status.getIn(['translation', 'spoilerHtml']) || status.get('spoilerHtml')} expanded={expanded} onClick={this.handleExpandedToggle} />}
|
||||
{(!matchedFilters || this.state.showDespiteFilter) && <ContentWarning status={status} expanded={expanded} onClick={this.handleExpandedToggle} />}
|
||||
|
||||
{expanded && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,102 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { HashtagMenuController } from '@/mastodon/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!',
|
||||
},
|
||||
};
|
||||
109
app/javascript/mastodon/components/status/handled_link.tsx
Normal file
109
app/javascript/mastodon/components/status/handled_link.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { useCallback } from 'react';
|
||||
import type { ComponentProps, FC } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import type { ApiMentionJSON } from '@/mastodon/api_types/statuses';
|
||||
import type { OnElementHandler } from '@/mastodon/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 };
|
||||
};
|
||||
|
|
@ -3,6 +3,8 @@ import { useCallback, useRef, useId } from 'react';
|
|||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { AnimateEmojiProvider } from './emoji/context';
|
||||
|
||||
export enum BannerVariant {
|
||||
Warning = 'warning',
|
||||
Filter = 'filter',
|
||||
|
|
@ -34,8 +36,7 @@ export const StatusBanner: React.FC<{
|
|||
|
||||
return (
|
||||
// Element clicks are passed on to button
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
<AnimateEmojiProvider
|
||||
className={
|
||||
variant === BannerVariant.Warning
|
||||
? 'content-warning'
|
||||
|
|
@ -69,6 +70,6 @@ export const StatusBanner: React.FC<{
|
|||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</AnimateEmojiProvider>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { languages as preloadedLanguages } from 'mastodon/initial_state';
|
|||
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)
|
||||
|
||||
|
|
@ -27,9 +28,6 @@ const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
|
|||
* @returns {string}
|
||||
*/
|
||||
export function getStatusContent(status) {
|
||||
if (isModernEmojiEnabled()) {
|
||||
return status.getIn(['translation', 'content']) || status.get('content');
|
||||
}
|
||||
return status.getIn(['translation', 'contentHtml']) || status.get('contentHtml');
|
||||
}
|
||||
|
||||
|
|
@ -99,6 +97,23 @@ 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;
|
||||
|
|
@ -128,18 +143,6 @@ class StatusContent extends PureComponent {
|
|||
link.classList.add('unhandled-link');
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
|
|
@ -201,6 +204,27 @@ 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;
|
||||
|
||||
|
|
@ -245,6 +269,7 @@ class StatusContent extends PureComponent {
|
|||
lang={language}
|
||||
htmlString={content}
|
||||
extraEmojis={status.get('emojis')}
|
||||
onElement={this.handleElement.bind(this)}
|
||||
/>
|
||||
|
||||
{poll}
|
||||
|
|
@ -262,6 +287,7 @@ class StatusContent extends PureComponent {
|
|||
lang={language}
|
||||
htmlString={content}
|
||||
extraEmojis={status.get('emojis')}
|
||||
onElement={this.handleElement.bind(this)}
|
||||
/>
|
||||
|
||||
{poll}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,17 @@
|
|||
import { EmojiHTML } from '@/mastodon/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) => {
|
||||
|
|
@ -15,7 +22,23 @@ const stripRelMe = (html: string) => {
|
|||
});
|
||||
|
||||
const body = document.querySelector('body');
|
||||
return body ? { __html: body.innerHTML } : undefined;
|
||||
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;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
|
|
@ -24,6 +47,10 @@ interface Props {
|
|||
export const VerifiedBadge: React.FC<Props> = ({ link }) => (
|
||||
<span className='verified-badge'>
|
||||
<Icon id='check' icon={CheckIcon} className='verified-badge__mark' />
|
||||
<span dangerouslySetInnerHTML={stripRelMe(link)} />
|
||||
<EmojiHTML
|
||||
as='span'
|
||||
htmlString={stripRelMe(link)}
|
||||
onAttribute={onAttribute}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@ import { Helmet } from 'react-helmet';
|
|||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import { AccountBio } from '@/mastodon/components/account_bio';
|
||||
import { AccountFields } from '@/mastodon/components/account_fields';
|
||||
import { DisplayName } from '@/mastodon/components/display_name';
|
||||
import { AnimateEmojiProvider } from '@/mastodon/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';
|
||||
|
|
@ -186,14 +186,6 @@ 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;
|
||||
|
|
@ -891,46 +883,7 @@ export const AccountHeader: React.FC<{
|
|||
</dd>
|
||||
</dl>
|
||||
|
||||
{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>
|
||||
))}
|
||||
<AccountFields fields={fields} emojis={account.emojis} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -50,9 +50,7 @@ export const EditIndicator = () => {
|
|||
|
||||
<EmbeddedStatusContent
|
||||
className='edit-indicator__content translate'
|
||||
content={status.get('contentHtml')}
|
||||
language={status.get('language')}
|
||||
mentions={status.get('mentions')}
|
||||
status={status}
|
||||
/>
|
||||
|
||||
{(status.get('poll') || status.get('media_attachments').size > 0) && (
|
||||
|
|
|
|||
|
|
@ -35,9 +35,7 @@ export const ReplyIndicator = () => {
|
|||
|
||||
<EmbeddedStatusContent
|
||||
className='reply-indicator__content translate'
|
||||
content={status.get('contentHtml')}
|
||||
language={status.get('language')}
|
||||
mentions={status.get('mentions')}
|
||||
status={status}
|
||||
/>
|
||||
|
||||
{(status.get('poll') || status.get('media_attachments').size > 0) && (
|
||||
|
|
|
|||
|
|
@ -10,10 +10,13 @@ import {
|
|||
insertEmojiCompose,
|
||||
uploadCompose,
|
||||
} from 'mastodon/actions/compose';
|
||||
import { pasteLinkCompose } from 'mastodon/actions/compose_typed';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
|
||||
import ComposeForm from '../components/compose_form';
|
||||
|
||||
const urlLikeRegex = /^https?:\/\/[^\s]+\/[^\s]+$/i;
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
text: state.getIn(['compose', 'text']),
|
||||
suggestions: state.getIn(['compose', 'suggestions']),
|
||||
|
|
@ -71,8 +74,21 @@ const mapDispatchToProps = (dispatch, props) => ({
|
|||
dispatch(changeComposeSpoilerText(checked));
|
||||
},
|
||||
|
||||
onPaste (files) {
|
||||
dispatch(uploadCompose(files));
|
||||
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;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onPickEmoji (position, data, needsSpace) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { FormattedMessage } from 'react-intl';
|
|||
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { EmojiHTML } from '@/mastodon/components/emoji/html';
|
||||
import { Avatar } from 'mastodon/components/avatar';
|
||||
import { DisplayName } from 'mastodon/components/display_name';
|
||||
import { FollowButton } from 'mastodon/components/follow_button';
|
||||
|
|
@ -39,9 +40,10 @@ export const AccountCard: React.FC<{ accountId: string }> = ({ accountId }) => {
|
|||
</Link>
|
||||
|
||||
{account.get('note').length > 0 && (
|
||||
<div
|
||||
className='account-card__bio translate animate-parent'
|
||||
dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }}
|
||||
<EmojiHTML
|
||||
className='account-card__bio translate'
|
||||
htmlString={account.get('note_emojified')}
|
||||
extraEmojis={account.get('emojis')}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import Trie from 'substring-trie';
|
||||
|
||||
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
|
||||
import { assetHost } from 'mastodon/utils/config';
|
||||
|
||||
import { autoPlayGif } from '../../initial_state';
|
||||
|
|
@ -148,7 +149,17 @@ const emojifyNode = (node, customEmojis) => {
|
|||
}
|
||||
};
|
||||
|
||||
const emojify = (str, 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 wrapper = document.createElement('div');
|
||||
wrapper.innerHTML = str;
|
||||
|
||||
|
|
|
|||
|
|
@ -2,9 +2,12 @@ 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 '@/mastodon/utils/environment';
|
||||
import { assetHost } from 'mastodon/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`;
|
||||
|
||||
|
|
@ -16,6 +19,7 @@ const Emoji = ({
|
|||
backgroundImageFn = backgroundImageFnDefault,
|
||||
...props
|
||||
}: EmojiProps) => {
|
||||
const { mode } = useEmojiAppState();
|
||||
return (
|
||||
<EmojiRaw
|
||||
data={EmojiData}
|
||||
|
|
@ -23,6 +27,7 @@ const Emoji = ({
|
|||
sheetSize={sheetSize}
|
||||
sheetColumns={sheetColumns}
|
||||
sheetRows={sheetRows}
|
||||
native={mode === EMOJI_MODE_NATIVE && isModernEmojiEnabled()}
|
||||
backgroundImageFn={backgroundImageFn}
|
||||
{...props}
|
||||
/>
|
||||
|
|
@ -37,6 +42,7 @@ const Picker = ({
|
|||
backgroundImageFn = backgroundImageFnDefault,
|
||||
...props
|
||||
}: PickerProps) => {
|
||||
const { mode } = useEmojiAppState();
|
||||
return (
|
||||
<PickerRaw
|
||||
data={EmojiData}
|
||||
|
|
@ -45,6 +51,7 @@ const Picker = ({
|
|||
sheetColumns={sheetColumns}
|
||||
sheetRows={sheetRows}
|
||||
backgroundImageFn={backgroundImageFn}
|
||||
native={mode === EMOJI_MODE_NATIVE && isModernEmojiEnabled()}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,75 +0,0 @@
|
|||
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { useAppSelector } from '@/mastodon/store';
|
||||
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
|
||||
|
||||
import { toSupportedLocale } from './locale';
|
||||
import { determineEmojiMode } from './mode';
|
||||
import { cleanExtraEmojis } from './normalize';
|
||||
import { emojifyElement, emojifyText } from './render';
|
||||
import type { CustomEmojiMapArg, EmojiAppState } 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 = useMemo(() => cleanExtraEmojis(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'),
|
||||
};
|
||||
}
|
||||
|
|
@ -10,6 +10,8 @@ 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) {
|
||||
|
|
@ -29,7 +31,7 @@ export function initializeEmoji() {
|
|||
log('worker is not ready after timeout');
|
||||
worker = null;
|
||||
void fallbackLoad();
|
||||
}, 500);
|
||||
}, WORKER_TIMEOUT);
|
||||
thisWorker.addEventListener('message', (event: MessageEvent<string>) => {
|
||||
const { data: message } = event;
|
||||
if (message === 'ready') {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// 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 '@/mastodon/store';
|
||||
import { isDevelopment } from '@/mastodon/utils/environment';
|
||||
|
||||
import {
|
||||
|
|
@ -8,7 +9,27 @@ import {
|
|||
EMOJI_MODE_NATIVE_WITH_FLAGS,
|
||||
EMOJI_MODE_TWEMOJI,
|
||||
} from './constants';
|
||||
import type { EmojiMode } from './types';
|
||||
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'),
|
||||
};
|
||||
}
|
||||
|
||||
type Feature = Uint8ClampedArray;
|
||||
|
||||
|
|
|
|||
|
|
@ -154,15 +154,21 @@ export function cleanExtraEmojis(extraEmojis?: CustomEmojiMapArg) {
|
|||
if (!extraEmojis) {
|
||||
return null;
|
||||
}
|
||||
if (!isList(extraEmojis)) {
|
||||
return extraEmojis;
|
||||
}
|
||||
return extraEmojis
|
||||
.toJSON()
|
||||
.reduce<ExtraCustomEmojiMap>(
|
||||
if (Array.isArray(extraEmojis)) {
|
||||
return extraEmojis.reduce<ExtraCustomEmojiMap>(
|
||||
(acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }),
|
||||
{},
|
||||
);
|
||||
}
|
||||
if (isList(extraEmojis)) {
|
||||
return extraEmojis
|
||||
.toJS()
|
||||
.reduce<ExtraCustomEmojiMap>(
|
||||
(acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }),
|
||||
{},
|
||||
);
|
||||
}
|
||||
return extraEmojis;
|
||||
}
|
||||
|
||||
function hexStringToNumbers(hexString: string): number[] {
|
||||
|
|
|
|||
|
|
@ -1,101 +1,12 @@
|
|||
import { customEmojiFactory, unicodeEmojiFactory } from '@/testing/factories';
|
||||
|
||||
import { EMOJI_MODE_TWEMOJI } from './constants';
|
||||
import * as db from './database';
|
||||
import * as loader from './loader';
|
||||
import {
|
||||
emojifyElement,
|
||||
emojifyText,
|
||||
testCacheClear,
|
||||
loadEmojiDataToState,
|
||||
stringToEmojiState,
|
||||
tokenizeText,
|
||||
} from './render';
|
||||
import type { EmojiAppState } from './types';
|
||||
|
||||
function mockDatabase() {
|
||||
return {
|
||||
searchCustomEmojisByShortcodes: vi
|
||||
.spyOn(db, 'searchCustomEmojisByShortcodes')
|
||||
.mockResolvedValue([customEmojiFactory()]),
|
||||
searchEmojisByHexcodes: vi
|
||||
.spyOn(db, 'searchEmojisByHexcodes')
|
||||
.mockResolvedValue([
|
||||
unicodeEmojiFactory({
|
||||
hexcode: '1F60A',
|
||||
label: 'smiling face with smiling eyes',
|
||||
unicode: '😊',
|
||||
}),
|
||||
unicodeEmojiFactory({
|
||||
hexcode: '1F1EA-1F1FA',
|
||||
label: 'flag-eu',
|
||||
unicode: '🇪🇺',
|
||||
}),
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
const expectedSmileImage =
|
||||
'<img draggable="false" class="emojione" alt="😊" title="smiling face with smiling eyes" src="/emoji/1f60a.svg">';
|
||||
const expectedFlagImage =
|
||||
'<img draggable="false" class="emojione" alt="🇪🇺" title="flag-eu" src="/emoji/1f1ea-1f1fa.svg">';
|
||||
|
||||
function testAppState(state: Partial<EmojiAppState> = {}) {
|
||||
return {
|
||||
locales: ['en'],
|
||||
mode: EMOJI_MODE_TWEMOJI,
|
||||
currentLocale: 'en',
|
||||
darkTheme: false,
|
||||
...state,
|
||||
} satisfies EmojiAppState;
|
||||
}
|
||||
|
||||
describe('emojifyElement', () => {
|
||||
function testElement(text = '<p>Hello 😊🇪🇺!</p><p>:custom:</p>') {
|
||||
const testElement = document.createElement('div');
|
||||
testElement.innerHTML = text;
|
||||
return testElement;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
testCacheClear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('caches element rendering results', async () => {
|
||||
const { searchCustomEmojisByShortcodes, searchEmojisByHexcodes } =
|
||||
mockDatabase();
|
||||
await emojifyElement(testElement(), testAppState());
|
||||
await emojifyElement(testElement(), testAppState());
|
||||
await emojifyElement(testElement(), testAppState());
|
||||
expect(searchEmojisByHexcodes).toHaveBeenCalledExactlyOnceWith(
|
||||
['1F1EA-1F1FA', '1F60A'],
|
||||
'en',
|
||||
);
|
||||
expect(searchCustomEmojisByShortcodes).toHaveBeenCalledExactlyOnceWith([
|
||||
':custom:',
|
||||
]);
|
||||
});
|
||||
|
||||
test('returns null when no emoji are found', async () => {
|
||||
mockDatabase();
|
||||
const actual = await emojifyElement(
|
||||
testElement('<p>here is just text :)</p>'),
|
||||
testAppState(),
|
||||
);
|
||||
expect(actual).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('emojifyText', () => {
|
||||
test('returns original input when no emoji are in string', async () => {
|
||||
const actual = await emojifyText('nothing here', testAppState());
|
||||
expect(actual).toBe('nothing here');
|
||||
});
|
||||
|
||||
test('renders Unicode emojis to twemojis', async () => {
|
||||
mockDatabase();
|
||||
const actual = await emojifyText('Hello 😊🇪🇺!', testAppState());
|
||||
expect(actual).toBe(`Hello ${expectedSmileImage}${expectedFlagImage}!`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tokenizeText', () => {
|
||||
test('returns an array of text to be a single token', () => {
|
||||
|
|
@ -162,3 +73,106 @@ describe('tokenizeText', () => {
|
|||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stringToEmojiState', () => {
|
||||
test('returns unicode emoji state for valid unicode emoji', () => {
|
||||
expect(stringToEmojiState('😊')).toEqual({
|
||||
type: 'unicode',
|
||||
code: '1F60A',
|
||||
});
|
||||
});
|
||||
|
||||
test('returns custom emoji state for valid custom emoji', () => {
|
||||
expect(stringToEmojiState(':smile:')).toEqual({
|
||||
type: 'custom',
|
||||
code: 'smile',
|
||||
data: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test('returns custom emoji state with data when provided', () => {
|
||||
const customEmoji = {
|
||||
smile: customEmojiFactory({
|
||||
shortcode: 'smile',
|
||||
url: 'https://example.com/smile.png',
|
||||
static_url: 'https://example.com/smile_static.png',
|
||||
}),
|
||||
};
|
||||
expect(stringToEmojiState(':smile:', customEmoji)).toEqual({
|
||||
type: 'custom',
|
||||
code: 'smile',
|
||||
data: customEmoji.smile,
|
||||
});
|
||||
});
|
||||
|
||||
test('returns null for invalid emoji strings', () => {
|
||||
expect(stringToEmojiState('notanemoji')).toBeNull();
|
||||
expect(stringToEmojiState(':invalid-emoji:')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadEmojiDataToState', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('loads unicode data into state', async () => {
|
||||
const dbCall = vi
|
||||
.spyOn(db, 'loadEmojiByHexcode')
|
||||
.mockResolvedValue(unicodeEmojiFactory());
|
||||
const unicodeState = { type: 'unicode', code: '1F60A' } as const;
|
||||
const result = await loadEmojiDataToState(unicodeState, 'en');
|
||||
expect(dbCall).toHaveBeenCalledWith('1F60A', 'en');
|
||||
expect(result).toEqual({
|
||||
type: 'unicode',
|
||||
code: '1F60A',
|
||||
data: unicodeEmojiFactory(),
|
||||
});
|
||||
});
|
||||
|
||||
test('loads custom emoji data into state', async () => {
|
||||
const dbCall = vi
|
||||
.spyOn(db, 'loadCustomEmojiByShortcode')
|
||||
.mockResolvedValueOnce(customEmojiFactory());
|
||||
const customState = { type: 'custom', code: 'smile' } as const;
|
||||
const result = await loadEmojiDataToState(customState, 'en');
|
||||
expect(dbCall).toHaveBeenCalledWith('smile');
|
||||
expect(result).toEqual({
|
||||
type: 'custom',
|
||||
code: 'smile',
|
||||
data: customEmojiFactory(),
|
||||
});
|
||||
});
|
||||
|
||||
test('returns null if unicode emoji not found in database', async () => {
|
||||
vi.spyOn(db, 'loadEmojiByHexcode').mockResolvedValueOnce(undefined);
|
||||
const unicodeState = { type: 'unicode', code: '1F60A' } as const;
|
||||
const result = await loadEmojiDataToState(unicodeState, 'en');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null if custom emoji not found in database', async () => {
|
||||
vi.spyOn(db, 'loadCustomEmojiByShortcode').mockResolvedValueOnce(undefined);
|
||||
const customState = { type: 'custom', code: 'smile' } as const;
|
||||
const result = await loadEmojiDataToState(customState, 'en');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('retries loading emoji data once if initial load fails', async () => {
|
||||
const dbCall = vi
|
||||
.spyOn(db, 'loadEmojiByHexcode')
|
||||
.mockRejectedValue(new db.LocaleNotLoadedError('en'));
|
||||
vi.spyOn(loader, 'importEmojiData').mockResolvedValueOnce();
|
||||
const consoleCall = vi
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementationOnce(() => null);
|
||||
|
||||
const unicodeState = { type: 'unicode', code: '1F60A' } as const;
|
||||
const result = await loadEmojiDataToState(unicodeState, 'en');
|
||||
|
||||
expect(dbCall).toHaveBeenCalledTimes(2);
|
||||
expect(loader.importEmojiData).toHaveBeenCalledWith('en');
|
||||
expect(consoleCall).toHaveBeenCalled();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,3 @@
|
|||
import { autoPlayGif } from '@/mastodon/initial_state';
|
||||
import { createLimitedCache } from '@/mastodon/utils/cache';
|
||||
import * as perf from '@/mastodon/utils/performance';
|
||||
|
||||
import {
|
||||
EMOJI_MODE_NATIVE,
|
||||
EMOJI_MODE_NATIVE_WITH_FLAGS,
|
||||
|
|
@ -12,33 +8,69 @@ import {
|
|||
loadCustomEmojiByShortcode,
|
||||
loadEmojiByHexcode,
|
||||
LocaleNotLoadedError,
|
||||
searchCustomEmojisByShortcodes,
|
||||
searchEmojisByHexcodes,
|
||||
} from './database';
|
||||
import { importEmojiData } from './loader';
|
||||
import { emojiToUnicodeHex, unicodeHexToUrl } from './normalize';
|
||||
import { emojiToUnicodeHex } from './normalize';
|
||||
import type {
|
||||
EmojiAppState,
|
||||
EmojiLoadedState,
|
||||
EmojiMode,
|
||||
EmojiState,
|
||||
EmojiStateCustom,
|
||||
EmojiStateMap,
|
||||
EmojiStateUnicode,
|
||||
ExtraCustomEmojiMap,
|
||||
LocaleOrCustom,
|
||||
} from './types';
|
||||
import {
|
||||
anyEmojiRegex,
|
||||
emojiLogger,
|
||||
isCustomEmoji,
|
||||
isUnicodeEmoji,
|
||||
stringHasAnyEmoji,
|
||||
stringHasUnicodeFlags,
|
||||
} from './utils';
|
||||
|
||||
const log = emojiLogger('render');
|
||||
|
||||
type TokenizedText = (string | EmojiState)[];
|
||||
|
||||
/**
|
||||
* Tokenizes text into strings and emoji states.
|
||||
* @param text Text to tokenize.
|
||||
* @returns Array of strings and emoji states.
|
||||
*/
|
||||
export function tokenizeText(text: string): TokenizedText {
|
||||
if (!text.trim()) {
|
||||
return [text];
|
||||
}
|
||||
|
||||
const tokens = [];
|
||||
let lastIndex = 0;
|
||||
for (const match of text.matchAll(anyEmojiRegex())) {
|
||||
if (match.index > lastIndex) {
|
||||
tokens.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
|
||||
const code = match[0];
|
||||
|
||||
if (code.startsWith(':') && code.endsWith(':')) {
|
||||
// Custom emoji
|
||||
tokens.push({
|
||||
type: EMOJI_TYPE_CUSTOM,
|
||||
code,
|
||||
} satisfies EmojiStateCustom);
|
||||
} else {
|
||||
// Unicode emoji
|
||||
tokens.push({
|
||||
type: EMOJI_TYPE_UNICODE,
|
||||
code: code,
|
||||
} satisfies EmojiStateUnicode);
|
||||
}
|
||||
lastIndex = match.index + code.length;
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
tokens.push(text.slice(lastIndex));
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses emoji string to extract emoji state.
|
||||
* @param code Hex code or custom shortcode.
|
||||
|
|
@ -132,305 +164,19 @@ export function isStateLoaded(state: EmojiState): state is EmojiLoadedState {
|
|||
}
|
||||
|
||||
/**
|
||||
* Emojifies an element. This modifies the element in place, replacing text nodes with emojified versions.
|
||||
* Determines if the given token should be rendered as an image based on the emoji mode.
|
||||
* @param state Emoji state to parse.
|
||||
* @param mode Rendering mode.
|
||||
* @returns Whether to render as an image.
|
||||
*/
|
||||
export async function emojifyElement<Element extends HTMLElement>(
|
||||
element: Element,
|
||||
appState: EmojiAppState,
|
||||
extraEmojis: ExtraCustomEmojiMap = {},
|
||||
): Promise<Element | null> {
|
||||
const cacheKey = createCacheKey(element, appState, extraEmojis);
|
||||
const cached = getCached(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
log('Cache hit on %s', element.outerHTML);
|
||||
if (cached === null) {
|
||||
return null;
|
||||
}
|
||||
element.innerHTML = cached;
|
||||
return element;
|
||||
}
|
||||
if (!stringHasAnyEmoji(element.innerHTML)) {
|
||||
updateCache(cacheKey, null);
|
||||
return null;
|
||||
}
|
||||
perf.start('emojifyElement()');
|
||||
const queue: (HTMLElement | Text)[] = [element];
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
if (
|
||||
!current ||
|
||||
current instanceof HTMLScriptElement ||
|
||||
current instanceof HTMLStyleElement
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
current.textContent &&
|
||||
(current instanceof Text || !current.hasChildNodes())
|
||||
) {
|
||||
const renderedContent = await textToElementArray(
|
||||
current.textContent,
|
||||
appState,
|
||||
extraEmojis,
|
||||
);
|
||||
if (renderedContent) {
|
||||
if (!(current instanceof Text)) {
|
||||
current.textContent = null; // Clear the text content if it's not a Text node.
|
||||
}
|
||||
current.replaceWith(renderedToHTML(renderedContent));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const child of current.childNodes) {
|
||||
if (child instanceof HTMLElement || child instanceof Text) {
|
||||
queue.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
updateCache(cacheKey, element.innerHTML);
|
||||
perf.stop('emojifyElement()');
|
||||
return element;
|
||||
}
|
||||
|
||||
export async function emojifyText(
|
||||
text: string,
|
||||
appState: EmojiAppState,
|
||||
extraEmojis: ExtraCustomEmojiMap = {},
|
||||
): Promise<string | null> {
|
||||
const cacheKey = createCacheKey(text, appState, extraEmojis);
|
||||
const cached = getCached(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
log('Cache hit on %s', text);
|
||||
return cached ?? text;
|
||||
}
|
||||
if (!stringHasAnyEmoji(text)) {
|
||||
updateCache(cacheKey, null);
|
||||
return text;
|
||||
}
|
||||
const eleArray = await textToElementArray(text, appState, extraEmojis);
|
||||
if (!eleArray) {
|
||||
updateCache(cacheKey, null);
|
||||
return text;
|
||||
}
|
||||
const rendered = renderedToHTML(eleArray, document.createElement('div'));
|
||||
updateCache(cacheKey, rendered.innerHTML);
|
||||
return rendered.innerHTML;
|
||||
}
|
||||
|
||||
// Private functions
|
||||
|
||||
const {
|
||||
set: updateCache,
|
||||
get: getCached,
|
||||
clear: cacheClear,
|
||||
} = createLimitedCache<string | null>({ log: log.extend('cache') });
|
||||
|
||||
function createCacheKey(
|
||||
input: HTMLElement | string,
|
||||
appState: EmojiAppState,
|
||||
extraEmojis: ExtraCustomEmojiMap,
|
||||
) {
|
||||
return JSON.stringify([
|
||||
input instanceof HTMLElement ? input.outerHTML : input,
|
||||
appState,
|
||||
extraEmojis,
|
||||
]);
|
||||
}
|
||||
|
||||
type EmojifiedTextArray = (string | HTMLImageElement)[];
|
||||
|
||||
async function textToElementArray(
|
||||
text: string,
|
||||
appState: EmojiAppState,
|
||||
extraEmojis: ExtraCustomEmojiMap = {},
|
||||
): Promise<EmojifiedTextArray | null> {
|
||||
// Exit if no text to convert.
|
||||
if (!text.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokens = tokenizeText(text);
|
||||
|
||||
// If only one token and it's a string, exit early.
|
||||
if (tokens.length === 1 && typeof tokens[0] === 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get all emoji from the state map, loading any missing ones.
|
||||
await loadMissingEmojiIntoCache(tokens, appState, extraEmojis);
|
||||
|
||||
const renderedFragments: EmojifiedTextArray = [];
|
||||
for (const token of tokens) {
|
||||
if (typeof token !== 'string' && shouldRenderImage(token, appState.mode)) {
|
||||
let state: EmojiState | undefined;
|
||||
if (token.type === EMOJI_TYPE_CUSTOM) {
|
||||
const extraEmojiData = extraEmojis[token.code];
|
||||
if (extraEmojiData) {
|
||||
state = {
|
||||
type: EMOJI_TYPE_CUSTOM,
|
||||
data: extraEmojiData,
|
||||
code: token.code,
|
||||
};
|
||||
} else {
|
||||
state = emojiForLocale(token.code, EMOJI_TYPE_CUSTOM);
|
||||
}
|
||||
} else {
|
||||
state = emojiForLocale(
|
||||
emojiToUnicodeHex(token.code),
|
||||
appState.currentLocale,
|
||||
);
|
||||
}
|
||||
|
||||
// If the state is valid, create an image element. Otherwise, just append as text.
|
||||
if (state && typeof state !== 'string' && isStateLoaded(state)) {
|
||||
const image = stateToImage(state, appState);
|
||||
renderedFragments.push(image);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const text = typeof token === 'string' ? token : token.code;
|
||||
renderedFragments.push(text);
|
||||
}
|
||||
|
||||
return renderedFragments;
|
||||
}
|
||||
|
||||
type TokenizedText = (string | EmojiState)[];
|
||||
|
||||
export function tokenizeText(text: string): TokenizedText {
|
||||
if (!text.trim()) {
|
||||
return [text];
|
||||
}
|
||||
|
||||
const tokens = [];
|
||||
let lastIndex = 0;
|
||||
for (const match of text.matchAll(anyEmojiRegex())) {
|
||||
if (match.index > lastIndex) {
|
||||
tokens.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
|
||||
const code = match[0];
|
||||
|
||||
if (code.startsWith(':') && code.endsWith(':')) {
|
||||
// Custom emoji
|
||||
tokens.push({
|
||||
type: EMOJI_TYPE_CUSTOM,
|
||||
code,
|
||||
} satisfies EmojiStateCustom);
|
||||
} else {
|
||||
// Unicode emoji
|
||||
tokens.push({
|
||||
type: EMOJI_TYPE_UNICODE,
|
||||
code: code,
|
||||
} satisfies EmojiStateUnicode);
|
||||
}
|
||||
lastIndex = match.index + code.length;
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
tokens.push(text.slice(lastIndex));
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
const localeCacheMap = new Map<LocaleOrCustom, EmojiStateMap>([
|
||||
[
|
||||
EMOJI_TYPE_CUSTOM,
|
||||
createLimitedCache<EmojiState>({ log: log.extend('custom') }),
|
||||
],
|
||||
]);
|
||||
|
||||
function cacheForLocale(locale: LocaleOrCustom): EmojiStateMap {
|
||||
return (
|
||||
localeCacheMap.get(locale) ??
|
||||
createLimitedCache<EmojiState>({ log: log.extend(locale) })
|
||||
);
|
||||
}
|
||||
|
||||
function emojiForLocale(
|
||||
code: string,
|
||||
locale: LocaleOrCustom,
|
||||
): EmojiState | undefined {
|
||||
const cache = cacheForLocale(locale);
|
||||
return cache.get(code);
|
||||
}
|
||||
|
||||
async function loadMissingEmojiIntoCache(
|
||||
tokens: TokenizedText,
|
||||
{ mode, currentLocale }: EmojiAppState,
|
||||
extraEmojis: ExtraCustomEmojiMap,
|
||||
) {
|
||||
const missingUnicodeEmoji = new Set<string>();
|
||||
const missingCustomEmoji = new Set<string>();
|
||||
|
||||
// Iterate over tokens and check if they are in the cache already.
|
||||
for (const token of tokens) {
|
||||
if (typeof token === 'string') {
|
||||
continue; // Skip plain strings.
|
||||
}
|
||||
|
||||
// If this is a custom emoji, check it separately.
|
||||
if (token.type === EMOJI_TYPE_CUSTOM) {
|
||||
const code = token.code;
|
||||
if (code in extraEmojis) {
|
||||
continue; // We don't care about extra emoji.
|
||||
}
|
||||
const emojiState = emojiForLocale(code, EMOJI_TYPE_CUSTOM);
|
||||
if (!emojiState) {
|
||||
missingCustomEmoji.add(code);
|
||||
}
|
||||
// Otherwise this is a unicode emoji, so check it against all locales.
|
||||
} else if (shouldRenderImage(token, mode)) {
|
||||
const code = emojiToUnicodeHex(token.code);
|
||||
if (missingUnicodeEmoji.has(code)) {
|
||||
continue; // Already marked as missing.
|
||||
}
|
||||
const emojiState = emojiForLocale(code, currentLocale);
|
||||
if (!emojiState) {
|
||||
// If it's missing in one locale, we consider it missing for all.
|
||||
missingUnicodeEmoji.add(code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (missingUnicodeEmoji.size > 0) {
|
||||
const missingEmojis = Array.from(missingUnicodeEmoji).toSorted();
|
||||
const emojis = await searchEmojisByHexcodes(missingEmojis, currentLocale);
|
||||
const cache = cacheForLocale(currentLocale);
|
||||
for (const emoji of emojis) {
|
||||
cache.set(emoji.hexcode, {
|
||||
type: EMOJI_TYPE_UNICODE,
|
||||
data: emoji,
|
||||
code: emoji.hexcode,
|
||||
});
|
||||
}
|
||||
localeCacheMap.set(currentLocale, cache);
|
||||
}
|
||||
|
||||
if (missingCustomEmoji.size > 0) {
|
||||
const missingEmojis = Array.from(missingCustomEmoji).toSorted();
|
||||
const emojis = await searchCustomEmojisByShortcodes(missingEmojis);
|
||||
const cache = cacheForLocale(EMOJI_TYPE_CUSTOM);
|
||||
for (const emoji of emojis) {
|
||||
cache.set(emoji.shortcode, {
|
||||
type: EMOJI_TYPE_CUSTOM,
|
||||
data: emoji,
|
||||
code: emoji.shortcode,
|
||||
});
|
||||
}
|
||||
localeCacheMap.set(EMOJI_TYPE_CUSTOM, cache);
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldRenderImage(token: EmojiState, mode: EmojiMode): boolean {
|
||||
if (token.type === EMOJI_TYPE_UNICODE) {
|
||||
export function shouldRenderImage(state: EmojiState, mode: EmojiMode): boolean {
|
||||
if (state.type === EMOJI_TYPE_UNICODE) {
|
||||
// If the mode is native or native with flags for non-flag emoji
|
||||
// we can just append the text node directly.
|
||||
if (
|
||||
mode === EMOJI_MODE_NATIVE ||
|
||||
(mode === EMOJI_MODE_NATIVE_WITH_FLAGS &&
|
||||
!stringHasUnicodeFlags(token.code))
|
||||
!stringHasUnicodeFlags(state.code))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -438,52 +184,3 @@ export function shouldRenderImage(token: EmojiState, mode: EmojiMode): boolean {
|
|||
|
||||
return true;
|
||||
}
|
||||
|
||||
function stateToImage(state: EmojiLoadedState, appState: EmojiAppState) {
|
||||
const image = document.createElement('img');
|
||||
image.draggable = false;
|
||||
image.classList.add('emojione');
|
||||
|
||||
if (state.type === EMOJI_TYPE_UNICODE) {
|
||||
image.alt = state.data.unicode;
|
||||
image.title = state.data.label;
|
||||
image.src = unicodeHexToUrl(state.data.hexcode, appState.darkTheme);
|
||||
} else {
|
||||
// Custom emoji
|
||||
const shortCode = `:${state.data.shortcode}:`;
|
||||
image.classList.add('custom-emoji');
|
||||
image.alt = shortCode;
|
||||
image.title = shortCode;
|
||||
image.src = autoPlayGif ? state.data.url : state.data.static_url;
|
||||
image.dataset.original = state.data.url;
|
||||
image.dataset.static = state.data.static_url;
|
||||
}
|
||||
|
||||
return image;
|
||||
}
|
||||
|
||||
function renderedToHTML(renderedArray: EmojifiedTextArray): DocumentFragment;
|
||||
function renderedToHTML<ParentType extends ParentNode>(
|
||||
renderedArray: EmojifiedTextArray,
|
||||
parent: ParentType,
|
||||
): ParentType;
|
||||
function renderedToHTML(
|
||||
renderedArray: EmojifiedTextArray,
|
||||
parent: ParentNode | null = null,
|
||||
) {
|
||||
const fragment = parent ?? document.createDocumentFragment();
|
||||
for (const fragmentItem of renderedArray) {
|
||||
if (typeof fragmentItem === 'string') {
|
||||
fragment.appendChild(document.createTextNode(fragmentItem));
|
||||
} else if (fragmentItem instanceof HTMLImageElement) {
|
||||
fragment.appendChild(fragmentItem);
|
||||
}
|
||||
}
|
||||
return fragment;
|
||||
}
|
||||
|
||||
// Testing helpers
|
||||
export const testCacheClear = () => {
|
||||
cacheClear();
|
||||
localeCacheMap.clear();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import type { FlatCompactEmoji, Locale } from 'emojibase';
|
|||
|
||||
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
|
||||
import type { CustomEmoji } from '@/mastodon/models/custom_emoji';
|
||||
import type { LimitedCache } from '@/mastodon/utils/cache';
|
||||
|
||||
import type {
|
||||
EMOJI_MODE_NATIVE,
|
||||
|
|
@ -48,23 +47,18 @@ export interface EmojiStateCustom {
|
|||
data?: CustomEmojiRenderFields;
|
||||
}
|
||||
export type EmojiState = EmojiStateUnicode | EmojiStateCustom;
|
||||
|
||||
export type EmojiLoadedState =
|
||||
| Required<EmojiStateUnicode>
|
||||
| Required<EmojiStateCustom>;
|
||||
|
||||
export type EmojiStateMap = LimitedCache<string, EmojiState>;
|
||||
|
||||
export type CustomEmojiMapArg =
|
||||
| ExtraCustomEmojiMap
|
||||
| ImmutableList<CustomEmoji>;
|
||||
| ImmutableList<CustomEmoji>
|
||||
| CustomEmoji[]
|
||||
| ApiCustomEmojiJSON[];
|
||||
|
||||
export type ExtraCustomEmojiMap = Record<
|
||||
string,
|
||||
Pick<CustomEmojiData, 'shortcode' | 'static_url' | 'url'>
|
||||
>;
|
||||
|
||||
export interface TwemojiBorderInfo {
|
||||
hexCode: string;
|
||||
hasLightBorder: boolean;
|
||||
hasDarkBorder: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,35 +1,31 @@
|
|||
import {
|
||||
stringHasAnyEmoji,
|
||||
stringHasCustomEmoji,
|
||||
stringHasUnicodeEmoji,
|
||||
stringHasUnicodeFlags,
|
||||
} from './utils';
|
||||
import { isCustomEmoji, isUnicodeEmoji, stringHasUnicodeFlags } from './utils';
|
||||
|
||||
describe('stringHasUnicodeEmoji', () => {
|
||||
describe('isUnicodeEmoji', () => {
|
||||
test.concurrent.for([
|
||||
['only text', false],
|
||||
['text with non-emoji symbols ™©', false],
|
||||
['text with emoji 😀', true],
|
||||
['multiple emojis 😀😃😄', true],
|
||||
['emoji with skin tone 👍🏽', true],
|
||||
['emoji with ZWJ 👩❤️👨', true],
|
||||
['emoji with variation selector ✊️', true],
|
||||
['emoji with keycap 1️⃣', true],
|
||||
['emoji with flags 🇺🇸', true],
|
||||
['emoji with regional indicators 🇦🇺', true],
|
||||
['emoji with gender 👩⚕️', true],
|
||||
['emoji with family 👨👩👧👦', true],
|
||||
['emoji with zero width joiner 👩🔬', true],
|
||||
['emoji with non-BMP codepoint 🧑🚀', true],
|
||||
['emoji with combining marks 👨👩👧👦', true],
|
||||
['emoji with enclosing keycap #️⃣', true],
|
||||
['emoji with no visible glyph \u200D', false],
|
||||
] as const)(
|
||||
'stringHasUnicodeEmoji has emojis in "%s": %o',
|
||||
([text, expected], { expect }) => {
|
||||
expect(stringHasUnicodeEmoji(text)).toBe(expected);
|
||||
},
|
||||
);
|
||||
['😊', true],
|
||||
['🇿🇼', true],
|
||||
['🏴☠️', true],
|
||||
['🏳️🌈', true],
|
||||
['foo', false],
|
||||
[':smile:', false],
|
||||
['😊foo', false],
|
||||
] as const)('isUnicodeEmoji("%s") is %o', ([input, expected], { expect }) => {
|
||||
expect(isUnicodeEmoji(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCustomEmoji', () => {
|
||||
test.concurrent.for([
|
||||
[':smile:', true],
|
||||
[':smile_123:', true],
|
||||
[':SMILE:', true],
|
||||
['😊', false],
|
||||
['foo', false],
|
||||
[':smile', false],
|
||||
['smile:', false],
|
||||
] as const)('isCustomEmoji("%s") is %o', ([input, expected], { expect }) => {
|
||||
expect(isCustomEmoji(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stringHasUnicodeFlags', () => {
|
||||
|
|
@ -51,27 +47,3 @@ describe('stringHasUnicodeFlags', () => {
|
|||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('stringHasCustomEmoji', () => {
|
||||
test('string with custom emoji returns true', () => {
|
||||
expect(stringHasCustomEmoji(':custom: :test:')).toBeTruthy();
|
||||
});
|
||||
test('string without custom emoji returns false', () => {
|
||||
expect(stringHasCustomEmoji('🏳️🌈 :🏳️🌈: text ™')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('stringHasAnyEmoji', () => {
|
||||
test('string without any emoji or characters', () => {
|
||||
expect(stringHasAnyEmoji('normal text. 12356?!')).toBeFalsy();
|
||||
});
|
||||
test('string with non-emoji characters', () => {
|
||||
expect(stringHasAnyEmoji('™©')).toBeFalsy();
|
||||
});
|
||||
test('has unicode emoji', () => {
|
||||
expect(stringHasAnyEmoji('🏳️🌈🔥🇸🇹 👩🔬')).toBeTruthy();
|
||||
});
|
||||
test('has custom emoji', () => {
|
||||
expect(stringHasAnyEmoji(':test: :custom:')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,10 +6,6 @@ export function emojiLogger(segment: string) {
|
|||
return debug(`emojis:${segment}`);
|
||||
}
|
||||
|
||||
export function stringHasUnicodeEmoji(input: string): boolean {
|
||||
return new RegExp(EMOJI_REGEX, supportedFlags()).test(input);
|
||||
}
|
||||
|
||||
export function isUnicodeEmoji(input: string): boolean {
|
||||
return (
|
||||
input.length > 0 &&
|
||||
|
|
@ -34,19 +30,13 @@ export function stringHasUnicodeFlags(input: string): boolean {
|
|||
|
||||
// Constant as this is supported by all browsers.
|
||||
const CUSTOM_EMOJI_REGEX = /:([a-z0-9_]+):/i;
|
||||
// Use the polyfill regex or the Unicode property escapes if supported.
|
||||
const EMOJI_REGEX = emojiRegexPolyfill?.source ?? '\\p{RGI_Emoji}';
|
||||
|
||||
export function isCustomEmoji(input: string): boolean {
|
||||
return new RegExp(`^${CUSTOM_EMOJI_REGEX.source}$`, 'i').test(input);
|
||||
}
|
||||
|
||||
export function stringHasCustomEmoji(input: string) {
|
||||
return CUSTOM_EMOJI_REGEX.test(input);
|
||||
}
|
||||
|
||||
export function stringHasAnyEmoji(input: string) {
|
||||
return stringHasUnicodeEmoji(input) || stringHasCustomEmoji(input);
|
||||
}
|
||||
|
||||
export function anyEmojiRegex() {
|
||||
return new RegExp(
|
||||
`${EMOJI_REGEX}|${CUSTOM_EMOJI_REGEX.source}`,
|
||||
|
|
@ -64,5 +54,3 @@ function supportedFlags(flags = '') {
|
|||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
const EMOJI_REGEX = emojiRegexPolyfill?.source ?? '\\p{RGI_Emoji}';
|
||||
|
|
|
|||
|
|
@ -1,23 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Avatar } from 'mastodon/components/avatar';
|
||||
import { useAppSelector } from 'mastodon/store';
|
||||
import { LinkedDisplayName } from '@/mastodon/components/display_name';
|
||||
|
||||
export const AuthorLink = ({ accountId }) => {
|
||||
const account = useAppSelector(state => state.getIn(['accounts', accountId]));
|
||||
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<LinkedDisplayName displayProps={{account}} className='story__details__shared__author-link'>
|
||||
<Avatar account={account} size={16} />
|
||||
</LinkedDisplayName>
|
||||
);
|
||||
};
|
||||
|
||||
AuthorLink.propTypes = {
|
||||
accountId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import type { FC } from 'react';
|
||||
|
||||
import { LinkedDisplayName } from '@/mastodon/components/display_name';
|
||||
import { Avatar } from 'mastodon/components/avatar';
|
||||
import { useAppSelector } from 'mastodon/store';
|
||||
|
||||
export const AuthorLink: FC<{ accountId: string }> = ({ accountId }) => {
|
||||
const account = useAppSelector((state) => state.accounts.get(accountId));
|
||||
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<LinkedDisplayName
|
||||
displayProps={{ account, variant: 'simple' }}
|
||||
className='story__details__shared__author-link'
|
||||
>
|
||||
<Avatar account={account} size={16} />
|
||||
</LinkedDisplayName>
|
||||
);
|
||||
};
|
||||
|
|
@ -13,7 +13,7 @@ import { changeSetting } from 'mastodon/actions/settings';
|
|||
import { connectPublicStream, connectCommunityStream } from 'mastodon/actions/streaming';
|
||||
import { expandPublicTimeline, expandCommunityTimeline } from 'mastodon/actions/timelines';
|
||||
import { DismissableBanner } from 'mastodon/components/dismissable_banner';
|
||||
import { domain } from 'mastodon/initial_state';
|
||||
import { localLiveFeedAccess, remoteLiveFeedAccess, me, domain } from 'mastodon/initial_state';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
import Column from '../../components/column';
|
||||
|
|
@ -165,19 +165,21 @@ const Firehose = ({ feedType, multiColumn }) => {
|
|||
<ColumnSettings />
|
||||
</ColumnHeader>
|
||||
|
||||
<div className='account__section-headline'>
|
||||
<NavLink exact to='/public/local'>
|
||||
<FormattedMessage tagName='div' id='firehose.local' defaultMessage='This server' />
|
||||
</NavLink>
|
||||
{(signedIn || (localLiveFeedAccess === 'public' && remoteLiveFeedAccess === 'public')) && (
|
||||
<div className='account__section-headline'>
|
||||
<NavLink exact to='/public/local'>
|
||||
<FormattedMessage tagName='div' id='firehose.local' defaultMessage='This server' />
|
||||
</NavLink>
|
||||
|
||||
<NavLink exact to='/public/remote'>
|
||||
<FormattedMessage tagName='div' id='firehose.remote' defaultMessage='Other servers' />
|
||||
</NavLink>
|
||||
<NavLink exact to='/public/remote'>
|
||||
<FormattedMessage tagName='div' id='firehose.remote' defaultMessage='Other servers' />
|
||||
</NavLink>
|
||||
|
||||
<NavLink exact to='/public'>
|
||||
<FormattedMessage tagName='div' id='firehose.all' defaultMessage='All' />
|
||||
</NavLink>
|
||||
</div>
|
||||
<NavLink exact to='/public'>
|
||||
<FormattedMessage tagName='div' id='firehose.all' defaultMessage='All' />
|
||||
</NavLink>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<StatusListContainer
|
||||
prepend={prependBanner}
|
||||
|
|
|
|||
|
|
@ -10,9 +10,10 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
|||
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
|
||||
import { Avatar } from '../../../components/avatar';
|
||||
import { DisplayName } from '../../../components/display_name';
|
||||
import { IconButton } from '../../../components/icon_button';
|
||||
import { Avatar } from '@/mastodon/components/avatar';
|
||||
import { DisplayName } from '@/mastodon/components/display_name';
|
||||
import { IconButton } from '@/mastodon/components/icon_button';
|
||||
import { EmojiHTML } from '@/mastodon/components/emoji/html';
|
||||
|
||||
const messages = defineMessages({
|
||||
authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' },
|
||||
|
|
@ -30,7 +31,6 @@ class AccountAuthorize extends ImmutablePureComponent {
|
|||
|
||||
render () {
|
||||
const { intl, account, onAuthorize, onReject } = this.props;
|
||||
const content = { __html: account.get('note_emojified') };
|
||||
|
||||
return (
|
||||
<div className='account-authorize__wrapper'>
|
||||
|
|
@ -40,7 +40,11 @@ class AccountAuthorize extends ImmutablePureComponent {
|
|||
<DisplayName account={account} />
|
||||
</Link>
|
||||
|
||||
<div className='account__header__content translate' dangerouslySetInnerHTML={content} />
|
||||
<EmojiHTML
|
||||
className='account__header__content translate'
|
||||
htmlString={account.get('note_emojified')}
|
||||
extraEmojis={account.get('emojis')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='account--panel'>
|
||||
|
|
|
|||
|
|
@ -16,15 +16,21 @@ import { expandHashtagTimeline, clearTimeline } from 'mastodon/actions/timelines
|
|||
import Column from 'mastodon/components/column';
|
||||
import ColumnHeader from 'mastodon/components/column_header';
|
||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
||||
import { remoteTopicFeedAccess, me } from 'mastodon/initial_state';
|
||||
|
||||
import StatusListContainer from '../ui/containers/status_list_container';
|
||||
|
||||
import { HashtagHeader } from './components/hashtag_header';
|
||||
import ColumnSettingsContainer from './containers/column_settings_container';
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}${props.params.local ? ':local' : ''}`, 'unread']) > 0,
|
||||
});
|
||||
const mapStateToProps = (state, props) => {
|
||||
const local = props.params.local || (!me && remoteTopicFeedAccess !== 'public');
|
||||
|
||||
return ({
|
||||
local,
|
||||
hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}${local ? ':local' : ''}`, 'unread']) > 0,
|
||||
});
|
||||
};
|
||||
|
||||
class HashtagTimeline extends PureComponent {
|
||||
disconnects = [];
|
||||
|
|
@ -113,16 +119,16 @@ class HashtagTimeline extends PureComponent {
|
|||
}
|
||||
|
||||
_unload () {
|
||||
const { dispatch } = this.props;
|
||||
const { id, local } = this.props.params;
|
||||
const { dispatch, local } = this.props;
|
||||
const { id } = this.props.params;
|
||||
|
||||
this._unsubscribe();
|
||||
dispatch(clearTimeline(`hashtag:${id}${local ? ':local' : ''}`));
|
||||
}
|
||||
|
||||
_load() {
|
||||
const { dispatch } = this.props;
|
||||
const { id, tags, local } = this.props.params;
|
||||
const { dispatch, local } = this.props;
|
||||
const { id, tags } = this.props.params;
|
||||
|
||||
this._subscribe(dispatch, id, tags, local);
|
||||
dispatch(expandHashtagTimeline(id, { tags, local }));
|
||||
|
|
@ -133,10 +139,10 @@ class HashtagTimeline extends PureComponent {
|
|||
}
|
||||
|
||||
componentDidUpdate (prevProps) {
|
||||
const { params } = this.props;
|
||||
const { id, tags, local } = prevProps.params;
|
||||
const { params, local } = this.props;
|
||||
const { id, tags } = prevProps.params;
|
||||
|
||||
if (id !== params.id || !isEqual(tags, params.tags) || !isEqual(local, params.local)) {
|
||||
if (id !== params.id || !isEqual(tags, params.tags) || !isEqual(local, prevProps.local)) {
|
||||
this._unload();
|
||||
this._load();
|
||||
}
|
||||
|
|
@ -151,15 +157,15 @@ class HashtagTimeline extends PureComponent {
|
|||
};
|
||||
|
||||
handleLoadMore = maxId => {
|
||||
const { dispatch, params } = this.props;
|
||||
const { id, tags, local } = params;
|
||||
const { dispatch, params, local } = this.props;
|
||||
const { id, tags } = params;
|
||||
|
||||
dispatch(expandHashtagTimeline(id, { maxId, tags, local }));
|
||||
};
|
||||
|
||||
render () {
|
||||
const { hasUnread, columnId, multiColumn } = this.props;
|
||||
const { id, local } = this.props.params;
|
||||
const { hasUnread, columnId, multiColumn, local } = this.props;
|
||||
const { id } = this.props.params;
|
||||
const pinned = !!columnId;
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,119 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { FormattedDate, FormattedMessage } from 'react-intl';
|
||||
|
||||
import type { ApiAnnouncementJSON } from '@/mastodon/api_types/announcements';
|
||||
import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context';
|
||||
import { EmojiHTML } from '@/mastodon/components/emoji/html';
|
||||
|
||||
import { ReactionsBar } from './reactions';
|
||||
|
||||
export interface IAnnouncement extends ApiAnnouncementJSON {
|
||||
contentHtml: string;
|
||||
}
|
||||
|
||||
interface AnnouncementProps {
|
||||
announcement: IAnnouncement;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
export const Announcement: FC<AnnouncementProps> = ({
|
||||
announcement,
|
||||
selected,
|
||||
}) => {
|
||||
const [unread, setUnread] = useState(!announcement.read);
|
||||
useEffect(() => {
|
||||
// Only update `unread` marker once the announcement is out of view
|
||||
if (!selected && unread !== !announcement.read) {
|
||||
setUnread(!announcement.read);
|
||||
}
|
||||
}, [announcement.read, selected, unread]);
|
||||
|
||||
return (
|
||||
<AnimateEmojiProvider className='announcements__item'>
|
||||
<strong className='announcements__item__range'>
|
||||
<FormattedMessage
|
||||
id='announcement.announcement'
|
||||
defaultMessage='Announcement'
|
||||
/>
|
||||
<span>
|
||||
{' · '}
|
||||
<Timestamp announcement={announcement} />
|
||||
</span>
|
||||
</strong>
|
||||
|
||||
<EmojiHTML
|
||||
className='announcements__item__content translate'
|
||||
htmlString={announcement.contentHtml}
|
||||
extraEmojis={announcement.emojis}
|
||||
/>
|
||||
|
||||
<ReactionsBar reactions={announcement.reactions} id={announcement.id} />
|
||||
|
||||
{unread && <span className='announcements__item__unread' />}
|
||||
</AnimateEmojiProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const Timestamp: FC<Pick<AnnouncementProps, 'announcement'>> = ({
|
||||
announcement,
|
||||
}) => {
|
||||
const startsAt = announcement.starts_at && new Date(announcement.starts_at);
|
||||
const endsAt = announcement.ends_at && new Date(announcement.ends_at);
|
||||
const now = new Date();
|
||||
const hasTimeRange = startsAt && endsAt;
|
||||
const skipTime = announcement.all_day;
|
||||
|
||||
if (hasTimeRange) {
|
||||
const skipYear =
|
||||
startsAt.getFullYear() === endsAt.getFullYear() &&
|
||||
endsAt.getFullYear() === now.getFullYear();
|
||||
const skipEndDate =
|
||||
startsAt.getDate() === endsAt.getDate() &&
|
||||
startsAt.getMonth() === endsAt.getMonth() &&
|
||||
startsAt.getFullYear() === endsAt.getFullYear();
|
||||
return (
|
||||
<>
|
||||
<FormattedDate
|
||||
value={startsAt}
|
||||
year={
|
||||
skipYear || startsAt.getFullYear() === now.getFullYear()
|
||||
? undefined
|
||||
: 'numeric'
|
||||
}
|
||||
month='short'
|
||||
day='2-digit'
|
||||
hour={skipTime ? undefined : '2-digit'}
|
||||
minute={skipTime ? undefined : '2-digit'}
|
||||
/>{' '}
|
||||
-{' '}
|
||||
<FormattedDate
|
||||
value={endsAt}
|
||||
year={
|
||||
skipYear || endsAt.getFullYear() === now.getFullYear()
|
||||
? undefined
|
||||
: 'numeric'
|
||||
}
|
||||
month={skipEndDate ? undefined : 'short'}
|
||||
day={skipEndDate ? undefined : '2-digit'}
|
||||
hour={skipTime ? undefined : '2-digit'}
|
||||
minute={skipTime ? undefined : '2-digit'}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
const publishedAt = new Date(announcement.published_at);
|
||||
return (
|
||||
<FormattedDate
|
||||
value={publishedAt}
|
||||
year={
|
||||
publishedAt.getFullYear() === now.getFullYear() ? undefined : 'numeric'
|
||||
}
|
||||
month='short'
|
||||
day='2-digit'
|
||||
hour={skipTime ? undefined : '2-digit'}
|
||||
minute={skipTime ? undefined : '2-digit'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
import { useCallback, useState } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import type { Map, List } from 'immutable';
|
||||
|
||||
import ReactSwipeableViews from 'react-swipeable-views';
|
||||
|
||||
import elephantUIPlane from '@/images/elephant_ui_plane.svg';
|
||||
import { CustomEmojiProvider } from '@/mastodon/components/emoji/context';
|
||||
import { IconButton } from '@/mastodon/components/icon_button';
|
||||
import LegacyAnnouncements from '@/mastodon/features/getting_started/containers/announcements_container';
|
||||
import { mascot, reduceMotion } from '@/mastodon/initial_state';
|
||||
import { createAppSelector, useAppSelector } from '@/mastodon/store';
|
||||
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
|
||||
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
|
||||
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
||||
|
||||
import type { IAnnouncement } from './announcement';
|
||||
import { Announcement } from './announcement';
|
||||
|
||||
const messages = defineMessages({
|
||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||
previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
|
||||
next: { id: 'lightbox.next', defaultMessage: 'Next' },
|
||||
});
|
||||
|
||||
const announcementSelector = createAppSelector(
|
||||
[(state) => state.announcements as Map<string, List<Map<string, unknown>>>],
|
||||
(announcements) =>
|
||||
(announcements.get('items')?.toJS() as IAnnouncement[] | undefined) ?? [],
|
||||
);
|
||||
|
||||
export const ModernAnnouncements: FC = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
const announcements = useAppSelector(announcementSelector);
|
||||
const emojis = useAppSelector((state) => state.custom_emojis);
|
||||
|
||||
const [index, setIndex] = useState(0);
|
||||
const handleChangeIndex = useCallback(
|
||||
(idx: number) => {
|
||||
setIndex(idx % announcements.length);
|
||||
},
|
||||
[announcements.length],
|
||||
);
|
||||
const handleNextIndex = useCallback(() => {
|
||||
setIndex((prevIndex) => (prevIndex + 1) % announcements.length);
|
||||
}, [announcements.length]);
|
||||
const handlePrevIndex = useCallback(() => {
|
||||
setIndex((prevIndex) =>
|
||||
prevIndex === 0 ? announcements.length - 1 : prevIndex - 1,
|
||||
);
|
||||
}, [announcements.length]);
|
||||
|
||||
if (announcements.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='announcements'>
|
||||
<img
|
||||
className='announcements__mastodon'
|
||||
alt=''
|
||||
draggable='false'
|
||||
src={mascot ?? elephantUIPlane}
|
||||
/>
|
||||
|
||||
<div className='announcements__container'>
|
||||
<CustomEmojiProvider emojis={emojis}>
|
||||
<ReactSwipeableViews
|
||||
animateHeight
|
||||
animateTransitions={!reduceMotion}
|
||||
index={index}
|
||||
onChangeIndex={handleChangeIndex}
|
||||
>
|
||||
{announcements
|
||||
.map((announcement, idx) => (
|
||||
<Announcement
|
||||
key={announcement.id}
|
||||
announcement={announcement}
|
||||
selected={index === idx}
|
||||
/>
|
||||
))
|
||||
.reverse()}
|
||||
</ReactSwipeableViews>
|
||||
</CustomEmojiProvider>
|
||||
|
||||
{announcements.length > 1 && (
|
||||
<div className='announcements__pagination'>
|
||||
<IconButton
|
||||
disabled={announcements.length === 1}
|
||||
title={intl.formatMessage(messages.previous)}
|
||||
icon='chevron-left'
|
||||
iconComponent={ChevronLeftIcon}
|
||||
onClick={handlePrevIndex}
|
||||
/>
|
||||
<span>
|
||||
{index + 1} / {announcements.length}
|
||||
</span>
|
||||
<IconButton
|
||||
disabled={announcements.length === 1}
|
||||
title={intl.formatMessage(messages.next)}
|
||||
icon='chevron-right'
|
||||
iconComponent={ChevronRightIcon}
|
||||
onClick={handleNextIndex}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Announcements = isModernEmojiEnabled()
|
||||
? ModernAnnouncements
|
||||
: LegacyAnnouncements;
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
import { useCallback, useMemo } from 'react';
|
||||
import type { FC, HTMLAttributes } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import type { AnimatedProps } from '@react-spring/web';
|
||||
import { animated, useTransition } from '@react-spring/web';
|
||||
|
||||
import { addReaction, removeReaction } from '@/mastodon/actions/announcements';
|
||||
import type { ApiAnnouncementReactionJSON } from '@/mastodon/api_types/announcements';
|
||||
import { AnimatedNumber } from '@/mastodon/components/animated_number';
|
||||
import { Emoji } from '@/mastodon/components/emoji';
|
||||
import { Icon } from '@/mastodon/components/icon';
|
||||
import EmojiPickerDropdown from '@/mastodon/features/compose/containers/emoji_picker_dropdown_container';
|
||||
import { isUnicodeEmoji } from '@/mastodon/features/emoji/utils';
|
||||
import { useAppDispatch } from '@/mastodon/store';
|
||||
import AddIcon from '@/material-icons/400-24px/add.svg?react';
|
||||
|
||||
export const ReactionsBar: FC<{
|
||||
reactions: ApiAnnouncementReactionJSON[];
|
||||
id: string;
|
||||
}> = ({ reactions, id }) => {
|
||||
const visibleReactions = useMemo(
|
||||
() => reactions.filter((x) => x.count > 0),
|
||||
[reactions],
|
||||
);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const handleEmojiPick = useCallback(
|
||||
(emoji: { native: string }) => {
|
||||
dispatch(addReaction(id, emoji.native.replaceAll(/:/g, '')));
|
||||
},
|
||||
[dispatch, id],
|
||||
);
|
||||
|
||||
const transitions = useTransition(visibleReactions, {
|
||||
from: {
|
||||
scale: 0,
|
||||
},
|
||||
enter: {
|
||||
scale: 1,
|
||||
},
|
||||
leave: {
|
||||
scale: 0,
|
||||
},
|
||||
keys: visibleReactions.map((x) => x.name),
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('reactions-bar', {
|
||||
'reactions-bar--empty': visibleReactions.length === 0,
|
||||
})}
|
||||
>
|
||||
{transitions(({ scale }, reaction) => (
|
||||
<Reaction
|
||||
key={reaction.name}
|
||||
reaction={reaction}
|
||||
style={{ transform: scale.to((s) => `scale(${s})`) }}
|
||||
id={id}
|
||||
/>
|
||||
))}
|
||||
|
||||
{visibleReactions.length < 8 && (
|
||||
<EmojiPickerDropdown
|
||||
onPickEmoji={handleEmojiPick}
|
||||
button={<Icon id='plus' icon={AddIcon} />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Reaction: FC<{
|
||||
reaction: ApiAnnouncementReactionJSON;
|
||||
id: string;
|
||||
style: AnimatedProps<HTMLAttributes<HTMLButtonElement>>['style'];
|
||||
}> = ({ id, reaction, style }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const handleClick = useCallback(() => {
|
||||
if (reaction.me) {
|
||||
dispatch(removeReaction(id, reaction.name));
|
||||
} else {
|
||||
dispatch(addReaction(id, reaction.name));
|
||||
}
|
||||
}, [dispatch, id, reaction.me, reaction.name]);
|
||||
|
||||
const code = isUnicodeEmoji(reaction.name)
|
||||
? reaction.name
|
||||
: `:${reaction.name}:`;
|
||||
|
||||
return (
|
||||
<animated.button
|
||||
className={classNames('reactions-bar__item', {
|
||||
active: reaction.me,
|
||||
})}
|
||||
onClick={handleClick}
|
||||
style={style}
|
||||
>
|
||||
<span className='reactions-bar__item__emoji'>
|
||||
<Emoji code={code} />
|
||||
</span>
|
||||
<span className='reactions-bar__item__count'>
|
||||
<AnimatedNumber value={reaction.count} />
|
||||
</span>
|
||||
</animated.button>
|
||||
);
|
||||
};
|
||||
|
|
@ -14,7 +14,6 @@ import { SymbolLogo } from 'mastodon/components/logo';
|
|||
import { fetchAnnouncements, toggleShowAnnouncements } from 'mastodon/actions/announcements';
|
||||
import { IconWithBadge } from 'mastodon/components/icon_with_badge';
|
||||
import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator';
|
||||
import AnnouncementsContainer from 'mastodon/features/getting_started/containers/announcements_container';
|
||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
||||
import { criticalUpdatesPending } from 'mastodon/initial_state';
|
||||
import { withBreakpoint } from 'mastodon/features/ui/hooks/useBreakpoint';
|
||||
|
|
@ -27,6 +26,7 @@ import StatusListContainer from '../ui/containers/status_list_container';
|
|||
|
||||
import { ColumnSettings } from './components/column_settings';
|
||||
import { CriticalUpdateBanner } from './components/critical_update_banner';
|
||||
import { Announcements } from './components/announcements';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'column.home', defaultMessage: 'Home' },
|
||||
|
|
@ -162,7 +162,7 @@ class HomeTimeline extends PureComponent {
|
|||
pinned={pinned}
|
||||
multiColumn={multiColumn}
|
||||
extraButton={announcementsButton}
|
||||
appendContent={hasAnnouncements && showAnnouncements && <AnnouncementsContainer />}
|
||||
appendContent={hasAnnouncements && showAnnouncements && <Announcements />}
|
||||
>
|
||||
<ColumnSettings />
|
||||
</ColumnHeader>
|
||||
|
|
|
|||
|
|
@ -35,7 +35,12 @@ import { Search } from 'mastodon/features/compose/components/search';
|
|||
import { ColumnLink } from 'mastodon/features/ui/components/column_link';
|
||||
import { useBreakpoint } from 'mastodon/features/ui/hooks/useBreakpoint';
|
||||
import { useIdentity } from 'mastodon/identity_context';
|
||||
import { timelinePreview, trendsEnabled, me } from 'mastodon/initial_state';
|
||||
import {
|
||||
localLiveFeedAccess,
|
||||
remoteLiveFeedAccess,
|
||||
trendsEnabled,
|
||||
me,
|
||||
} from 'mastodon/initial_state';
|
||||
import { transientSingleColumn } from 'mastodon/is_mobile';
|
||||
import { selectUnreadNotificationGroupsCount } from 'mastodon/selectors/notifications';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
|
@ -257,10 +262,16 @@ export const NavigationPanel: React.FC<{ multiColumn?: boolean }> = ({
|
|||
/>
|
||||
)}
|
||||
|
||||
{(signedIn || timelinePreview) && (
|
||||
{(signedIn ||
|
||||
localLiveFeedAccess === 'public' ||
|
||||
remoteLiveFeedAccess === 'public') && (
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/public/local'
|
||||
to={
|
||||
signedIn || localLiveFeedAccess === 'public'
|
||||
? '/public/local'
|
||||
: '/public/remote'
|
||||
}
|
||||
icon='globe'
|
||||
iconComponent={PublicIcon}
|
||||
isActive={isFirehoseActive}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { useHistory } from 'react-router-dom';
|
|||
|
||||
import type { List as ImmutableList, RecordOf } from 'immutable';
|
||||
|
||||
import type { ApiMentionJSON } from '@/mastodon/api_types/statuses';
|
||||
import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context';
|
||||
import BarChart4BarsIcon from '@/material-icons/400-24px/bar_chart_4_bars.svg?react';
|
||||
import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react';
|
||||
|
|
@ -18,7 +19,7 @@ import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
|||
|
||||
import { EmbeddedStatusContent } from './embedded_status_content';
|
||||
|
||||
export type Mention = RecordOf<{ url: string; acct: string }>;
|
||||
export type Mention = RecordOf<ApiMentionJSON>;
|
||||
|
||||
export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
|
||||
statusId,
|
||||
|
|
@ -86,12 +87,9 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
|
|||
}
|
||||
|
||||
// Assign status attributes to variables with a forced type, as status is not yet properly typed
|
||||
const contentHtml = status.get('contentHtml') as string;
|
||||
const contentWarning = status.get('spoilerHtml') as string;
|
||||
const hasContentWarning = !!status.get('spoiler_text');
|
||||
const poll = status.get('poll');
|
||||
const language = status.get('language') as string;
|
||||
const mentions = status.get('mentions') as ImmutableList<Mention>;
|
||||
const expanded = !status.get('hidden') || !contentWarning;
|
||||
const expanded = !status.get('hidden') || !hasContentWarning;
|
||||
const mediaAttachmentsSize = (
|
||||
status.get('media_attachments') as ImmutableList<unknown>
|
||||
).size;
|
||||
|
|
@ -109,20 +107,16 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
|
|||
<DisplayName account={account} />
|
||||
</div>
|
||||
|
||||
{contentWarning && (
|
||||
<ContentWarning
|
||||
text={contentWarning}
|
||||
onClick={handleContentWarningClick}
|
||||
expanded={expanded}
|
||||
/>
|
||||
)}
|
||||
<ContentWarning
|
||||
status={status}
|
||||
onClick={handleContentWarningClick}
|
||||
expanded={expanded}
|
||||
/>
|
||||
|
||||
{(!contentWarning || expanded) && (
|
||||
{(!hasContentWarning || expanded) && (
|
||||
<EmbeddedStatusContent
|
||||
className='notification-group__embedded-status__content reply-indicator__content translate'
|
||||
content={contentHtml}
|
||||
language={language}
|
||||
mentions={mentions}
|
||||
status={status}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
|
|
@ -6,16 +6,22 @@ import type { List } from 'immutable';
|
|||
|
||||
import type { History } from 'history';
|
||||
|
||||
import type { ApiMentionJSON } from '@/mastodon/api_types/statuses';
|
||||
import { EmojiHTML } from '@/mastodon/components/emoji/html';
|
||||
import { useElementHandledLink } from '@/mastodon/components/status/handled_link';
|
||||
import type { Status } from '@/mastodon/models/status';
|
||||
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
|
||||
|
||||
import type { Mention } from './embedded_status';
|
||||
|
||||
const handleMentionClick = (
|
||||
history: History,
|
||||
mention: Mention,
|
||||
mention: ApiMentionJSON,
|
||||
e: MouseEvent,
|
||||
) => {
|
||||
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
history.push(`/@${mention.get('acct')}`);
|
||||
history.push(`/@${mention.acct}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -31,16 +37,25 @@ const handleHashtagClick = (
|
|||
};
|
||||
|
||||
export const EmbeddedStatusContent: React.FC<{
|
||||
content: string;
|
||||
mentions: List<Mention>;
|
||||
language: string;
|
||||
status: Status;
|
||||
className?: string;
|
||||
}> = ({ content, mentions, language, className }) => {
|
||||
}> = ({ status, className }) => {
|
||||
const history = useHistory();
|
||||
|
||||
const mentions = useMemo(
|
||||
() => (status.get('mentions') as List<Mention>).toJS(),
|
||||
[status],
|
||||
);
|
||||
const htmlHandlers = useElementHandledLink({
|
||||
hashtagAccountId: status.get('account') as string | undefined,
|
||||
hrefToMention(href) {
|
||||
return mentions.find((item) => item.url === href);
|
||||
},
|
||||
});
|
||||
|
||||
const handleContentRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
if (!node) {
|
||||
if (!node || isModernEmojiEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -53,7 +68,7 @@ export const EmbeddedStatusContent: React.FC<{
|
|||
|
||||
link.classList.add('status-link');
|
||||
|
||||
const mention = mentions.find((item) => link.href === item.get('url'));
|
||||
const mention = mentions.find((item) => link.href === item.url);
|
||||
|
||||
if (mention) {
|
||||
link.addEventListener(
|
||||
|
|
@ -61,8 +76,8 @@ export const EmbeddedStatusContent: React.FC<{
|
|||
handleMentionClick.bind(null, history, mention),
|
||||
false,
|
||||
);
|
||||
link.setAttribute('title', `@${mention.get('acct')}`);
|
||||
link.setAttribute('href', `/@${mention.get('acct')}`);
|
||||
link.setAttribute('title', `@${mention.acct}`);
|
||||
link.setAttribute('href', `/@${mention.acct}`);
|
||||
} else if (
|
||||
link.textContent.startsWith('#') ||
|
||||
link.previousSibling?.textContent?.endsWith('#')
|
||||
|
|
@ -83,11 +98,12 @@ export const EmbeddedStatusContent: React.FC<{
|
|||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
<EmojiHTML
|
||||
{...htmlHandlers}
|
||||
className={className}
|
||||
ref={handleContentRef}
|
||||
lang={language}
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
lang={status.get('language') as string}
|
||||
htmlString={status.get('contentHtml') as string}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -394,17 +394,13 @@ export const DetailedStatus: React.FC<{
|
|||
/>
|
||||
)}
|
||||
|
||||
{status.get('spoiler_text').length > 0 &&
|
||||
(!matchedFilters || showDespiteFilter) && (
|
||||
<ContentWarning
|
||||
text={
|
||||
status.getIn(['translation', 'spoilerHtml']) ||
|
||||
status.get('spoilerHtml')
|
||||
}
|
||||
expanded={expanded}
|
||||
onClick={handleExpandedToggle}
|
||||
/>
|
||||
)}
|
||||
{(!matchedFilters || showDespiteFilter) && (
|
||||
<ContentWarning
|
||||
status={status}
|
||||
expanded={expanded}
|
||||
onClick={handleExpandedToggle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{expanded && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import { useIntl, defineMessages } from 'react-intl';
|
|||
import {
|
||||
fetchContext,
|
||||
completeContextRefresh,
|
||||
showPendingReplies,
|
||||
clearPendingReplies,
|
||||
} from 'mastodon/actions/statuses';
|
||||
import type { AsyncRefreshHeader } from 'mastodon/api';
|
||||
import { apiGetAsyncRefresh } from 'mastodon/api/async_refreshes';
|
||||
|
|
@ -34,13 +36,9 @@ const messages = defineMessages({
|
|||
id: 'status.context.loading',
|
||||
defaultMessage: 'Loading',
|
||||
},
|
||||
loadingMore: {
|
||||
id: 'status.context.loading_more',
|
||||
defaultMessage: 'Loading more replies',
|
||||
},
|
||||
success: {
|
||||
id: 'status.context.loading_success',
|
||||
defaultMessage: 'All replies loaded',
|
||||
defaultMessage: 'New replies loaded',
|
||||
},
|
||||
error: {
|
||||
id: 'status.context.loading_error',
|
||||
|
|
@ -52,80 +50,113 @@ const messages = defineMessages({
|
|||
},
|
||||
});
|
||||
|
||||
type LoadingState =
|
||||
| 'idle'
|
||||
| 'more-available'
|
||||
| 'loading-initial'
|
||||
| 'loading-more'
|
||||
| 'success'
|
||||
| 'error';
|
||||
type LoadingState = 'idle' | 'more-available' | 'loading' | 'success' | 'error';
|
||||
|
||||
export const RefreshController: React.FC<{
|
||||
statusId: string;
|
||||
}> = ({ statusId }) => {
|
||||
const refresh = useAppSelector(
|
||||
(state) => state.contexts.refreshing[statusId],
|
||||
);
|
||||
const currentReplyCount = useAppSelector(
|
||||
(state) => state.contexts.replies[statusId]?.length ?? 0,
|
||||
);
|
||||
const autoRefresh = !currentReplyCount;
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
const [loadingState, setLoadingState] = useState<LoadingState>(
|
||||
refresh && autoRefresh ? 'loading-initial' : 'idle',
|
||||
const refreshHeader = useAppSelector(
|
||||
(state) => state.contexts.refreshing[statusId],
|
||||
);
|
||||
const hasPendingReplies = useAppSelector(
|
||||
(state) => !!state.contexts.pendingReplies[statusId]?.length,
|
||||
);
|
||||
const [partialLoadingState, setLoadingState] = useState<LoadingState>(
|
||||
refreshHeader ? 'loading' : 'idle',
|
||||
);
|
||||
const loadingState = hasPendingReplies
|
||||
? 'more-available'
|
||||
: partialLoadingState;
|
||||
|
||||
const [wasDismissed, setWasDismissed] = useState(false);
|
||||
const dismissPrompt = useCallback(() => {
|
||||
setWasDismissed(true);
|
||||
setLoadingState('idle');
|
||||
}, []);
|
||||
dispatch(clearPendingReplies({ statusId }));
|
||||
}, [dispatch, statusId]);
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
|
||||
const scheduleRefresh = (refresh: AsyncRefreshHeader) => {
|
||||
const scheduleRefresh = (
|
||||
refresh: AsyncRefreshHeader,
|
||||
iteration: number,
|
||||
) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
void apiGetAsyncRefresh(refresh.id).then((result) => {
|
||||
if (result.async_refresh.status === 'finished') {
|
||||
dispatch(completeContextRefresh({ statusId }));
|
||||
// At three scheduled refreshes, we consider the job
|
||||
// long-running and attempt to fetch any new replies so far
|
||||
const isLongRunning = iteration === 3;
|
||||
|
||||
if (result.async_refresh.result_count > 0) {
|
||||
if (autoRefresh) {
|
||||
void dispatch(fetchContext({ statusId })).then(() => {
|
||||
setLoadingState('idle');
|
||||
});
|
||||
} else {
|
||||
setLoadingState('more-available');
|
||||
}
|
||||
} else {
|
||||
setLoadingState('idle');
|
||||
}
|
||||
} else {
|
||||
scheduleRefresh(refresh);
|
||||
const { status, result_count } = result.async_refresh;
|
||||
|
||||
// If the refresh status is not finished and not long-running,
|
||||
// we just schedule another refresh and exit
|
||||
if (status === 'running' && !isLongRunning) {
|
||||
scheduleRefresh(refresh, iteration + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
// If refresh status is finished, clear `refreshHeader`
|
||||
// (we don't want to do this if it's just a long-running job)
|
||||
if (status === 'finished') {
|
||||
dispatch(completeContextRefresh({ statusId }));
|
||||
}
|
||||
|
||||
// Exit if there's nothing to fetch
|
||||
if (result_count === 0) {
|
||||
if (status === 'finished') {
|
||||
setLoadingState('idle');
|
||||
} else {
|
||||
scheduleRefresh(refresh, iteration + 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// A positive result count means there _might_ be new replies,
|
||||
// so we fetch the context in the background to check if there
|
||||
// are any new replies.
|
||||
// If so, they will populate `contexts.pendingReplies[statusId]`
|
||||
void dispatch(fetchContext({ statusId, prefetchOnly: true }))
|
||||
.then(() => {
|
||||
// Reset loading state to `idle`. If the fetch has
|
||||
// resulted in new pending replies, the `hasPendingReplies`
|
||||
// flag will switch the loading state to 'more-available'
|
||||
if (status === 'finished') {
|
||||
setLoadingState('idle');
|
||||
} else {
|
||||
// Keep background fetch going if `isLongRunning` is true
|
||||
scheduleRefresh(refresh, iteration + 1);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Show an error if the fetch failed
|
||||
setLoadingState('error');
|
||||
});
|
||||
});
|
||||
}, refresh.retry * 1000);
|
||||
};
|
||||
|
||||
if (refresh && !wasDismissed) {
|
||||
scheduleRefresh(refresh);
|
||||
setLoadingState('loading-initial');
|
||||
// Initialise a refresh
|
||||
if (refreshHeader && !wasDismissed) {
|
||||
scheduleRefresh(refreshHeader, 1);
|
||||
setLoadingState('loading');
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [dispatch, statusId, refresh, autoRefresh, wasDismissed]);
|
||||
}, [dispatch, statusId, refreshHeader, wasDismissed]);
|
||||
|
||||
useEffect(() => {
|
||||
// Hide success message after a short delay
|
||||
if (loadingState === 'success') {
|
||||
const timeoutId = setTimeout(() => {
|
||||
setLoadingState('idle');
|
||||
}, 3000);
|
||||
}, 2500);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
|
|
@ -134,20 +165,19 @@ export const RefreshController: React.FC<{
|
|||
return () => '';
|
||||
}, [loadingState]);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
setLoadingState('loading-more');
|
||||
|
||||
dispatch(fetchContext({ statusId }))
|
||||
.then(() => {
|
||||
setLoadingState('success');
|
||||
return '';
|
||||
})
|
||||
.catch(() => {
|
||||
setLoadingState('error');
|
||||
});
|
||||
useEffect(() => {
|
||||
// Clear pending replies on unmount
|
||||
return () => {
|
||||
dispatch(clearPendingReplies({ statusId }));
|
||||
};
|
||||
}, [dispatch, statusId]);
|
||||
|
||||
if (loadingState === 'loading-initial') {
|
||||
const handleClick = useCallback(() => {
|
||||
dispatch(showPendingReplies({ statusId }));
|
||||
setLoadingState('success');
|
||||
}, [dispatch, statusId]);
|
||||
|
||||
if (loadingState === 'loading') {
|
||||
return (
|
||||
<div
|
||||
className='load-more load-gap'
|
||||
|
|
@ -170,13 +200,6 @@ export const RefreshController: React.FC<{
|
|||
onDismiss={dismissPrompt}
|
||||
animateFrom='below'
|
||||
/>
|
||||
<AnimatedAlert
|
||||
isLoading
|
||||
withEntryDelay
|
||||
isActive={loadingState === 'loading-more'}
|
||||
message={intl.formatMessage(messages.loadingMore)}
|
||||
animateFrom='below'
|
||||
/>
|
||||
<AnimatedAlert
|
||||
withEntryDelay
|
||||
isActive={loadingState === 'error'}
|
||||
|
|
|
|||
|
|
@ -603,7 +603,7 @@ class Status extends ImmutablePureComponent {
|
|||
/>
|
||||
|
||||
<ScrollContainer scrollKey='thread' shouldUpdateScroll={this.shouldUpdateScroll} childRef={this.setContainerRef}>
|
||||
<div className={classNames('scrollable item-list', { fullscreen })} ref={this.setContainerRef}>
|
||||
<div className={classNames('item-list scrollable scrollable--flex', { fullscreen })} ref={this.setContainerRef}>
|
||||
{ancestors}
|
||||
|
||||
<Hotkeys handlers={handlers}>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import InlineAccount from 'mastodon/components/inline_account';
|
|||
import MediaAttachments from 'mastodon/components/media_attachments';
|
||||
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
|
||||
import emojify from 'mastodon/features/emoji/emoji';
|
||||
import { EmojiHTML } from '@/mastodon/components/emoji/html';
|
||||
import { CustomEmojiProvider } from '@/mastodon/components/emoji/context';
|
||||
|
||||
const mapStateToProps = (state, { statusId }) => ({
|
||||
language: state.getIn(['statuses', statusId, 'language']),
|
||||
|
|
@ -51,8 +53,8 @@ class CompareHistoryModal extends PureComponent {
|
|||
return obj;
|
||||
}, {});
|
||||
|
||||
const content = { __html: emojify(currentVersion.get('content'), emojiMap) };
|
||||
const spoilerContent = { __html: emojify(escapeTextContentForBrowser(currentVersion.get('spoiler_text')), emojiMap) };
|
||||
const content = emojify(currentVersion.get('content'), emojiMap);
|
||||
const spoilerContent = emojify(escapeTextContentForBrowser(currentVersion.get('spoiler_text')), emojiMap);
|
||||
|
||||
const formattedDate = <RelativeTimestamp timestamp={currentVersion.get('created_at')} short={false} />;
|
||||
const formattedName = <InlineAccount accountId={currentVersion.get('account')} />;
|
||||
|
|
@ -65,43 +67,52 @@ class CompareHistoryModal extends PureComponent {
|
|||
|
||||
return (
|
||||
<div className='modal-root__modal compare-history-modal'>
|
||||
<div className='report-modal__target'>
|
||||
<IconButton className='report-modal__close' icon='times' iconComponent={CloseIcon} onClick={onClose} size={20} />
|
||||
{label}
|
||||
</div>
|
||||
|
||||
<div className='compare-history-modal__container'>
|
||||
<div className='status__content'>
|
||||
{currentVersion.get('spoiler_text').length > 0 && (
|
||||
<>
|
||||
<div className='translate' dangerouslySetInnerHTML={spoilerContent} lang={language} />
|
||||
<hr />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className='status__content__text status__content__text--visible translate' dangerouslySetInnerHTML={content} lang={language} />
|
||||
|
||||
{!!currentVersion.get('poll') && (
|
||||
<div className='poll'>
|
||||
<ul>
|
||||
{currentVersion.getIn(['poll', 'options']).map(option => (
|
||||
<li key={option.get('title')}>
|
||||
<span className='poll__input disabled' />
|
||||
|
||||
<span
|
||||
className='poll__option__text translate'
|
||||
dangerouslySetInnerHTML={{ __html: emojify(escapeTextContentForBrowser(option.get('title')), emojiMap) }}
|
||||
lang={language}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MediaAttachments status={currentVersion} lang={language} />
|
||||
<CustomEmojiProvider emojis={currentVersion.get('emojis')}>
|
||||
<div className='report-modal__target'>
|
||||
<IconButton className='report-modal__close' icon='times' iconComponent={CloseIcon} onClick={onClose} size={20} />
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='compare-history-modal__container'>
|
||||
<div className='status__content'>
|
||||
{currentVersion.get('spoiler_text').length > 0 && (
|
||||
<>
|
||||
<EmojiHTML className='translate' htmlString={spoilerContent} lang={language} />
|
||||
<hr />
|
||||
</>
|
||||
)}
|
||||
|
||||
<EmojiHTML
|
||||
className='status__content__text status__content__text--visible translate'
|
||||
htmlString={content}
|
||||
lang={language}
|
||||
/>
|
||||
|
||||
{!!currentVersion.get('poll') && (
|
||||
<div className='poll'>
|
||||
<ul>
|
||||
{currentVersion.getIn(['poll', 'options']).map(option => (
|
||||
<li key={option.get('title')}>
|
||||
<label className='poll__option editable'>
|
||||
{/* FIXME: does not support multiple choice, #35632 */}
|
||||
<span className='poll__input' />
|
||||
<EmojiHTML
|
||||
as="span"
|
||||
className='poll__option__text translate'
|
||||
htmlString={emojify(escapeTextContentForBrowser(option.get('title')), emojiMap)}
|
||||
lang={language}
|
||||
/>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MediaAttachments status={currentVersion} lang={language} />
|
||||
</div>
|
||||
</div>
|
||||
</CustomEmojiProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import type {
|
|||
} from 'react-overlays/esm/usePopper';
|
||||
|
||||
import { DropdownMenu } from 'mastodon/components/dropdown_menu';
|
||||
import { useIdentity } from 'mastodon/identity_context';
|
||||
import type { MenuItem } from 'mastodon/models/dropdown_menu';
|
||||
import { useAppSelector } from 'mastodon/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
|
|
@ -45,6 +47,7 @@ interface TargetParams {
|
|||
|
||||
export const HashtagMenuController: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const { signedIn } = useIdentity();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [{ accountId, hashtag }, setTargetParams] = useState<TargetParams>({});
|
||||
const targetRef = useRef<HTMLAnchorElement | null>(null);
|
||||
|
|
@ -96,8 +99,8 @@ export const HashtagMenuController: React.FC = () => {
|
|||
targetRef.current = null;
|
||||
}, [setOpen]);
|
||||
|
||||
const menu = useMemo(
|
||||
() => [
|
||||
const menu = useMemo(() => {
|
||||
const arr: MenuItem[] = [
|
||||
{
|
||||
text: intl.formatMessage(messages.browseHashtag, {
|
||||
hashtag,
|
||||
|
|
@ -111,17 +114,20 @@ export const HashtagMenuController: React.FC = () => {
|
|||
}),
|
||||
to: `/@${account?.acct}/tagged/${hashtag}`,
|
||||
},
|
||||
null,
|
||||
{
|
||||
];
|
||||
|
||||
if (signedIn) {
|
||||
arr.push(null, {
|
||||
text: intl.formatMessage(messages.muteHashtag, {
|
||||
hashtag,
|
||||
}),
|
||||
href: '/filters',
|
||||
dangerous: true,
|
||||
},
|
||||
],
|
||||
[intl, hashtag, account],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return arr;
|
||||
}, [intl, hashtag, account, signedIn]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import { isFulfilled, isRejected } from '@reduxjs/toolkit';
|
|||
import { openURL } from 'mastodon/actions/search';
|
||||
import { useAppDispatch } from 'mastodon/store';
|
||||
|
||||
import { isModernEmojiEnabled } from '../utils/environment';
|
||||
|
||||
const isMentionClick = (element: HTMLAnchorElement) =>
|
||||
element.classList.contains('mention') &&
|
||||
!element.classList.contains('hashtag');
|
||||
|
|
@ -53,6 +55,11 @@ export const useLinks = (skipHashtags?: boolean) => {
|
|||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// Exit early if modern emoji is enabled, as this is handled by HandledLink.
|
||||
if (isModernEmojiEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = (e.target as HTMLElement).closest('a');
|
||||
|
||||
if (!target || e.button !== 0 || e.ctrlKey || e.metaKey) {
|
||||
|
|
|
|||
|
|
@ -32,7 +32,10 @@ interface InitialStateMeta {
|
|||
single_user_mode: boolean;
|
||||
source_url: string;
|
||||
streaming_api_base_url: string;
|
||||
timeline_preview: boolean;
|
||||
local_live_feed_access: 'public' | 'authenticated';
|
||||
remote_live_feed_access: 'public' | 'authenticated';
|
||||
local_topic_feed_access: 'public' | 'authenticated';
|
||||
remote_topic_feed_access: 'public' | 'authenticated';
|
||||
title: string;
|
||||
show_trends: boolean;
|
||||
trends_as_landing_page: boolean;
|
||||
|
|
@ -110,7 +113,10 @@ export const trendsEnabled = getMeta('trends_enabled');
|
|||
export const showTrends = getMeta('show_trends');
|
||||
export const singleUserMode = getMeta('single_user_mode');
|
||||
export const source_url = getMeta('source_url');
|
||||
export const timelinePreview = getMeta('timeline_preview');
|
||||
export const localLiveFeedAccess = getMeta('local_live_feed_access');
|
||||
export const remoteLiveFeedAccess = getMeta('remote_live_feed_access');
|
||||
export const localTopicFeedAccess = getMeta('local_topic_feed_access');
|
||||
export const remoteTopicFeedAccess = getMeta('remote_topic_feed_access');
|
||||
export const title = getMeta('title');
|
||||
export const trendsAsLanding = getMeta('trends_as_landing_page');
|
||||
export const useBlurhash = getMeta('use_blurhash');
|
||||
|
|
|
|||
|
|
@ -257,7 +257,12 @@
|
|||
"confirmations.revoke_quote.confirm": "Выдаліць допіс",
|
||||
"confirmations.revoke_quote.message": "Гэтае дзеянне немагчыма адмяніць.",
|
||||
"confirmations.revoke_quote.title": "Выдаліць допіс?",
|
||||
"confirmations.unblock.confirm": "Разблакіраваць",
|
||||
"confirmations.unblock.title": "Разблакіраваць {name}?",
|
||||
"confirmations.unfollow.confirm": "Адпісацца",
|
||||
"confirmations.unfollow.title": "Адпісацца ад {name}?",
|
||||
"confirmations.withdraw_request.confirm": "Адклікаць запыт",
|
||||
"confirmations.withdraw_request.title": "Адклікаць запыт на падпіску на {name}?",
|
||||
"content_warning.hide": "Схаваць допіс",
|
||||
"content_warning.show": "Усё адно паказаць",
|
||||
"content_warning.show_more": "Паказаць усё роўна",
|
||||
|
|
@ -748,6 +753,7 @@
|
|||
"privacy.unlisted.short": "Ціхі публічны",
|
||||
"privacy_policy.last_updated": "Адноўлена {date}",
|
||||
"privacy_policy.title": "Палітыка канфідэнцыйнасці",
|
||||
"quote_error.edit": "Нельга дадаваць цытаты пры рэдагаванні допісаў.",
|
||||
"quote_error.poll": "Нельга цытаваць з апытаннямі.",
|
||||
"quote_error.quote": "За раз дазволена рабіць толькі адну цытату.",
|
||||
"quote_error.unauthorized": "Вы не ўвайшлі, каб цытаваць гэты допіс.",
|
||||
|
|
@ -870,7 +876,6 @@
|
|||
"status.contains_quote": "Утрымлівае цытату",
|
||||
"status.context.loading": "Загружаюцца іншыя адказы",
|
||||
"status.context.loading_error": "Немагчыма загрузіць новыя адказы",
|
||||
"status.context.loading_more": "Загружаюцца іншыя адказы",
|
||||
"status.context.loading_success": "Усе адказы загружаныя",
|
||||
"status.context.more_replies_found": "Знойдзеныя іншыя адказы",
|
||||
"status.context.retry": "Паспрабаваць зноў",
|
||||
|
|
@ -918,6 +923,8 @@
|
|||
"status.quote_private": "Прыватныя допісы нельга цытаваць",
|
||||
"status.quotes": "{count, plural,one {цытата} few {цытаты} other {цытат}}",
|
||||
"status.quotes.empty": "Яшчэ ніхто не цытаваў гэты допіс. Калі гэта адбудзецца, то Вы пабачыце гэта тут.",
|
||||
"status.quotes.local_other_disclaimer": "Цытаты, у якіх адмовіў аўтар, паказаныя не будуць.",
|
||||
"status.quotes.remote_other_disclaimer": "Толькі цытаты з {domain} тут будуць гарантавана паказаныя. Цытаты, у якіх адмовіў аўтар, паказаныя не будуць.",
|
||||
"status.read_more": "Чытаць болей",
|
||||
"status.reblog": "Пашырыць",
|
||||
"status.reblog_or_quote": "Пашырыць ці цытаваць",
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@
|
|||
"account.disable_notifications": "Paouez d'am c'hemenn pa vez embannet traoù gant @{name}",
|
||||
"account.domain_blocking": "Domani stanket",
|
||||
"account.edit_profile": "Kemmañ ar profil",
|
||||
"account.edit_profile_short": "Kemmañ",
|
||||
"account.enable_notifications": "Ma c'hemenn pa vez embannet traoù gant @{name}",
|
||||
"account.endorse": "Lakaat en a-raok war ar profil",
|
||||
"account.familiar_followers_one": "Heuliet gant {name1}",
|
||||
|
|
@ -39,6 +40,10 @@
|
|||
"account.featured_tags.last_status_never": "Embann ebet",
|
||||
"account.follow": "Heuliañ",
|
||||
"account.follow_back": "Heuliañ d'ho tro",
|
||||
"account.follow_back_short": "Heuliañ d'ho tro",
|
||||
"account.follow_request": "Reked d'ho heuliañ",
|
||||
"account.follow_request_cancel": "Nullañ ar reked",
|
||||
"account.follow_request_cancel_short": "Nullañ",
|
||||
"account.followers": "Tud koumanantet",
|
||||
"account.followers.empty": "Den na heul an implijer·ez-mañ c'hoazh.",
|
||||
"account.followers_counter": "{count, plural, one {{counter} heulier} two {{counter} heulier} few {{counter} heulier} many {{counter} heulier} other {{counter} heulier}}",
|
||||
|
|
@ -210,13 +215,19 @@
|
|||
"confirmations.missing_alt_text.secondary": "Embann memes tra",
|
||||
"confirmations.missing_alt_text.title": "Ouzhpennañ an eiltestenn?",
|
||||
"confirmations.mute.confirm": "Kuzhat",
|
||||
"confirmations.quiet_post_quote_info.got_it": "Mat eo",
|
||||
"confirmations.redraft.confirm": "Diverkañ ha skrivañ en-dro",
|
||||
"confirmations.redraft.title": "Diverkañ ha skrivañ an embann en-dro?",
|
||||
"confirmations.remove_from_followers.confirm": "Dilemel an heulier·ez",
|
||||
"confirmations.remove_from_followers.title": "Dilemel an heulier·ez?",
|
||||
"confirmations.revoke_quote.confirm": "Dilemel an embannadur",
|
||||
"confirmations.revoke_quote.title": "Dilemel an embannadur?",
|
||||
"confirmations.unblock.confirm": "Distankañ",
|
||||
"confirmations.unblock.title": "Distankañ {name}?",
|
||||
"confirmations.unfollow.confirm": "Diheuliañ",
|
||||
"confirmations.unfollow.title": "Diheuliañ {name}?",
|
||||
"confirmations.withdraw_request.confirm": "Nullañ ar reked",
|
||||
"confirmations.withdraw_request.title": "Nullañ ho reked da heuliañ {name}?",
|
||||
"content_warning.hide": "Kuzhat an embannadur",
|
||||
"content_warning.show": "Diskwel memes tra",
|
||||
"content_warning.show_more": "Diskouez muioc'h",
|
||||
|
|
@ -239,6 +250,7 @@
|
|||
"domain_block_modal.block_account_instead": "Stankañ @{name} kentoc'h",
|
||||
"domain_block_modal.title": "Stankañ an domani?",
|
||||
"domain_pill.server": "Dafariad",
|
||||
"domain_pill.their_handle": "H·ec'h anaouder:",
|
||||
"domain_pill.username": "Anv-implijer",
|
||||
"domain_pill.whats_in_a_handle": "Petra eo an anaouder?",
|
||||
"domain_pill.your_handle": "Hoc'h anaouder:",
|
||||
|
|
@ -260,6 +272,7 @@
|
|||
"emoji_button.search_results": "Disoc'hoù an enklask",
|
||||
"emoji_button.symbols": "Arouezioù",
|
||||
"emoji_button.travel": "Beajiñ & Lec'hioù",
|
||||
"empty_column.account_featured_other.unknown": "N'eo ket bet lakaet netra en a-raok gant ar gont-mañ.",
|
||||
"empty_column.account_suspended": "Kont astalet",
|
||||
"empty_column.account_timeline": "Embannadur ebet amañ!",
|
||||
"empty_column.account_unavailable": "Profil dihegerz",
|
||||
|
|
@ -486,6 +499,7 @@
|
|||
"notifications.column_settings.admin.sign_up": "Enskrivadurioù nevez :",
|
||||
"notifications.column_settings.alert": "Kemennoù war ar burev",
|
||||
"notifications.column_settings.favourite": "Muiañ-karet:",
|
||||
"notifications.column_settings.filter_bar.advanced": "Diskouez an holl rummadoù",
|
||||
"notifications.column_settings.follow": "Heulierien nevez:",
|
||||
"notifications.column_settings.follow_request": "Rekedoù heuliañ nevez:",
|
||||
"notifications.column_settings.group": "Strollañ",
|
||||
|
|
@ -511,7 +525,7 @@
|
|||
"notifications.group": "{count} a gemennoù",
|
||||
"notifications.mark_as_read": "Merkañ an holl kemennoù evel bezañ lennet",
|
||||
"notifications.permission_denied": "Kemennoù war ar burev n'int ket hegerz rak pedadenn aotren ar merdeer a zo bet nullet araok",
|
||||
"notifications.permission_denied_alert": "Kemennoù wa ar burev na c'hellont ket bezañ lezelet, rak aotre ar merdeer a zo bet nac'het a-raok",
|
||||
"notifications.permission_denied_alert": "Kemennoù war ar burev na c'hellont ket bezañ lezelet, rak aotre ar merdeer a zo bet nac'het a-raok",
|
||||
"notifications.permission_required": "Kemennoù war ar burev n'int ket hegerz abalamour d'an aotre rekis n'eo ket bet roet.",
|
||||
"notifications.policy.accept": "Asantiñ",
|
||||
"notifications.policy.accept_hint": "Diskouez er c’hemennoù",
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@
|
|||
"account.disable_notifications": "Deixa de notificar-me els tuts de @{name}",
|
||||
"account.domain_blocking": "Bloquem el domini",
|
||||
"account.edit_profile": "Edita el perfil",
|
||||
"account.edit_profile_short": "Edita",
|
||||
"account.enable_notifications": "Notifica'm els tuts de @{name}",
|
||||
"account.endorse": "Recomana en el perfil",
|
||||
"account.familiar_followers_many": "Seguit per {name1}, {name2} i {othersCount, plural, one {# altre compte} other {# altres comptes}} que coneixeu",
|
||||
|
|
@ -40,6 +41,11 @@
|
|||
"account.featured_tags.last_status_never": "No hi ha tuts",
|
||||
"account.follow": "Segueix",
|
||||
"account.follow_back": "Segueix tu també",
|
||||
"account.follow_back_short": "Segueix tu també",
|
||||
"account.follow_request": "Sol·licita seguir",
|
||||
"account.follow_request_cancel": "Cancel·la la petició",
|
||||
"account.follow_request_cancel_short": "Cancel·la",
|
||||
"account.follow_request_short": "Petició",
|
||||
"account.followers": "Seguidors",
|
||||
"account.followers.empty": "A aquest usuari encara no el segueix ningú.",
|
||||
"account.followers_counter": "{count, plural, one {{counter} seguidor} other {{counter} seguidors}}",
|
||||
|
|
@ -238,6 +244,8 @@
|
|||
"confirmations.missing_alt_text.secondary": "Publica-la igualment",
|
||||
"confirmations.missing_alt_text.title": "Hi voleu afegir text alternatiu?",
|
||||
"confirmations.mute.confirm": "Silencia",
|
||||
"confirmations.quiet_post_quote_info.dismiss": "No m'ho tornis a recordar",
|
||||
"confirmations.quiet_post_quote_info.got_it": "Entesos",
|
||||
"confirmations.redraft.confirm": "Esborra i reescriu",
|
||||
"confirmations.redraft.message": "Segur que vols eliminar aquest tut i tornar a escriure'l? Es perdran tots els impulsos i els favorits, i les respostes al tut original quedaran aïllades.",
|
||||
"confirmations.redraft.title": "Esborrar i reescriure la publicació?",
|
||||
|
|
@ -247,7 +255,12 @@
|
|||
"confirmations.revoke_quote.confirm": "Eliminar la publicació",
|
||||
"confirmations.revoke_quote.message": "Aquesta acció no es pot desfer.",
|
||||
"confirmations.revoke_quote.title": "Eliminar la publicació?",
|
||||
"confirmations.unblock.confirm": "Desbloca",
|
||||
"confirmations.unblock.title": "Desblocar {name}?",
|
||||
"confirmations.unfollow.confirm": "Deixa de seguir",
|
||||
"confirmations.unfollow.title": "Deixar de seguir {name}?",
|
||||
"confirmations.withdraw_request.confirm": "Retirar la sol·licitud",
|
||||
"confirmations.withdraw_request.title": "Retirar la sol·licitud de seguir {name}?",
|
||||
"content_warning.hide": "Amaga la publicació",
|
||||
"content_warning.show": "Mostra-la igualment",
|
||||
"content_warning.show_more": "Mostra'n més",
|
||||
|
|
@ -445,10 +458,12 @@
|
|||
"ignore_notifications_modal.not_following_title": "Voleu ignorar les notificacions de qui no seguiu?",
|
||||
"ignore_notifications_modal.private_mentions_title": "Voleu ignorar les notificacions de mencions privades no sol·licitades?",
|
||||
"info_button.label": "Ajuda",
|
||||
"interaction_modal.action": "Per a interactuar amb la publicació de {name} cal que inicieu la sessió en el servidor que feu servir.",
|
||||
"interaction_modal.go": "Endavant",
|
||||
"interaction_modal.no_account_yet": "Encara no teniu cap compte?",
|
||||
"interaction_modal.on_another_server": "A un altre servidor",
|
||||
"interaction_modal.on_this_server": "En aquest servidor",
|
||||
"interaction_modal.title": "Inicieu la sessió per a continuar",
|
||||
"interaction_modal.username_prompt": "P. ex. {example}",
|
||||
"intervals.full.days": "{number, plural, one {# dia} other {# dies}}",
|
||||
"intervals.full.hours": "{number, plural, one {# hora} other {# hores}}",
|
||||
|
|
@ -726,10 +741,18 @@
|
|||
"privacy.private.short": "Seguidors",
|
||||
"privacy.public.long": "Tothom dins o fora Mastodon",
|
||||
"privacy.public.short": "Públic",
|
||||
"privacy.quote.anyone": "{visibility}, qualsevol pot citar",
|
||||
"privacy.quote.disabled": "{visibility}, cites desactivades",
|
||||
"privacy.quote.limited": "{visibility}, cites limitades",
|
||||
"privacy.unlisted.additional": "Es comporta igual que públic, excepte que la publicació no apareixerà als canals en directe o etiquetes, l'explora o a la cerca de Mastodon, fins i tot si ho heu activat a nivell de compte.",
|
||||
"privacy.unlisted.long": "Amagat dels resultats de cerca de Mastodon, de les tendències i de les línies temporals",
|
||||
"privacy.unlisted.short": "Públic silenciós",
|
||||
"privacy_policy.last_updated": "Darrera actualització {date}",
|
||||
"privacy_policy.title": "Política de Privacitat",
|
||||
"quote_error.poll": "Amb les enquestes no es permeten cites.",
|
||||
"quote_error.quote": "Només es permet una cita alhora.",
|
||||
"quote_error.unauthorized": "No se us permet de citar aquesta publicació.",
|
||||
"quote_error.upload": "Amb media adjunts no es permeten cites.",
|
||||
"recommended": "Recomanat",
|
||||
"refresh": "Actualitza",
|
||||
"regeneration_indicator.please_stand_by": "Espereu.",
|
||||
|
|
@ -745,6 +768,9 @@
|
|||
"relative_time.minutes": "{number}min",
|
||||
"relative_time.seconds": "{number}s",
|
||||
"relative_time.today": "avui",
|
||||
"remove_quote_hint.button_label": "Entesos",
|
||||
"remove_quote_hint.message": "Ho podeu fer des de {icon} al menú d'opcions.",
|
||||
"remove_quote_hint.title": "Voleu eliminar la vostra publicació citada?",
|
||||
"reply_indicator.attachments": "{count, plural, one {# adjunt} other {# adjunts}}",
|
||||
"reply_indicator.cancel": "Cancel·la",
|
||||
"reply_indicator.poll": "Enquesta",
|
||||
|
|
@ -840,7 +866,15 @@
|
|||
"status.block": "Bloca @{name}",
|
||||
"status.bookmark": "Marca",
|
||||
"status.cancel_reblog_private": "Desfés l'impuls",
|
||||
"status.cannot_quote": "No se't permet de citar aquesta publicació",
|
||||
"status.cannot_reblog": "No es pot impulsar aquest tut",
|
||||
"status.contains_quote": "Conté una cita",
|
||||
"status.context.loading": "Es carreguen més respostes",
|
||||
"status.context.loading_error": "No s'han pogut carregar respostes noves",
|
||||
"status.context.loading_success": "S'han carregat totes les respostes",
|
||||
"status.context.more_replies_found": "S'han trobat més respostes",
|
||||
"status.context.retry": "Torna-ho a provar",
|
||||
"status.context.show": "Mostra",
|
||||
"status.continued_thread": "Continuació del fil",
|
||||
"status.copy": "Copia l'enllaç al tut",
|
||||
"status.delete": "Elimina",
|
||||
|
|
@ -870,24 +904,33 @@
|
|||
"status.quote": "Cita",
|
||||
"status.quote.cancel": "Canceŀlar la citació",
|
||||
"status.quote_error.filtered": "No es mostra a causa d'un dels vostres filtres",
|
||||
"status.quote_error.limited_account_hint.action": "Mostra-la igualment",
|
||||
"status.quote_error.limited_account_hint.title": "Aquest perfil l'han amagat els moderadors de {domain}.",
|
||||
"status.quote_error.not_available": "Publicació no disponible",
|
||||
"status.quote_error.pending_approval": "Publicació pendent",
|
||||
"status.quote_error.pending_approval_popout.body": "A Mastodon pots controlar si algú et pot citar. Aquesta publicació està pendent mentre esperem l'aprovació de l'autor original.",
|
||||
"status.quote_error.revoked": "Publicació eliminada per l'autor",
|
||||
"status.quote_followers_only": "Només els seguidors poden citar aquesta publicació",
|
||||
"status.quote_manual_review": "L'autor ho revisarà manualment",
|
||||
"status.quote_noun": "Cita",
|
||||
"status.quote_policy_change": "Canvieu qui us pot citar",
|
||||
"status.quote_post_author": "S'ha citat una publicació de @{name}",
|
||||
"status.quote_private": "No es poden citar les publicacions privades",
|
||||
"status.quotes": "{count, plural, one {cita} other {cites}}",
|
||||
"status.quotes.empty": "Encara no ha citat aquesta publicació ningú. Quan ho faci algú apareixerà aquí.",
|
||||
"status.quotes.local_other_disclaimer": "No es mostraran les cites rebutjades per l'autor.",
|
||||
"status.quotes.remote_other_disclaimer": "Només es garanteix que es mostraran aquí les cites de {domain}. No es mostraran les rebutjades per l'autor.",
|
||||
"status.read_more": "Més informació",
|
||||
"status.reblog": "Impulsa",
|
||||
"status.reblog_or_quote": "Impuls or cita",
|
||||
"status.reblog_private": "Compartiu de nou amb els vostres seguidors",
|
||||
"status.reblogged_by": "impulsat per {name}",
|
||||
"status.reblogs": "{count, plural, one {impuls} other {impulsos}}",
|
||||
"status.reblogs.empty": "Encara no ha impulsat ningú aquest tut. Quan algú ho faci, apareixerà aquí.",
|
||||
"status.redraft": "Esborra i reescriu",
|
||||
"status.remove_bookmark": "Elimina el marcador",
|
||||
"status.remove_favourite": "Elimina dels preferits",
|
||||
"status.remove_quote": "Elimina",
|
||||
"status.replied_in_thread": "Respost al fil",
|
||||
"status.replied_to": "En resposta a {name}",
|
||||
"status.reply": "Respon",
|
||||
|
|
@ -932,6 +975,7 @@
|
|||
"upload_button.label": "Afegeix imatges, un vídeo o un fitxer d'àudio",
|
||||
"upload_error.limit": "S'ha superat el límit de càrrega d'arxius.",
|
||||
"upload_error.poll": "No es permet carregar fitxers a les enquestes.",
|
||||
"upload_error.quote": "No es permet de carregat fitxer amb cites.",
|
||||
"upload_form.drag_and_drop.instructions": "Per a agafar un fitxer multimèdia adjunt, premeu l'espai o la tecla Enter. Mentre l'arrossegueu, utilitzeu les fletxes per a moure l'adjunt en qualsevol direcció. Premeu espai o Enter un altre cop per a deixar-lo anar a la seva nova posició, o premeu la tecla d'escapament per cancel·lar.",
|
||||
"upload_form.drag_and_drop.on_drag_cancel": "S'ha cancel·lat l'arrossegament. S'ha deixat anar l'adjunt multimèdia {item}.",
|
||||
"upload_form.drag_and_drop.on_drag_end": "S'ha deixat anar l'adjunt multimèdia {item}.",
|
||||
|
|
@ -960,7 +1004,9 @@
|
|||
"visibility_modal.helper.direct_quoting": "No es poden citar mencions privades fetes a Mastondon.",
|
||||
"visibility_modal.helper.private_quoting": "No es poden citar publicacions fetes a Mastodon només per a seguidors.",
|
||||
"visibility_modal.helper.unlisted_quoting": "Quan la gent et citi les seves publicacions estaran amagades de les línies de temps de tendències.",
|
||||
"visibility_modal.privacy_label": "Visibilitat",
|
||||
"visibility_modal.quote_followers": "Només seguidors",
|
||||
"visibility_modal.quote_label": "Qui pot citar",
|
||||
"visibility_modal.quote_nobody": "Només jo",
|
||||
"visibility_modal.quote_public": "Qualsevol",
|
||||
"visibility_modal.save": "Desa"
|
||||
|
|
|
|||
|
|
@ -257,7 +257,12 @@
|
|||
"confirmations.revoke_quote.confirm": "Odstranit příspěvek",
|
||||
"confirmations.revoke_quote.message": "Tuto akci nelze vrátit zpět.",
|
||||
"confirmations.revoke_quote.title": "Odstranit příspěvek?",
|
||||
"confirmations.unblock.confirm": "Odblokovat",
|
||||
"confirmations.unblock.title": "Odblokovat {name}?",
|
||||
"confirmations.unfollow.confirm": "Přestat sledovat",
|
||||
"confirmations.unfollow.title": "Přestat sledovat {name}?",
|
||||
"confirmations.withdraw_request.confirm": "Zrušit žádost",
|
||||
"confirmations.withdraw_request.title": "Zrušit žádost na sledování {name}?",
|
||||
"content_warning.hide": "Skrýt příspěvek",
|
||||
"content_warning.show": "Přesto zobrazit",
|
||||
"content_warning.show_more": "Zobrazit více",
|
||||
|
|
@ -748,6 +753,7 @@
|
|||
"privacy.unlisted.short": "Ztišené veřejné",
|
||||
"privacy_policy.last_updated": "Naposledy aktualizováno {date}",
|
||||
"privacy_policy.title": "Zásady ochrany osobních údajů",
|
||||
"quote_error.edit": "Citáty nemohou být přidány při úpravě příspěvku.",
|
||||
"quote_error.poll": "Citování není u dotazníků povoleno.",
|
||||
"quote_error.quote": "Je povoleno citovat pouze jednou.",
|
||||
"quote_error.unauthorized": "Nemáte oprávnění citovat tento příspěvek.",
|
||||
|
|
@ -870,7 +876,6 @@
|
|||
"status.contains_quote": "Obsahuje citaci",
|
||||
"status.context.loading": "Načítání dalších odpovědí",
|
||||
"status.context.loading_error": "Nelze načíst nové odpovědi",
|
||||
"status.context.loading_more": "Načítání dalších odpovědí",
|
||||
"status.context.loading_success": "Všechny odpovědi načteny",
|
||||
"status.context.more_replies_found": "Nalezeny další odpovědi",
|
||||
"status.context.retry": "Zkusit znovu",
|
||||
|
|
@ -918,6 +923,8 @@
|
|||
"status.quote_private": "Soukromé příspěvky nelze citovat",
|
||||
"status.quotes": "{count, plural, one {citace} few {citace} other {citací}}",
|
||||
"status.quotes.empty": "Tento příspěvek zatím nikdo necitoval. Pokud tak někdo učiní, uvidíte to zde.",
|
||||
"status.quotes.local_other_disclaimer": "Citace zamítnuté autorem nebudou zobrazeny.",
|
||||
"status.quotes.remote_other_disclaimer": "Pouze citace z {domain} zde budou zaručeně ukázány. Citace zamítnuté autorem nebudou zobrazeny.",
|
||||
"status.read_more": "Číst více",
|
||||
"status.reblog": "Boostnout",
|
||||
"status.reblog_or_quote": "Boostnout nebo citovat",
|
||||
|
|
|
|||
|
|
@ -257,7 +257,12 @@
|
|||
"confirmations.revoke_quote.confirm": "Dileu'r postiad",
|
||||
"confirmations.revoke_quote.message": "Does dim modd dadwneud y weithred hon.",
|
||||
"confirmations.revoke_quote.title": "Dileu'r postiad?",
|
||||
"confirmations.unblock.confirm": "Dadrwystro",
|
||||
"confirmations.unblock.title": "Dadrwystro {name}?",
|
||||
"confirmations.unfollow.confirm": "Dad-ddilyn",
|
||||
"confirmations.unfollow.title": "Dad-ddilyn {name}",
|
||||
"confirmations.withdraw_request.confirm": "Tynnu'r cais yn ôl",
|
||||
"confirmations.withdraw_request.title": "Tynnu nôl y cais i ddilyn {name}?",
|
||||
"content_warning.hide": "Cuddio'r postiad",
|
||||
"content_warning.show": "Dangos beth bynnag",
|
||||
"content_warning.show_more": "Dangos rhagor",
|
||||
|
|
@ -870,7 +875,6 @@
|
|||
"status.contains_quote": "Yn cynnwys dyfyniad",
|
||||
"status.context.loading": "Yn llwytho mwy o atebion",
|
||||
"status.context.loading_error": "Wedi methu llwytho atebion newydd",
|
||||
"status.context.loading_more": "Yn llwytho mwy o atebion",
|
||||
"status.context.loading_success": "Wedi llwytho'r holl atebion",
|
||||
"status.context.more_replies_found": "Mwy o atebion wedi'u canfod",
|
||||
"status.context.retry": "Ceisio eto",
|
||||
|
|
@ -916,8 +920,10 @@
|
|||
"status.quote_policy_change": "Newid pwy all ddyfynnu",
|
||||
"status.quote_post_author": "Wedi dyfynnu postiad gan @{name}",
|
||||
"status.quote_private": "Does dim modd dyfynnu postiadau preifat",
|
||||
"status.quotes": "{count, plural, zero {}one {dyfyniad} two {ddyfyniad} few {dyfyniad} many {dyfyniad} other {dyfyniad}}",
|
||||
"status.quotes": "{count, plural, zero {dyfyniadau} one {dyfyniad} two {ddyfyniad} few {dyfyniad} many {dyfyniad} other {dyfyniad}}",
|
||||
"status.quotes.empty": "Does neb wedi dyfynnu'r postiad hwn eto. Pan fydd rhywun yn gwneud hynny, bydd yn ymddangos yma.",
|
||||
"status.quotes.local_other_disclaimer": "Bydd dyfyniadau wedi'u gwrthod gan yr awdur ddim yn cael eu dangos.",
|
||||
"status.quotes.remote_other_disclaimer": "Dim ond dyfyniadau o {domain} sy'n siŵr o gael eu dangos yma. Bydd dyfyniadau wedi'u gwrthod gan yr awdur ddim yn cael eu dangos.",
|
||||
"status.read_more": "Darllen rhagor",
|
||||
"status.reblog": "Hybu",
|
||||
"status.reblog_or_quote": "Hybu neu ddyfynnu",
|
||||
|
|
|
|||
|
|
@ -753,6 +753,7 @@
|
|||
"privacy.unlisted.short": "Offentlig (stille)",
|
||||
"privacy_policy.last_updated": "Senest opdateret {date}",
|
||||
"privacy_policy.title": "Privatlivspolitik",
|
||||
"quote_error.edit": "Citater kan ikke tilføjes ved redigering af et indlæg.",
|
||||
"quote_error.poll": "Citering ikke tilladt i afstemninger.",
|
||||
"quote_error.quote": "Kun ét citat ad gangen er tilladt.",
|
||||
"quote_error.unauthorized": "Du har ikke tilladelse til at citere dette indlæg.",
|
||||
|
|
@ -875,7 +876,6 @@
|
|||
"status.contains_quote": "Indeholder citat",
|
||||
"status.context.loading": "Indlæser flere svar",
|
||||
"status.context.loading_error": "Kunne ikke indlæse nye svar",
|
||||
"status.context.loading_more": "Indlæser flere svar",
|
||||
"status.context.loading_success": "Alle svar indlæst",
|
||||
"status.context.more_replies_found": "Flere svar fundet",
|
||||
"status.context.retry": "Prøv igen",
|
||||
|
|
|
|||
|
|
@ -257,8 +257,8 @@
|
|||
"confirmations.revoke_quote.confirm": "Beitrag entfernen",
|
||||
"confirmations.revoke_quote.message": "Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"confirmations.revoke_quote.title": "Beitrag entfernen?",
|
||||
"confirmations.unblock.confirm": "Nicht mehr blockieren",
|
||||
"confirmations.unblock.title": "{name} nicht mehr blockieren?",
|
||||
"confirmations.unblock.confirm": "Entsperren",
|
||||
"confirmations.unblock.title": "{name} entsperren?",
|
||||
"confirmations.unfollow.confirm": "Entfolgen",
|
||||
"confirmations.unfollow.title": "{name} entfolgen?",
|
||||
"confirmations.withdraw_request.confirm": "Anfrage zurückziehen",
|
||||
|
|
@ -753,6 +753,7 @@
|
|||
"privacy.unlisted.short": "Öffentlich (still)",
|
||||
"privacy_policy.last_updated": "Stand: {date}",
|
||||
"privacy_policy.title": "Datenschutzerklärung",
|
||||
"quote_error.edit": "Beim Bearbeiten eines Beitrags können keine Zitate hinzugefügt werden.",
|
||||
"quote_error.poll": "Zitieren ist bei Umfragen nicht gestattet.",
|
||||
"quote_error.quote": "Es ist jeweils nur ein Zitat zulässig.",
|
||||
"quote_error.unauthorized": "Du bist nicht berechtigt, diesen Beitrag zu zitieren.",
|
||||
|
|
@ -875,7 +876,6 @@
|
|||
"status.contains_quote": "Enthält Zitat",
|
||||
"status.context.loading": "Weitere Antworten laden",
|
||||
"status.context.loading_error": "Weitere Antworten konnten nicht geladen werden",
|
||||
"status.context.loading_more": "Weitere Antworten laden",
|
||||
"status.context.loading_success": "Alle weiteren Antworten geladen",
|
||||
"status.context.more_replies_found": "Weitere Antworten verfügbar",
|
||||
"status.context.retry": "Erneut versuchen",
|
||||
|
|
|
|||
|
|
@ -739,7 +739,7 @@
|
|||
"poll_button.add_poll": "Προσθήκη δημοσκόπησης",
|
||||
"poll_button.remove_poll": "Αφαίρεση δημοσκόπησης",
|
||||
"privacy.change": "Προσαρμογή ιδιωτικότητας ανάρτησης",
|
||||
"privacy.direct.long": "Όλοι όσοι αναφέρθηκαν στην ανάρτηση",
|
||||
"privacy.direct.long": "Όλοι όσοι επισημάνθηκαν στην ανάρτηση",
|
||||
"privacy.direct.short": "Ιδιωτική επισήμανση",
|
||||
"privacy.private.long": "Μόνο οι ακόλουθοί σας",
|
||||
"privacy.private.short": "Ακόλουθοι",
|
||||
|
|
@ -753,6 +753,7 @@
|
|||
"privacy.unlisted.short": "Ήσυχα δημόσια",
|
||||
"privacy_policy.last_updated": "Τελευταία ενημέρωση {date}",
|
||||
"privacy_policy.title": "Πολιτική Απορρήτου",
|
||||
"quote_error.edit": "Δεν μπορούν να προστεθούν παραθέσεις κατά την επεξεργασία μιας ανάρτησης.",
|
||||
"quote_error.poll": "Η παράθεση δεν επιτρέπεται με δημοσκοπήσεις.",
|
||||
"quote_error.quote": "Επιτρέπεται μόνο μία παράθεση τη φορά.",
|
||||
"quote_error.unauthorized": "Δεν είστε εξουσιοδοτημένοι να παραθέσετε αυτή την ανάρτηση.",
|
||||
|
|
@ -875,7 +876,6 @@
|
|||
"status.contains_quote": "Περιέχει παράθεση",
|
||||
"status.context.loading": "Φόρτωση περισσότερων απαντήσεων",
|
||||
"status.context.loading_error": "Αδυναμία φόρτωσης νέων απαντήσεων",
|
||||
"status.context.loading_more": "Φόρτωση περισσότερων απαντήσεων",
|
||||
"status.context.loading_success": "Όλες οι απαντήσεις φορτώθηκαν",
|
||||
"status.context.more_replies_found": "Βρέθηκαν περισσότερες απαντήσεις",
|
||||
"status.context.retry": "Επανάληψη",
|
||||
|
|
|
|||
|
|
@ -753,6 +753,7 @@
|
|||
"privacy.unlisted.short": "Quiet public",
|
||||
"privacy_policy.last_updated": "Last updated {date}",
|
||||
"privacy_policy.title": "Privacy Policy",
|
||||
"quote_error.edit": "Quotes cannot be added when editing a post.",
|
||||
"quote_error.poll": "Quoting is not allowed with polls.",
|
||||
"quote_error.quote": "Only one quote at a time is allowed.",
|
||||
"quote_error.unauthorized": "You are not authorized to quote this post.",
|
||||
|
|
@ -875,8 +876,7 @@
|
|||
"status.contains_quote": "Contains quote",
|
||||
"status.context.loading": "Loading more replies",
|
||||
"status.context.loading_error": "Couldn't load new replies",
|
||||
"status.context.loading_more": "Loading more replies",
|
||||
"status.context.loading_success": "All replies loaded",
|
||||
"status.context.loading_success": "New replies loaded",
|
||||
"status.context.more_replies_found": "More replies found",
|
||||
"status.context.retry": "Retry",
|
||||
"status.context.show": "Show",
|
||||
|
|
|
|||
|
|
@ -753,6 +753,7 @@
|
|||
"privacy.unlisted.short": "Público silencioso",
|
||||
"privacy_policy.last_updated": "Última actualización: {date}",
|
||||
"privacy_policy.title": "Política de privacidad",
|
||||
"quote_error.edit": "Las citas no se pueden agregar al editar un mensaje.",
|
||||
"quote_error.poll": "No se permite citar encuestas.",
|
||||
"quote_error.quote": "Solo se permite una cita a la vez.",
|
||||
"quote_error.unauthorized": "No tenés autorización para citar este mensaje.",
|
||||
|
|
@ -875,7 +876,6 @@
|
|||
"status.contains_quote": "Contiene cita",
|
||||
"status.context.loading": "Cargando más respuestas",
|
||||
"status.context.loading_error": "No se pudieron cargar nuevas respuestas",
|
||||
"status.context.loading_more": "Cargando más respuestas",
|
||||
"status.context.loading_success": "Se cargaron todas las respuestas",
|
||||
"status.context.more_replies_found": "Se encontraron más respuestas",
|
||||
"status.context.retry": "Reintentar",
|
||||
|
|
@ -923,8 +923,8 @@
|
|||
"status.quote_private": "No se pueden citar los mensajes privados",
|
||||
"status.quotes": "{count, plural, one {# voto} other {# votos}}",
|
||||
"status.quotes.empty": "Todavía nadie citó este mensaje. Cuando alguien lo haga, se mostrará acá.",
|
||||
"status.quotes.local_other_disclaimer": "Las citas rechazadas por el autor no se mostrarán.",
|
||||
"status.quotes.remote_other_disclaimer": "Solo se muestran las citas de {domain}. Las citas rechazadas por el autor no se mostrarán.",
|
||||
"status.quotes.local_other_disclaimer": "Las citas rechazadas por el autor no serán mostradas.",
|
||||
"status.quotes.remote_other_disclaimer": "Solo las citas de {domain} están garantizadas de ser mostradas acá. Las citas rechazadas por el autor no serán mostradas.",
|
||||
"status.read_more": "Leé más",
|
||||
"status.reblog": "Adherir",
|
||||
"status.reblog_or_quote": "Adherir o citar",
|
||||
|
|
|
|||
|
|
@ -753,6 +753,7 @@
|
|||
"privacy.unlisted.short": "Pública, pero discreta",
|
||||
"privacy_policy.last_updated": "Actualizado por última vez {date}",
|
||||
"privacy_policy.title": "Política de Privacidad",
|
||||
"quote_error.edit": "No se pueden añadir citas mientras se edita una publicación.",
|
||||
"quote_error.poll": "No se permite citar encuestas.",
|
||||
"quote_error.quote": "Solo se permite una cita a la vez.",
|
||||
"quote_error.unauthorized": "No estás autorizado a citar esta publicación.",
|
||||
|
|
@ -875,7 +876,6 @@
|
|||
"status.contains_quote": "Contiene cita",
|
||||
"status.context.loading": "Cargando más respuestas",
|
||||
"status.context.loading_error": "No se pudieron cargar nuevas respuestas",
|
||||
"status.context.loading_more": "Cargando más respuestas",
|
||||
"status.context.loading_success": "Todas las respuestas cargadas",
|
||||
"status.context.more_replies_found": "Se han encontrado más respuestas",
|
||||
"status.context.retry": "Reintentar",
|
||||
|
|
@ -924,7 +924,7 @@
|
|||
"status.quotes": "{count, plural,one {cita} other {citas}}",
|
||||
"status.quotes.empty": "Nadie ha citado esta publicación todavía. Cuando alguien lo haga, aparecerá aquí.",
|
||||
"status.quotes.local_other_disclaimer": "Las citas rechazadas por el autor no se mostrarán.",
|
||||
"status.quotes.remote_other_disclaimer": "Solo se muestran las citas de {domain}. Las citas rechazadas por el autor no se mostrarán.",
|
||||
"status.quotes.remote_other_disclaimer": "Solo se garantiza que se muestren las citas de {domain}. Las citas rechazadas por el autor no se mostrarán.",
|
||||
"status.read_more": "Leer más",
|
||||
"status.reblog": "Impulsar",
|
||||
"status.reblog_or_quote": "Impulsar o citar",
|
||||
|
|
|
|||
|
|
@ -753,6 +753,7 @@
|
|||
"privacy.unlisted.short": "Pública silenciosa",
|
||||
"privacy_policy.last_updated": "Actualizado por última vez {date}",
|
||||
"privacy_policy.title": "Política de Privacidad",
|
||||
"quote_error.edit": "No se pueden añadir citas mientras se edita una publicación.",
|
||||
"quote_error.poll": "No es posible citar encuestas.",
|
||||
"quote_error.quote": "Solo se permite una cita a la vez.",
|
||||
"quote_error.unauthorized": "No tienes permiso para citar esta publicación.",
|
||||
|
|
@ -875,7 +876,6 @@
|
|||
"status.contains_quote": "Contiene cita",
|
||||
"status.context.loading": "Cargando más respuestas",
|
||||
"status.context.loading_error": "No se pudieron cargar nuevas respuestas",
|
||||
"status.context.loading_more": "Cargando más respuestas",
|
||||
"status.context.loading_success": "Se cargaron todas las respuestas",
|
||||
"status.context.more_replies_found": "Se encontraron más respuestas",
|
||||
"status.context.retry": "Reintentar",
|
||||
|
|
@ -924,7 +924,7 @@
|
|||
"status.quotes": "{count, plural,one {cita} other {citas}}",
|
||||
"status.quotes.empty": "Nadie ha citado esta publicación todavía. Cuando alguien lo haga, aparecerá aquí.",
|
||||
"status.quotes.local_other_disclaimer": "Las citas rechazadas por el autor no se mostrarán.",
|
||||
"status.quotes.remote_other_disclaimer": "Solo se muestran las citas de {domain}. Las citas rechazadas por el autor no se mostrarán.",
|
||||
"status.quotes.remote_other_disclaimer": "Solo se garantiza que se muestren las citas de {domain}. Las citas rechazadas por el autor no se mostrarán.",
|
||||
"status.read_more": "Leer más",
|
||||
"status.reblog": "Impulsar",
|
||||
"status.reblog_or_quote": "Impulsar o citar",
|
||||
|
|
|
|||
|
|
@ -257,7 +257,12 @@
|
|||
"confirmations.revoke_quote.confirm": "Eemalda postitus",
|
||||
"confirmations.revoke_quote.message": "Seda tegevust ei saa tagasi pöörata.",
|
||||
"confirmations.revoke_quote.title": "Kas eemaldame postituse?",
|
||||
"confirmations.unblock.confirm": "Lõpeta blokeerimine",
|
||||
"confirmations.unblock.title": "Kas lõpetad {name} kasutaja blokeerimise?",
|
||||
"confirmations.unfollow.confirm": "Ära jälgi",
|
||||
"confirmations.unfollow.title": "Kas lõpetad {name} kasutaja jälgimise?",
|
||||
"confirmations.withdraw_request.confirm": "Tühista päring",
|
||||
"confirmations.withdraw_request.title": "Tühistad päringu {name} kasutaja jälgimiseks?",
|
||||
"content_warning.hide": "Peida postitus",
|
||||
"content_warning.show": "Näita ikkagi",
|
||||
"content_warning.show_more": "Näita rohkem",
|
||||
|
|
@ -870,7 +875,6 @@
|
|||
"status.contains_quote": "Sisaldab tsitaati",
|
||||
"status.context.loading": "Laadin veel vastuseid",
|
||||
"status.context.loading_error": "Uute vastuste laadimine ei õnnestunud",
|
||||
"status.context.loading_more": "Laadin veel vastuseid",
|
||||
"status.context.loading_success": "Kõik vastused on laaditud",
|
||||
"status.context.more_replies_found": "Leidub veel vastuseid",
|
||||
"status.context.retry": "Proovi uuesti",
|
||||
|
|
@ -918,6 +922,8 @@
|
|||
"status.quote_private": "Otsepostituste tsiteerimine pole võimalik",
|
||||
"status.quotes": "{count, plural, one {# tsiteerimine} other {# tsiteerimist}}",
|
||||
"status.quotes.empty": "Keegi pole seda postitust veel tsiteerinud. Kui keegi seda teeb, siis on ta nähtav siin.",
|
||||
"status.quotes.local_other_disclaimer": "Autori poolt tagasilükatud tsitaate ei kuvata.",
|
||||
"status.quotes.remote_other_disclaimer": "Kui kasutaja on {domain} domeenist, siis siin on tagatud vaid tema tsitaatide näitamine. Autori poolt tagasilükatud tsitaate ei kuvata.",
|
||||
"status.read_more": "Loe veel",
|
||||
"status.reblog": "Jaga",
|
||||
"status.reblog_or_quote": "Anna hoogu või tsiteeri",
|
||||
|
|
|
|||
|
|
@ -753,6 +753,7 @@
|
|||
"privacy.unlisted.short": "Vaivihkaa julkinen",
|
||||
"privacy_policy.last_updated": "Päivitetty viimeksi {date}",
|
||||
"privacy_policy.title": "Tietosuojakäytäntö",
|
||||
"quote_error.edit": "Lainauksia ei voi lisätä julkaisua muokattaessa.",
|
||||
"quote_error.poll": "Äänestysten lainaaminen ei ole sallittua.",
|
||||
"quote_error.quote": "Vain yksi lainaus kerrallaan on sallittu.",
|
||||
"quote_error.unauthorized": "Sinulla ei ole valtuuksia lainata tätä julkaisua.",
|
||||
|
|
@ -875,7 +876,6 @@
|
|||
"status.contains_quote": "Sisältää lainauksen",
|
||||
"status.context.loading": "Ladataan lisää vastauksia",
|
||||
"status.context.loading_error": "Ei voitu ladata lisää vastauksia",
|
||||
"status.context.loading_more": "Ladataan lisää vastauksia",
|
||||
"status.context.loading_success": "Kaikki vastaukset ladattu",
|
||||
"status.context.more_replies_found": "Löytyi lisää vastauksia",
|
||||
"status.context.retry": "Yritä uudelleen",
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
"account.badges.group": "Bólkur",
|
||||
"account.block": "Banna @{name}",
|
||||
"account.block_domain": "Banna økisnavnið {domain}",
|
||||
"account.block_short": "Blokera",
|
||||
"account.block_short": "Banna",
|
||||
"account.blocked": "Bannað/ur",
|
||||
"account.blocking": "Banni",
|
||||
"account.cancel_follow_request": "Strika fylgjaraumbøn",
|
||||
|
|
@ -257,7 +257,12 @@
|
|||
"confirmations.revoke_quote.confirm": "Strika post",
|
||||
"confirmations.revoke_quote.message": "Hendan atgerðin kann ikki angrast.",
|
||||
"confirmations.revoke_quote.title": "Strika post?",
|
||||
"confirmations.unblock.confirm": "Banna ikki",
|
||||
"confirmations.unblock.title": "Banna ikki {name}?",
|
||||
"confirmations.unfollow.confirm": "Fylg ikki",
|
||||
"confirmations.unfollow.title": "Gevst at fylgja {name}?",
|
||||
"confirmations.withdraw_request.confirm": "Tak umbønina aftur",
|
||||
"confirmations.withdraw_request.title": "Tak umbønina um at fylgja {name} aftur?",
|
||||
"content_warning.hide": "Fjal post",
|
||||
"content_warning.show": "Vís kortini",
|
||||
"content_warning.show_more": "Vís meiri",
|
||||
|
|
@ -748,6 +753,7 @@
|
|||
"privacy.unlisted.short": "Stillur almenningur",
|
||||
"privacy_policy.last_updated": "Seinast dagført {date}",
|
||||
"privacy_policy.title": "Privatlívspolitikkur",
|
||||
"quote_error.edit": "Sitatir kunnu ikki leggjast afturat tá tú rættar ein post.",
|
||||
"quote_error.poll": "Tað er ikki loyvt at sitera spurnarkanningar.",
|
||||
"quote_error.quote": "Bara ein sitering er loyvd í senn.",
|
||||
"quote_error.unauthorized": "Tú hevur ikki rættindi at sitera hendan postin.",
|
||||
|
|
@ -870,7 +876,6 @@
|
|||
"status.contains_quote": "Inniheldur sitat",
|
||||
"status.context.loading": "Tekur fleiri svar niður",
|
||||
"status.context.loading_error": "Fekk ikki tikið nýggj svar niður",
|
||||
"status.context.loading_more": "Tekur fleiri svar niður",
|
||||
"status.context.loading_success": "Øll svar tikin niður",
|
||||
"status.context.more_replies_found": "Fleiri svar funnin",
|
||||
"status.context.retry": "Royn aftur",
|
||||
|
|
@ -918,6 +923,8 @@
|
|||
"status.quote_private": "Privatir postar kunnu ikki siterast",
|
||||
"status.quotes": "{count, plural, one {sitat} other {sitat}}",
|
||||
"status.quotes.empty": "Eingin hevur siterað hendan postin enn. Tá onkur siterar postin, verður hann sjónligur her.",
|
||||
"status.quotes.local_other_disclaimer": "Sitatir, sum eru avvíst av høvundanum, verða ikki víst.",
|
||||
"status.quotes.remote_other_disclaimer": "Einans sitatir frá {domain} vera garanterað víst her. Sitatir, sum eru avvíst av høvundanum, verða ikki víst.",
|
||||
"status.read_more": "Les meira",
|
||||
"status.reblog": "Stimbra",
|
||||
"status.reblog_or_quote": "Stimbra ella sitera",
|
||||
|
|
|
|||
|
|
@ -251,7 +251,12 @@
|
|||
"confirmations.revoke_quote.confirm": "Retirer la publication",
|
||||
"confirmations.revoke_quote.message": "Cette action ne peut pas être annulée.",
|
||||
"confirmations.revoke_quote.title": "Retirer la publication ?",
|
||||
"confirmations.unblock.confirm": "Débloquer",
|
||||
"confirmations.unblock.title": "Débloquer {name} ?",
|
||||
"confirmations.unfollow.confirm": "Ne plus suivre",
|
||||
"confirmations.unfollow.title": "Ne plus suivre {name} ?",
|
||||
"confirmations.withdraw_request.confirm": "Rejeter la demande",
|
||||
"confirmations.withdraw_request.title": "Rejeter la demande de suivre {name} ?",
|
||||
"content_warning.hide": "Masquer le message",
|
||||
"content_warning.show": "Montrer quand même",
|
||||
"content_warning.show_more": "Montrer plus",
|
||||
|
|
@ -861,6 +866,13 @@
|
|||
"status.cancel_reblog_private": "Débooster",
|
||||
"status.cannot_quote": "Vous n'êtes pas autorisé à citer ce message",
|
||||
"status.cannot_reblog": "Cette publication ne peut pas être boostée",
|
||||
"status.contains_quote": "Contient la citation",
|
||||
"status.context.loading": "Chargement de réponses supplémentaires",
|
||||
"status.context.loading_error": "Impossible de charger les nouvelles réponses",
|
||||
"status.context.loading_success": "Toutes les réponses sont chargées",
|
||||
"status.context.more_replies_found": "Plus de réponses trouvées",
|
||||
"status.context.retry": "Réessayer",
|
||||
"status.context.show": "Montrer",
|
||||
"status.continued_thread": "Suite du fil",
|
||||
"status.copy": "Copier un lien vers cette publication",
|
||||
"status.delete": "Supprimer",
|
||||
|
|
@ -890,17 +902,22 @@
|
|||
"status.quote": "Citer",
|
||||
"status.quote.cancel": "Annuler la citation",
|
||||
"status.quote_error.filtered": "Caché en raison de l'un de vos filtres",
|
||||
"status.quote_error.limited_account_hint.action": "Afficher quand même",
|
||||
"status.quote_error.limited_account_hint.title": "Ce profil a été masqué par la modération de {domain}.",
|
||||
"status.quote_error.not_available": "Publication non disponible",
|
||||
"status.quote_error.pending_approval": "Publication en attente",
|
||||
"status.quote_error.pending_approval_popout.body": "Sur Mastodon, vous pouvez contrôler si quelqu'un peut vous citer. Ce message est en attente pendant que nous recevons l'approbation de l'auteur original.",
|
||||
"status.quote_error.revoked": "Post supprimé par l'auteur",
|
||||
"status.quote_followers_only": "Seul·e·s les abonné·e·s peuvent citer cette publication",
|
||||
"status.quote_manual_review": "L'auteur va vérifier manuellement",
|
||||
"status.quote_noun": "Citation",
|
||||
"status.quote_policy_change": "Changer qui peut vous citer",
|
||||
"status.quote_post_author": "A cité un message par @{name}",
|
||||
"status.quote_private": "Les publications privées ne peuvent pas être citées",
|
||||
"status.quotes": " {count, plural, one {quote} other {quotes}}",
|
||||
"status.quotes.empty": "Personne n'a encore cité ce message. Quand quelqu'un le fera, il apparaîtra ici.",
|
||||
"status.quotes.local_other_disclaimer": "Les citations rejetées par l'auteur ne seront pas affichées.",
|
||||
"status.quotes.remote_other_disclaimer": "Seules les citations de {domain} sont garanties d'être affichées ici. Les citations rejetées par l'auteur ne seront pas affichées.",
|
||||
"status.read_more": "En savoir plus",
|
||||
"status.reblog": "Booster",
|
||||
"status.reblog_or_quote": "Boost ou citation",
|
||||
|
|
|
|||
|
|
@ -251,7 +251,12 @@
|
|||
"confirmations.revoke_quote.confirm": "Retirer la publication",
|
||||
"confirmations.revoke_quote.message": "Cette action ne peut pas être annulée.",
|
||||
"confirmations.revoke_quote.title": "Retirer la publication ?",
|
||||
"confirmations.unblock.confirm": "Débloquer",
|
||||
"confirmations.unblock.title": "Débloquer {name} ?",
|
||||
"confirmations.unfollow.confirm": "Ne plus suivre",
|
||||
"confirmations.unfollow.title": "Ne plus suivre {name} ?",
|
||||
"confirmations.withdraw_request.confirm": "Rejeter la demande",
|
||||
"confirmations.withdraw_request.title": "Rejeter la demande de suivre {name} ?",
|
||||
"content_warning.hide": "Masquer le message",
|
||||
"content_warning.show": "Montrer quand même",
|
||||
"content_warning.show_more": "Montrer plus",
|
||||
|
|
@ -861,6 +866,13 @@
|
|||
"status.cancel_reblog_private": "Annuler le partage",
|
||||
"status.cannot_quote": "Vous n'êtes pas autorisé à citer ce message",
|
||||
"status.cannot_reblog": "Ce message ne peut pas être partagé",
|
||||
"status.contains_quote": "Contient la citation",
|
||||
"status.context.loading": "Chargement de réponses supplémentaires",
|
||||
"status.context.loading_error": "Impossible de charger les nouvelles réponses",
|
||||
"status.context.loading_success": "Toutes les réponses sont chargées",
|
||||
"status.context.more_replies_found": "Plus de réponses trouvées",
|
||||
"status.context.retry": "Réessayer",
|
||||
"status.context.show": "Montrer",
|
||||
"status.continued_thread": "Suite du fil",
|
||||
"status.copy": "Copier le lien vers le message",
|
||||
"status.delete": "Supprimer",
|
||||
|
|
@ -890,18 +902,23 @@
|
|||
"status.quote": "Citer",
|
||||
"status.quote.cancel": "Annuler la citation",
|
||||
"status.quote_error.filtered": "Caché en raison de l'un de vos filtres",
|
||||
"status.quote_error.limited_account_hint.action": "Afficher quand même",
|
||||
"status.quote_error.limited_account_hint.title": "Ce profil a été masqué par la modération de {domain}.",
|
||||
"status.quote_error.not_available": "Publication non disponible",
|
||||
"status.quote_error.pending_approval": "Publication en attente",
|
||||
"status.quote_error.pending_approval_popout.body": "Sur Mastodon, vous pouvez contrôler si quelqu'un peut vous citer. Ce message est en attente pendant que nous recevons l'approbation de l'auteur original.",
|
||||
"status.quote_error.revoked": "Post supprimé par l'auteur",
|
||||
"status.quote_followers_only": "Seul·e·s les abonné·e·s peuvent citer cette publication",
|
||||
"status.quote_manual_review": "L'auteur va vérifier manuellement",
|
||||
"status.quote_noun": "Citation",
|
||||
"status.quote_policy_change": "Changer qui peut vous citer",
|
||||
"status.quote_post_author": "A cité un message par @{name}",
|
||||
"status.quote_private": "Les publications privées ne peuvent pas être citées",
|
||||
"status.quotes": " {count, plural, one {quote} other {quotes}}",
|
||||
"status.quotes.empty": "Personne n'a encore cité ce message. Quand quelqu'un le fera, il apparaîtra ici.",
|
||||
"status.read_more": "En savoir plus",
|
||||
"status.quotes.local_other_disclaimer": "Les citations rejetées par l'auteur ne seront pas affichées.",
|
||||
"status.quotes.remote_other_disclaimer": "Seules les citations de {domain} sont garanties d'être affichées ici. Les citations rejetées par l'auteur ne seront pas affichées.",
|
||||
"status.read_more": "Lire la suite",
|
||||
"status.reblog": "Partager",
|
||||
"status.reblog_or_quote": "Boost ou citation",
|
||||
"status.reblog_private": "Partagez à nouveau avec vos abonnés",
|
||||
|
|
|
|||
|
|
@ -257,7 +257,12 @@
|
|||
"confirmations.revoke_quote.confirm": "Bain postáil",
|
||||
"confirmations.revoke_quote.message": "Ní féidir an gníomh seo a chealú.",
|
||||
"confirmations.revoke_quote.title": "Bain postáil?",
|
||||
"confirmations.unblock.confirm": "Díbhlocáil",
|
||||
"confirmations.unblock.title": "Díbhlocáil {name}?",
|
||||
"confirmations.unfollow.confirm": "Ná lean",
|
||||
"confirmations.unfollow.title": "Díleanúint {name}?",
|
||||
"confirmations.withdraw_request.confirm": "Iarratas ar tharraingt siar",
|
||||
"confirmations.withdraw_request.title": "Iarratas chun {name} a leanúint a tharraingt siar?",
|
||||
"content_warning.hide": "Folaigh postáil",
|
||||
"content_warning.show": "Taispeáin ar aon nós",
|
||||
"content_warning.show_more": "Taispeáin níos mó",
|
||||
|
|
@ -870,7 +875,6 @@
|
|||
"status.contains_quote": "Tá luachan ann",
|
||||
"status.context.loading": "Ag lódáil tuilleadh freagraí",
|
||||
"status.context.loading_error": "Níorbh fhéidir freagraí nua a lódáil",
|
||||
"status.context.loading_more": "Ag lódáil tuilleadh freagraí",
|
||||
"status.context.loading_success": "Luchtaithe na freagraí uile",
|
||||
"status.context.more_replies_found": "Tuilleadh freagraí aimsithe",
|
||||
"status.context.retry": "Déan iarracht arís",
|
||||
|
|
@ -918,6 +922,8 @@
|
|||
"status.quote_private": "Ní féidir poist phríobháideacha a lua",
|
||||
"status.quotes": "{count, plural, one {sliocht} few {sliocht} other {sliocht}}",
|
||||
"status.quotes.empty": "Níl an post seo luaite ag aon duine go fóill. Nuair a dhéanann duine é, taispeánfar anseo é.",
|
||||
"status.quotes.local_other_disclaimer": "Ní thaispeánfar sleachta ar dhiúltaigh an t-údar dóibh.",
|
||||
"status.quotes.remote_other_disclaimer": "Níl ráthaíocht ann go dtaispeánfar anseo ach sleachta ó {domain}. Ní thaispeánfar sleachta ar dhiúltaigh an t-údar dóibh.",
|
||||
"status.read_more": "Léan a thuilleadh",
|
||||
"status.reblog": "Treisiú",
|
||||
"status.reblog_or_quote": "Borradh nó luachan",
|
||||
|
|
|
|||
|
|
@ -753,6 +753,7 @@
|
|||
"privacy.unlisted.short": "Pública limitada",
|
||||
"privacy_policy.last_updated": "Actualizado por última vez no {date}",
|
||||
"privacy_policy.title": "Política de Privacidade",
|
||||
"quote_error.edit": "Non se poden engadir citas ao editar unha publicación.",
|
||||
"quote_error.poll": "Non se permite citar as enquisas.",
|
||||
"quote_error.quote": "Só se permite citar unha vez.",
|
||||
"quote_error.unauthorized": "Non tes permiso para citar esta publicación.",
|
||||
|
|
@ -875,7 +876,6 @@
|
|||
"status.contains_quote": "Contén unha cita",
|
||||
"status.context.loading": "Cargando máis respostas",
|
||||
"status.context.loading_error": "Non se puideron mostrar novas respostas",
|
||||
"status.context.loading_more": "Cargando máis respostas",
|
||||
"status.context.loading_success": "Móstranse todas as respostas",
|
||||
"status.context.more_replies_found": "Existen máis respostas",
|
||||
"status.context.retry": "Volver tentar",
|
||||
|
|
|
|||
|
|
@ -753,6 +753,7 @@
|
|||
"privacy.unlisted.short": "ציבורי שקט",
|
||||
"privacy_policy.last_updated": "עודכן לאחרונה {date}",
|
||||
"privacy_policy.title": "מדיניות פרטיות",
|
||||
"quote_error.edit": "לא ניתן להוסיף ציטוטים בשלב עריכת ההודעה.",
|
||||
"quote_error.poll": "לא ניתן לכלול משאל כאשר מחברים הודעת ציטוט.",
|
||||
"quote_error.quote": "רק ציטוט אחד מותר בכל הודעה.",
|
||||
"quote_error.unauthorized": "אין לך הרשאה לצטט את ההודעה הזו.",
|
||||
|
|
@ -875,7 +876,6 @@
|
|||
"status.contains_quote": "הודעה מכילה ציטוט",
|
||||
"status.context.loading": "נטענות תשובות נוספות",
|
||||
"status.context.loading_error": "טעינת תשובות נוספות נכשלה",
|
||||
"status.context.loading_more": "נטענות תשובות נוספות",
|
||||
"status.context.loading_success": "כל התשובות נטענו",
|
||||
"status.context.more_replies_found": "תשובות נוספות נמצאו",
|
||||
"status.context.retry": "נסה שוב",
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@
|
|||
"account.disable_notifications": "Ne figyelmeztessen, ha @{name} bejegyzést tesz közzé",
|
||||
"account.domain_blocking": "Domain tiltás",
|
||||
"account.edit_profile": "Profil szerkesztése",
|
||||
"account.edit_profile_short": "Szerkesztés",
|
||||
"account.enable_notifications": "Figyelmeztessen, ha @{name} bejegyzést tesz közzé",
|
||||
"account.endorse": "Kiemelés a profilodon",
|
||||
"account.familiar_followers_many": "{name1}, {name2} és még {othersCount, plural, one {egy valaki} other {# valaki}}, akit ismersz",
|
||||
|
|
@ -40,6 +41,11 @@
|
|||
"account.featured_tags.last_status_never": "Nincs bejegyzés",
|
||||
"account.follow": "Követés",
|
||||
"account.follow_back": "Viszontkövetés",
|
||||
"account.follow_back_short": "Visszakövetés",
|
||||
"account.follow_request": "Követési kérés",
|
||||
"account.follow_request_cancel": "Kérés törlése",
|
||||
"account.follow_request_cancel_short": "Mégse",
|
||||
"account.follow_request_short": "Kérés",
|
||||
"account.followers": "Követő",
|
||||
"account.followers.empty": "Ezt a felhasználót még senki sem követi.",
|
||||
"account.followers_counter": "{count, plural, one {{counter} követő} other {{counter} követő}}",
|
||||
|
|
@ -251,7 +257,12 @@
|
|||
"confirmations.revoke_quote.confirm": "Bejegyzés eltávolítása",
|
||||
"confirmations.revoke_quote.message": "Ez a művelet nem vonható vissza.",
|
||||
"confirmations.revoke_quote.title": "Bejegyzés eltávolítása?",
|
||||
"confirmations.unblock.confirm": "Tiltás feloldása",
|
||||
"confirmations.unblock.title": "{name} tiltásának feloldása?",
|
||||
"confirmations.unfollow.confirm": "Követés visszavonása",
|
||||
"confirmations.unfollow.title": "{name} követésének megszüntetése?",
|
||||
"confirmations.withdraw_request.confirm": "Kérés visszavonása",
|
||||
"confirmations.withdraw_request.title": "{name} követése kérésének visszavonása?",
|
||||
"content_warning.hide": "Bejegyzés elrejtése",
|
||||
"content_warning.show": "Megjelenítés mindenképp",
|
||||
"content_warning.show_more": "Több megjelenítése",
|
||||
|
|
@ -742,6 +753,7 @@
|
|||
"privacy.unlisted.short": "Csendes nyilvános",
|
||||
"privacy_policy.last_updated": "Utoljára frissítve: {date}",
|
||||
"privacy_policy.title": "Adatvédelmi szabályzat",
|
||||
"quote_error.edit": "Idézés nem adható hozzá bejegyzés szerkesztésekor.",
|
||||
"quote_error.poll": "Az idézés szavazások esetén nincs engedélyezve.",
|
||||
"quote_error.quote": "Egyszerre csak egy idézet van engedélyezve.",
|
||||
"quote_error.unauthorized": "Nem idézheted ezt a bejegyzést.",
|
||||
|
|
@ -862,6 +874,12 @@
|
|||
"status.cannot_quote": "Nem idézheted ezt a bejegyzést",
|
||||
"status.cannot_reblog": "Ezt a bejegyzést nem lehet megtolni",
|
||||
"status.contains_quote": "Idézést tartalmaz",
|
||||
"status.context.loading": "Több válasz betöltése",
|
||||
"status.context.loading_error": "Az új válaszok nem tölthetőek be",
|
||||
"status.context.loading_success": "Összes válasz betöltve",
|
||||
"status.context.more_replies_found": "Több válasz található",
|
||||
"status.context.retry": "Újra",
|
||||
"status.context.show": "Megjelenítés",
|
||||
"status.continued_thread": "Folytatott szál",
|
||||
"status.copy": "Link másolása bejegyzésbe",
|
||||
"status.delete": "Törlés",
|
||||
|
|
@ -905,6 +923,8 @@
|
|||
"status.quote_private": "A privát bejegyzések nem idézhetőek",
|
||||
"status.quotes": "{count, plural, one {idézés} other {idézés}}",
|
||||
"status.quotes.empty": "Senki sem idézte még ezt a bejegyzést. Ha valaki megteszi, itt fog megjelenni.",
|
||||
"status.quotes.local_other_disclaimer": "A szerző által elutasított idézések nem fognak megjelenni.",
|
||||
"status.quotes.remote_other_disclaimer": "Csak a(z) {domain} idézései jelennek meg itt garantáltan. A szerző által elutasított idézések nem fognak megjelenni.",
|
||||
"status.read_more": "Bővebben",
|
||||
"status.reblog": "Megtolás",
|
||||
"status.reblog_or_quote": "Megtolás vagy idézés",
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user