mirror of
https://github.com/glitch-soc/mastodon.git
synced 2025-12-17 09:48:42 +00:00
Compare commits
2 Commits
v4.5.0-rc.
...
revert-320
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
855c3be3d7 | ||
|
|
805a19e288 |
@@ -318,3 +318,24 @@ MAX_POLL_OPTION_CHARS=100
|
|||||||
# -----------------------
|
# -----------------------
|
||||||
IP_RETENTION_PERIOD=31556952
|
IP_RETENTION_PERIOD=31556952
|
||||||
SESSION_RETENTION_PERIOD=31556952
|
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
|
||||||
|
|
||||||
|
# Period to wait after a post is first created before fetching its replies (in minutes)
|
||||||
|
FETCH_REPLIES_INITIAL_WAIT_MINUTES=5
|
||||||
|
|
||||||
|
# Max number of replies to fetch - total, recursively through a whole reply tree
|
||||||
|
FETCH_REPLIES_MAX_GLOBAL=1000
|
||||||
|
|
||||||
|
# Max number of replies to fetch - for a single post
|
||||||
|
FETCH_REPLIES_MAX_SINGLE=500
|
||||||
|
|
||||||
|
# Max number of replies Collection pages to fetch - total
|
||||||
|
FETCH_REPLIES_MAX_PAGES=500
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ permissions:
|
|||||||
jobs:
|
jobs:
|
||||||
download-translations-stable:
|
download-translations-stable:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.repository == 'glitch-soc/mastodon'
|
if: github.repository == 'mastodon/mastodon'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -23,7 +23,6 @@
|
|||||||
/public/packs
|
/public/packs
|
||||||
/public/packs-dev
|
/public/packs-dev
|
||||||
/public/packs-test
|
/public/packs-test
|
||||||
stats.html
|
|
||||||
.env
|
.env
|
||||||
.env.production
|
.env.production
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
3.4.7
|
3.4.6
|
||||||
|
|||||||
@@ -50,13 +50,9 @@ const preview: Preview = {
|
|||||||
locale: 'en',
|
locale: 'en',
|
||||||
},
|
},
|
||||||
decorators: [
|
decorators: [
|
||||||
(Story, { parameters, globals, args }) => {
|
(Story, { parameters, globals }) => {
|
||||||
// Get the locale from the global toolbar
|
|
||||||
// and merge it with any parameters or args state.
|
|
||||||
const { locale } = globals as { locale: string };
|
const { locale } = globals as { locale: string };
|
||||||
const { state = {} } = parameters;
|
const { state = {} } = parameters;
|
||||||
const { state: argsState = {} } = args;
|
|
||||||
|
|
||||||
const reducer = reducerWithInitialState(
|
const reducer = reducerWithInitialState(
|
||||||
{
|
{
|
||||||
meta: {
|
meta: {
|
||||||
@@ -64,9 +60,7 @@ const preview: Preview = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
state as Record<string, unknown>,
|
state as Record<string, unknown>,
|
||||||
argsState as Record<string, unknown>,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const store = configureStore({
|
const store = configureStore({
|
||||||
reducer,
|
reducer,
|
||||||
middleware(getDefaultMiddleware) {
|
middleware(getDefaultMiddleware) {
|
||||||
|
|||||||
158
CHANGELOG.md
158
CHANGELOG.md
@@ -2,164 +2,6 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
## [4.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, #36461, #36516, #36528, #36549, #36550 and #36559 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, #36239, #36484, #36481, #36583, #36627 and #36547 by @ClearlyClaire, @diondiondion, @Gargron and @renchap)
|
|
||||||
- **Add ability to block words in usernames** (#35407, #35655, and #35806 by @ClearlyClaire and @Gargron)
|
|
||||||
- Add ability to individually disable local or remote feeds for visitors or logged-in users `disabled` value to server setting for live and topic feeds, as well as user permission to bypass that (#36338, #36467, #36497, #36563, #36577, #36585, and #36607 by @ClearlyClaire)\
|
|
||||||
This splits the `timeline_preview` setting into four more granular settings controlling live feeds and topic (hashtag, trending link) feeds, with 3 values each: `public`, `authenticated`, `disabled`.\
|
|
||||||
When `disabled`, users with the “View live and topic feeds” will still be able to view them.
|
|
||||||
- Add support for displaying of quote posts in Moderator UI (#35964 by @ThisIsMissEm)
|
|
||||||
- Add support for displaying link previews for Admin UI (#35958 by @ThisIsMissEm)
|
|
||||||
- Add a new server setting to choose the server landing page (#36588 and #36602 by @ClearlyClaire and @renchap)
|
|
||||||
- Add support for `Update` activities on converted object types (#36322 by @ClearlyClaire)
|
|
||||||
- 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)
|
|
||||||
- Added emoji from Twemoji v16 (#36501 and #36530 by @ChaosExAnima)
|
|
||||||
- Add 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, #36402, #36503, #36502, #36532, #36603, #36409 and #36638 by @ChaosExAnima, @ClearlyClaire 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 appearance settings to introduce new Advanced settings section (#36496 and #36506 by @diondiondion)
|
|
||||||
- Change display of blocked and muted quoted users (#36619 by @ClearlyClaire)\
|
|
||||||
This adds `blocked_account`, `blocked_domain` and `muted_account` values to the `state` attribute of `Quote` and `ShallowQuote` REST API entities.
|
|
||||||
- Change display of content warnings in Admin UI (#35935 by @ThisIsMissEm)
|
|
||||||
- Change styling of column banners (#36531 by @ClearlyClaire)
|
|
||||||
- Change recommended Node version to 24 (LTS) (#36539 by @renchap)
|
|
||||||
- Change min. characters required for logged-out account search from 5 to 3 (#36487 by @Gargron)
|
|
||||||
- Change browser target to Vite legacy plugin defaults (#36611 by @larouxn)
|
|
||||||
- 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 support for RFC9421 HTTP signatures to be enabled unconditionally (#36610 by @oneiros)
|
|
||||||
- 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 @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)
|
|
||||||
- Change docker-compose.yml sidekiq health check to work for both 4.4 and 4.5 (#36498 by @ClearlyClaire)
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Fix relationship not being fetched to evaluate whether to show a quote post (#36517 by @ClearlyClaire)
|
|
||||||
- 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 URL comparison for mentions in case of empty path (#36613 and #36626 by @ClearlyClaire)
|
|
||||||
- Fix hashtags not being picked up when full-width hash sign is used (#36103 and #36625 by @ClearlyClaire and @Gargron)
|
|
||||||
- Fix layout of severed relationships when purged events are listed (#36593 by @mejofi)
|
|
||||||
- Fix vacuum tasks being interrupted by a single batch failure (#36606 by @Gargron)
|
|
||||||
- Fix handling of unreachable network error for search services (#36587 by @mjankowski)
|
|
||||||
- Fix bookmarks export when a bookmarked status is soft-deleted (#36576 by @ClearlyClaire)
|
|
||||||
- Fix text overflow alignment for long author names in News (#36562 by @diondiondion)
|
|
||||||
- Fix discovery preamble missing word in admin settings (#36560 by @belatedly)
|
|
||||||
- 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)
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
|
|
||||||
- Remove support for PostgreSQL 13 (#36540 by @renchap)
|
|
||||||
|
|
||||||
## [4.4.8] - 2025-10-21
|
|
||||||
|
|
||||||
### Security
|
|
||||||
|
|
||||||
- Fix quote control bypass ([GHSA-8h43-rcqj-wpc6](https://github.com/mastodon/mastodon/security/advisories/GHSA-8h43-rcqj-wpc6))
|
|
||||||
|
|
||||||
## [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
|
## [4.4.4] - 2025-09-16
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|||||||
12
Dockerfile
12
Dockerfile
@@ -13,10 +13,10 @@ ARG BASE_REGISTRY="docker.io"
|
|||||||
|
|
||||||
# Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.4.x"]
|
# Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.4.x"]
|
||||||
# renovate: datasource=docker depName=docker.io/ruby
|
# renovate: datasource=docker depName=docker.io/ruby
|
||||||
ARG RUBY_VERSION="3.4.7"
|
ARG RUBY_VERSION="3.4.6"
|
||||||
# # Node.js version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="22"]
|
# # Node.js version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"]
|
||||||
# renovate: datasource=node-version depName=node
|
# renovate: datasource=node-version depName=node
|
||||||
ARG NODE_MAJOR_VERSION="24"
|
ARG NODE_MAJOR_VERSION="22"
|
||||||
# Debian image to use for base image, change with [--build-arg DEBIAN_VERSION="trixie"]
|
# Debian image to use for base image, change with [--build-arg DEBIAN_VERSION="trixie"]
|
||||||
ARG DEBIAN_VERSION="trixie"
|
ARG DEBIAN_VERSION="trixie"
|
||||||
# Node.js image to use for base image based on combined variables (ex: 20-trixie-slim)
|
# Node.js image to use for base image based on combined variables (ex: 20-trixie-slim)
|
||||||
@@ -208,12 +208,12 @@ FROM build AS ffmpeg
|
|||||||
# renovate: datasource=repology depName=ffmpeg packageName=openpkg_current/ffmpeg
|
# renovate: datasource=repology depName=ffmpeg packageName=openpkg_current/ffmpeg
|
||||||
ARG FFMPEG_VERSION=8.0
|
ARG FFMPEG_VERSION=8.0
|
||||||
# ffmpeg download URL, change with [--build-arg FFMPEG_URL="https://ffmpeg.org/releases"]
|
# ffmpeg download URL, change with [--build-arg FFMPEG_URL="https://ffmpeg.org/releases"]
|
||||||
ARG FFMPEG_URL=https://github.com/FFmpeg/FFmpeg/archive/refs/tags
|
ARG FFMPEG_URL=https://ffmpeg.org/releases
|
||||||
|
|
||||||
WORKDIR /usr/local/ffmpeg/src
|
WORKDIR /usr/local/ffmpeg/src
|
||||||
# Download and extract ffmpeg source code
|
# Download and extract ffmpeg source code
|
||||||
ADD ${FFMPEG_URL}/n${FFMPEG_VERSION}.tar.gz /usr/local/ffmpeg/src/
|
ADD ${FFMPEG_URL}/ffmpeg-${FFMPEG_VERSION}.tar.xz /usr/local/ffmpeg/src/
|
||||||
RUN tar xf n${FFMPEG_VERSION}.tar.gz && mv FFmpeg-n${FFMPEG_VERSION} ffmpeg-${FFMPEG_VERSION};
|
RUN tar xf ffmpeg-${FFMPEG_VERSION}.tar.xz;
|
||||||
|
|
||||||
WORKDIR /usr/local/ffmpeg/src/ffmpeg-${FFMPEG_VERSION}
|
WORKDIR /usr/local/ffmpeg/src/ffmpeg-${FFMPEG_VERSION}
|
||||||
|
|
||||||
|
|||||||
35
Gemfile
35
Gemfile
@@ -4,12 +4,12 @@ source 'https://rubygems.org'
|
|||||||
ruby '>= 3.2.0', '< 3.5.0'
|
ruby '>= 3.2.0', '< 3.5.0'
|
||||||
|
|
||||||
gem 'propshaft'
|
gem 'propshaft'
|
||||||
gem 'puma', '~> 7.0'
|
gem 'puma', '~> 6.3'
|
||||||
gem 'rails', '~> 8.0'
|
gem 'rails', '~> 8.0'
|
||||||
gem 'thor', '~> 1.2'
|
gem 'thor', '~> 1.2'
|
||||||
|
|
||||||
gem 'dotenv'
|
gem 'dotenv'
|
||||||
gem 'haml-rails', '~>3.0'
|
gem 'haml-rails', '~>2.0'
|
||||||
gem 'pg', '~> 1.5'
|
gem 'pg', '~> 1.5'
|
||||||
gem 'pghero'
|
gem 'pghero'
|
||||||
|
|
||||||
@@ -105,20 +105,20 @@ gem 'prometheus_exporter', '~> 2.2', require: false
|
|||||||
gem 'opentelemetry-api', '~> 1.7.0'
|
gem 'opentelemetry-api', '~> 1.7.0'
|
||||||
|
|
||||||
group :opentelemetry do
|
group :opentelemetry do
|
||||||
gem 'opentelemetry-exporter-otlp', '~> 0.31.0', require: false
|
gem 'opentelemetry-exporter-otlp', '~> 0.30.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-active_job', '~> 0.10.0', require: false
|
gem 'opentelemetry-instrumentation-active_job', '~> 0.8.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.24.0', require: false
|
gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.22.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.24.0', require: false
|
gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.22.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-excon', '~> 0.26.0', require: false
|
gem 'opentelemetry-instrumentation-excon', '~> 0.24.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-faraday', '~> 0.30.0', require: false
|
gem 'opentelemetry-instrumentation-faraday', '~> 0.28.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-http', '~> 0.27.0', require: false
|
gem 'opentelemetry-instrumentation-http', '~> 0.25.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-http_client', '~> 0.26.0', require: false
|
gem 'opentelemetry-instrumentation-http_client', '~> 0.24.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-net_http', '~> 0.26.0', require: false
|
gem 'opentelemetry-instrumentation-net_http', '~> 0.24.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-pg', '~> 0.32.0', require: false
|
gem 'opentelemetry-instrumentation-pg', '~> 0.30.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-rack', '~> 0.29.0', require: false
|
gem 'opentelemetry-instrumentation-rack', '~> 0.27.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-rails', '~> 0.39.0', require: false
|
gem 'opentelemetry-instrumentation-rails', '~> 0.37.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-redis', '~> 0.28.0', require: false
|
gem 'opentelemetry-instrumentation-redis', '~> 0.26.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-sidekiq', '~> 0.28.0', require: false
|
gem 'opentelemetry-instrumentation-sidekiq', '~> 0.26.0', require: false
|
||||||
gem 'opentelemetry-sdk', '~> 1.4', require: false
|
gem 'opentelemetry-sdk', '~> 1.4', require: false
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -160,9 +160,6 @@ group :test do
|
|||||||
|
|
||||||
# Stub web requests for specs
|
# Stub web requests for specs
|
||||||
gem 'webmock', '~> 3.18'
|
gem 'webmock', '~> 3.18'
|
||||||
|
|
||||||
# Websocket driver for testing integration between rails/sidekiq and streaming
|
|
||||||
gem 'websocket-driver', '~> 0.8', require: false
|
|
||||||
end
|
end
|
||||||
|
|
||||||
group :development do
|
group :development do
|
||||||
|
|||||||
422
Gemfile.lock
422
Gemfile.lock
@@ -10,29 +10,29 @@ GIT
|
|||||||
GEM
|
GEM
|
||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
specs:
|
||||||
actioncable (8.0.3)
|
actioncable (8.0.2.1)
|
||||||
actionpack (= 8.0.3)
|
actionpack (= 8.0.2.1)
|
||||||
activesupport (= 8.0.3)
|
activesupport (= 8.0.2.1)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
websocket-driver (>= 0.6.1)
|
websocket-driver (>= 0.6.1)
|
||||||
zeitwerk (~> 2.6)
|
zeitwerk (~> 2.6)
|
||||||
actionmailbox (8.0.3)
|
actionmailbox (8.0.2.1)
|
||||||
actionpack (= 8.0.3)
|
actionpack (= 8.0.2.1)
|
||||||
activejob (= 8.0.3)
|
activejob (= 8.0.2.1)
|
||||||
activerecord (= 8.0.3)
|
activerecord (= 8.0.2.1)
|
||||||
activestorage (= 8.0.3)
|
activestorage (= 8.0.2.1)
|
||||||
activesupport (= 8.0.3)
|
activesupport (= 8.0.2.1)
|
||||||
mail (>= 2.8.0)
|
mail (>= 2.8.0)
|
||||||
actionmailer (8.0.3)
|
actionmailer (8.0.2.1)
|
||||||
actionpack (= 8.0.3)
|
actionpack (= 8.0.2.1)
|
||||||
actionview (= 8.0.3)
|
actionview (= 8.0.2.1)
|
||||||
activejob (= 8.0.3)
|
activejob (= 8.0.2.1)
|
||||||
activesupport (= 8.0.3)
|
activesupport (= 8.0.2.1)
|
||||||
mail (>= 2.8.0)
|
mail (>= 2.8.0)
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
actionpack (8.0.3)
|
actionpack (8.0.2.1)
|
||||||
actionview (= 8.0.3)
|
actionview (= 8.0.2.1)
|
||||||
activesupport (= 8.0.3)
|
activesupport (= 8.0.2.1)
|
||||||
nokogiri (>= 1.8.5)
|
nokogiri (>= 1.8.5)
|
||||||
rack (>= 2.2.4)
|
rack (>= 2.2.4)
|
||||||
rack-session (>= 1.0.1)
|
rack-session (>= 1.0.1)
|
||||||
@@ -40,15 +40,15 @@ GEM
|
|||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
rails-html-sanitizer (~> 1.6)
|
rails-html-sanitizer (~> 1.6)
|
||||||
useragent (~> 0.16)
|
useragent (~> 0.16)
|
||||||
actiontext (8.0.3)
|
actiontext (8.0.2.1)
|
||||||
actionpack (= 8.0.3)
|
actionpack (= 8.0.2.1)
|
||||||
activerecord (= 8.0.3)
|
activerecord (= 8.0.2.1)
|
||||||
activestorage (= 8.0.3)
|
activestorage (= 8.0.2.1)
|
||||||
activesupport (= 8.0.3)
|
activesupport (= 8.0.2.1)
|
||||||
globalid (>= 0.6.0)
|
globalid (>= 0.6.0)
|
||||||
nokogiri (>= 1.8.5)
|
nokogiri (>= 1.8.5)
|
||||||
actionview (8.0.3)
|
actionview (8.0.2.1)
|
||||||
activesupport (= 8.0.3)
|
activesupport (= 8.0.2.1)
|
||||||
builder (~> 3.1)
|
builder (~> 3.1)
|
||||||
erubi (~> 1.11)
|
erubi (~> 1.11)
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
@@ -58,22 +58,22 @@ GEM
|
|||||||
activemodel (>= 4.1)
|
activemodel (>= 4.1)
|
||||||
case_transform (>= 0.2)
|
case_transform (>= 0.2)
|
||||||
jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
|
jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
|
||||||
activejob (8.0.3)
|
activejob (8.0.2.1)
|
||||||
activesupport (= 8.0.3)
|
activesupport (= 8.0.2.1)
|
||||||
globalid (>= 0.3.6)
|
globalid (>= 0.3.6)
|
||||||
activemodel (8.0.3)
|
activemodel (8.0.2.1)
|
||||||
activesupport (= 8.0.3)
|
activesupport (= 8.0.2.1)
|
||||||
activerecord (8.0.3)
|
activerecord (8.0.2.1)
|
||||||
activemodel (= 8.0.3)
|
activemodel (= 8.0.2.1)
|
||||||
activesupport (= 8.0.3)
|
activesupport (= 8.0.2.1)
|
||||||
timeout (>= 0.4.0)
|
timeout (>= 0.4.0)
|
||||||
activestorage (8.0.3)
|
activestorage (8.0.2.1)
|
||||||
actionpack (= 8.0.3)
|
actionpack (= 8.0.2.1)
|
||||||
activejob (= 8.0.3)
|
activejob (= 8.0.2.1)
|
||||||
activerecord (= 8.0.3)
|
activerecord (= 8.0.2.1)
|
||||||
activesupport (= 8.0.3)
|
activesupport (= 8.0.2.1)
|
||||||
marcel (~> 1.0)
|
marcel (~> 1.0)
|
||||||
activesupport (8.0.3)
|
activesupport (8.0.2.1)
|
||||||
base64
|
base64
|
||||||
benchmark (>= 0.3)
|
benchmark (>= 0.3)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
@@ -90,13 +90,13 @@ GEM
|
|||||||
public_suffix (>= 2.0.2, < 7.0)
|
public_suffix (>= 2.0.2, < 7.0)
|
||||||
aes_key_wrap (1.1.0)
|
aes_key_wrap (1.1.0)
|
||||||
android_key_attestation (0.3.0)
|
android_key_attestation (0.3.0)
|
||||||
annotaterb (4.20.0)
|
annotaterb (4.19.0)
|
||||||
activerecord (>= 6.0.0)
|
activerecord (>= 6.0.0)
|
||||||
activesupport (>= 6.0.0)
|
activesupport (>= 6.0.0)
|
||||||
ast (2.4.3)
|
ast (2.4.3)
|
||||||
attr_required (1.0.2)
|
attr_required (1.0.2)
|
||||||
aws-eventstream (1.4.0)
|
aws-eventstream (1.4.0)
|
||||||
aws-partitions (1.1168.0)
|
aws-partitions (1.1135.0)
|
||||||
aws-sdk-core (3.215.1)
|
aws-sdk-core (3.215.1)
|
||||||
aws-eventstream (~> 1, >= 1.3.0)
|
aws-eventstream (~> 1, >= 1.3.0)
|
||||||
aws-partitions (~> 1, >= 1.992.0)
|
aws-partitions (~> 1, >= 1.992.0)
|
||||||
@@ -116,12 +116,12 @@ GEM
|
|||||||
base64 (0.3.0)
|
base64 (0.3.0)
|
||||||
bcp47_spec (0.2.1)
|
bcp47_spec (0.2.1)
|
||||||
bcrypt (3.1.20)
|
bcrypt (3.1.20)
|
||||||
benchmark (0.5.0)
|
benchmark (0.4.1)
|
||||||
better_errors (2.10.1)
|
better_errors (2.10.1)
|
||||||
erubi (>= 1.0.0)
|
erubi (>= 1.0.0)
|
||||||
rack (>= 0.9.0)
|
rack (>= 0.9.0)
|
||||||
rouge (>= 1.0.0)
|
rouge (>= 1.0.0)
|
||||||
bigdecimal (3.3.1)
|
bigdecimal (3.2.3)
|
||||||
bindata (2.5.1)
|
bindata (2.5.1)
|
||||||
binding_of_caller (1.0.1)
|
binding_of_caller (1.0.1)
|
||||||
debug_inspector (>= 1.2.0)
|
debug_inspector (>= 1.2.0)
|
||||||
@@ -150,7 +150,7 @@ GEM
|
|||||||
playwright-ruby-client (>= 1.16.0)
|
playwright-ruby-client (>= 1.16.0)
|
||||||
case_transform (0.2)
|
case_transform (0.2)
|
||||||
activesupport
|
activesupport
|
||||||
cbor (0.5.10.1)
|
cbor (0.5.9.8)
|
||||||
cgi (0.4.2)
|
cgi (0.4.2)
|
||||||
charlock_holmes (0.7.9)
|
charlock_holmes (0.7.9)
|
||||||
chewy (7.6.0)
|
chewy (7.6.0)
|
||||||
@@ -168,7 +168,7 @@ GEM
|
|||||||
cose (1.3.1)
|
cose (1.3.1)
|
||||||
cbor (~> 0.5.9)
|
cbor (~> 0.5.9)
|
||||||
openssl-signature_algorithm (~> 1.0)
|
openssl-signature_algorithm (~> 1.0)
|
||||||
crack (1.0.1)
|
crack (1.0.0)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
rexml
|
rexml
|
||||||
crass (1.0.6)
|
crass (1.0.6)
|
||||||
@@ -190,10 +190,10 @@ GEM
|
|||||||
railties (>= 4.1.0)
|
railties (>= 4.1.0)
|
||||||
responders
|
responders
|
||||||
warden (~> 1.2.3)
|
warden (~> 1.2.3)
|
||||||
devise-two-factor (6.2.0)
|
devise-two-factor (6.1.0)
|
||||||
activesupport (>= 7.0, < 8.2)
|
activesupport (>= 7.0, < 8.1)
|
||||||
devise (~> 4.0)
|
devise (~> 4.0)
|
||||||
railties (>= 7.0, < 8.2)
|
railties (>= 7.0, < 8.1)
|
||||||
rotp (~> 6.0)
|
rotp (~> 6.0)
|
||||||
devise_pam_authenticatable2 (9.2.0)
|
devise_pam_authenticatable2 (9.2.0)
|
||||||
devise (>= 4.0.0)
|
devise (>= 4.0.0)
|
||||||
@@ -207,7 +207,7 @@ GEM
|
|||||||
railties (>= 5)
|
railties (>= 5)
|
||||||
dotenv (3.1.8)
|
dotenv (3.1.8)
|
||||||
drb (2.2.3)
|
drb (2.2.3)
|
||||||
dry-cli (1.3.0)
|
dry-cli (1.2.0)
|
||||||
elasticsearch (7.17.11)
|
elasticsearch (7.17.11)
|
||||||
elasticsearch-api (= 7.17.11)
|
elasticsearch-api (= 7.17.11)
|
||||||
elasticsearch-transport (= 7.17.11)
|
elasticsearch-transport (= 7.17.11)
|
||||||
@@ -224,20 +224,20 @@ GEM
|
|||||||
mail (~> 2.7)
|
mail (~> 2.7)
|
||||||
email_validator (2.2.4)
|
email_validator (2.2.4)
|
||||||
activemodel
|
activemodel
|
||||||
erb (5.1.1)
|
erb (5.0.2)
|
||||||
erubi (1.13.1)
|
erubi (1.13.1)
|
||||||
et-orbi (1.4.0)
|
et-orbi (1.2.11)
|
||||||
tzinfo
|
tzinfo
|
||||||
excon (1.3.0)
|
excon (1.2.8)
|
||||||
logger
|
logger
|
||||||
fabrication (3.0.0)
|
fabrication (3.0.0)
|
||||||
faker (3.5.2)
|
faker (3.5.2)
|
||||||
i18n (>= 1.8.11, < 2)
|
i18n (>= 1.8.11, < 2)
|
||||||
faraday (2.14.0)
|
faraday (2.13.4)
|
||||||
faraday-net_http (>= 2.0, < 3.5)
|
faraday-net_http (>= 2.0, < 3.5)
|
||||||
json
|
json
|
||||||
logger
|
logger
|
||||||
faraday-follow_redirects (0.4.0)
|
faraday-follow_redirects (0.3.0)
|
||||||
faraday (>= 1, < 3)
|
faraday (>= 1, < 3)
|
||||||
faraday-httpclient (2.0.2)
|
faraday-httpclient (2.0.2)
|
||||||
httpclient (>= 2.2)
|
httpclient (>= 2.2)
|
||||||
@@ -266,24 +266,23 @@ GEM
|
|||||||
fog-openstack (1.1.5)
|
fog-openstack (1.1.5)
|
||||||
fog-core (~> 2.1)
|
fog-core (~> 2.1)
|
||||||
fog-json (>= 1.0)
|
fog-json (>= 1.0)
|
||||||
formatador (1.2.1)
|
formatador (1.1.1)
|
||||||
reline
|
|
||||||
forwardable (1.3.3)
|
forwardable (1.3.3)
|
||||||
fugit (1.12.0)
|
fugit (1.11.1)
|
||||||
et-orbi (~> 1.4)
|
et-orbi (~> 1, >= 1.2.11)
|
||||||
raabro (~> 1.4)
|
raabro (~> 1.4)
|
||||||
globalid (1.3.0)
|
globalid (1.2.1)
|
||||||
activesupport (>= 6.1)
|
activesupport (>= 6.1)
|
||||||
google-protobuf (4.32.1)
|
google-protobuf (4.31.1)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
rake (>= 13)
|
rake (>= 13)
|
||||||
googleapis-common-protos-types (1.22.0)
|
googleapis-common-protos-types (1.20.0)
|
||||||
google-protobuf (~> 4.26)
|
google-protobuf (>= 3.18, < 5.a)
|
||||||
haml (6.3.0)
|
haml (6.3.0)
|
||||||
temple (>= 0.8.2)
|
temple (>= 0.8.2)
|
||||||
thor
|
thor
|
||||||
tilt
|
tilt
|
||||||
haml-rails (3.0.0)
|
haml-rails (2.1.0)
|
||||||
actionpack (>= 5.1)
|
actionpack (>= 5.1)
|
||||||
activesupport (>= 5.1)
|
activesupport (>= 5.1)
|
||||||
haml (>= 4.0.6)
|
haml (>= 4.0.6)
|
||||||
@@ -294,15 +293,15 @@ GEM
|
|||||||
rainbow
|
rainbow
|
||||||
rubocop (>= 1.0)
|
rubocop (>= 1.0)
|
||||||
sysexits (~> 1.1)
|
sysexits (~> 1.1)
|
||||||
hashdiff (1.2.1)
|
hashdiff (1.2.0)
|
||||||
hashie (5.0.0)
|
hashie (5.0.0)
|
||||||
hcaptcha (7.1.0)
|
hcaptcha (7.1.0)
|
||||||
json
|
json
|
||||||
highline (3.1.2)
|
highline (3.1.2)
|
||||||
reline
|
reline
|
||||||
hiredis (0.6.3)
|
hiredis (0.6.3)
|
||||||
hiredis-client (0.26.1)
|
hiredis-client (0.25.3)
|
||||||
redis-client (= 0.26.1)
|
redis-client (= 0.25.3)
|
||||||
hkdf (0.3.0)
|
hkdf (0.3.0)
|
||||||
htmlentities (4.3.4)
|
htmlentities (4.3.4)
|
||||||
http (5.3.1)
|
http (5.3.1)
|
||||||
@@ -310,7 +309,7 @@ GEM
|
|||||||
http-cookie (~> 1.0)
|
http-cookie (~> 1.0)
|
||||||
http-form_data (~> 2.2)
|
http-form_data (~> 2.2)
|
||||||
llhttp-ffi (~> 0.5.0)
|
llhttp-ffi (~> 0.5.0)
|
||||||
http-cookie (1.1.0)
|
http-cookie (1.0.8)
|
||||||
domain_name (~> 0.5)
|
domain_name (~> 0.5)
|
||||||
http-form_data (2.3.0)
|
http-form_data (2.3.0)
|
||||||
http_accept_language (2.1.1)
|
http_accept_language (2.1.1)
|
||||||
@@ -346,9 +345,9 @@ GEM
|
|||||||
azure-blob (~> 0.5.2)
|
azure-blob (~> 0.5.2)
|
||||||
hashie (~> 5.0)
|
hashie (~> 5.0)
|
||||||
jmespath (1.6.2)
|
jmespath (1.6.2)
|
||||||
json (2.15.1)
|
json (2.13.2)
|
||||||
json-canonicalization (1.0.0)
|
json-canonicalization (1.0.0)
|
||||||
json-jwt (1.17.0)
|
json-jwt (1.16.7)
|
||||||
activesupport (>= 4.2)
|
activesupport (>= 4.2)
|
||||||
aes_key_wrap
|
aes_key_wrap
|
||||||
base64
|
base64
|
||||||
@@ -426,8 +425,7 @@ GEM
|
|||||||
loofah (2.24.1)
|
loofah (2.24.1)
|
||||||
crass (~> 1.0.2)
|
crass (~> 1.0.2)
|
||||||
nokogiri (>= 1.12.0)
|
nokogiri (>= 1.12.0)
|
||||||
mail (2.9.0)
|
mail (2.8.1)
|
||||||
logger
|
|
||||||
mini_mime (>= 0.1.1)
|
mini_mime (>= 0.1.1)
|
||||||
net-imap
|
net-imap
|
||||||
net-pop
|
net-pop
|
||||||
@@ -440,16 +438,16 @@ GEM
|
|||||||
mime-types (3.7.0)
|
mime-types (3.7.0)
|
||||||
logger
|
logger
|
||||||
mime-types-data (~> 3.2025, >= 3.2025.0507)
|
mime-types-data (~> 3.2025, >= 3.2025.0507)
|
||||||
mime-types-data (3.2025.0924)
|
mime-types-data (3.2025.0916)
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
mini_portile2 (2.8.9)
|
mini_portile2 (2.8.9)
|
||||||
minitest (5.26.0)
|
minitest (5.25.5)
|
||||||
msgpack (1.8.0)
|
msgpack (1.8.0)
|
||||||
multi_json (1.17.0)
|
multi_json (1.17.0)
|
||||||
mutex_m (0.3.0)
|
mutex_m (0.3.0)
|
||||||
net-http (0.6.0)
|
net-http (0.6.0)
|
||||||
uri
|
uri
|
||||||
net-imap (0.5.12)
|
net-imap (0.5.9)
|
||||||
date
|
date
|
||||||
net-protocol
|
net-protocol
|
||||||
net-ldap (0.20.0)
|
net-ldap (0.20.0)
|
||||||
@@ -468,9 +466,8 @@ GEM
|
|||||||
oj (3.16.11)
|
oj (3.16.11)
|
||||||
bigdecimal (>= 3.0)
|
bigdecimal (>= 3.0)
|
||||||
ostruct (>= 0.2)
|
ostruct (>= 0.2)
|
||||||
omniauth (2.1.4)
|
omniauth (2.1.3)
|
||||||
hashie (>= 3.4.6)
|
hashie (>= 3.4.6)
|
||||||
logger
|
|
||||||
rack (>= 2.2.3)
|
rack (>= 2.2.3)
|
||||||
rack-protection
|
rack-protection
|
||||||
omniauth-cas (3.0.2)
|
omniauth-cas (3.0.2)
|
||||||
@@ -499,77 +496,102 @@ GEM
|
|||||||
tzinfo
|
tzinfo
|
||||||
validate_url
|
validate_url
|
||||||
webfinger (~> 2.0)
|
webfinger (~> 2.0)
|
||||||
openssl (3.3.2)
|
openssl (3.3.0)
|
||||||
openssl-signature_algorithm (1.3.0)
|
openssl-signature_algorithm (1.3.0)
|
||||||
openssl (> 2.0)
|
openssl (> 2.0)
|
||||||
opentelemetry-api (1.7.0)
|
opentelemetry-api (1.7.0)
|
||||||
opentelemetry-common (0.23.0)
|
opentelemetry-common (0.22.0)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-exporter-otlp (0.31.1)
|
opentelemetry-exporter-otlp (0.30.0)
|
||||||
google-protobuf (>= 3.18)
|
google-protobuf (>= 3.18)
|
||||||
googleapis-common-protos-types (~> 1.3)
|
googleapis-common-protos-types (~> 1.3)
|
||||||
opentelemetry-api (~> 1.1)
|
opentelemetry-api (~> 1.1)
|
||||||
opentelemetry-common (~> 0.20)
|
opentelemetry-common (~> 0.20)
|
||||||
opentelemetry-sdk (~> 1.10)
|
opentelemetry-sdk (~> 1.2)
|
||||||
opentelemetry-semantic_conventions
|
opentelemetry-semantic_conventions
|
||||||
opentelemetry-helpers-sql (0.2.0)
|
opentelemetry-helpers-sql (0.1.1)
|
||||||
opentelemetry-api (~> 1.7)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-helpers-sql-obfuscation (0.4.0)
|
opentelemetry-helpers-sql-obfuscation (0.3.0)
|
||||||
opentelemetry-common (~> 0.21)
|
opentelemetry-common (~> 0.21)
|
||||||
opentelemetry-instrumentation-action_mailer (0.6.1)
|
opentelemetry-instrumentation-action_mailer (0.4.0)
|
||||||
opentelemetry-instrumentation-active_support (~> 0.10)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-action_pack (0.15.1)
|
opentelemetry-instrumentation-active_support (~> 0.7)
|
||||||
opentelemetry-instrumentation-rack (~> 0.29)
|
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||||
opentelemetry-instrumentation-action_view (0.11.1)
|
opentelemetry-instrumentation-action_pack (0.13.0)
|
||||||
opentelemetry-instrumentation-active_support (~> 0.10)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-active_job (0.10.1)
|
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.25)
|
opentelemetry-instrumentation-rack (~> 0.21)
|
||||||
opentelemetry-instrumentation-active_model_serializers (0.24.0)
|
opentelemetry-instrumentation-action_view (0.9.0)
|
||||||
|
opentelemetry-api (~> 1.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_support (>= 0.7.0)
|
opentelemetry-instrumentation-active_support (>= 0.7.0)
|
||||||
opentelemetry-instrumentation-active_record (0.11.1)
|
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.25)
|
opentelemetry-instrumentation-active_record (0.9.0)
|
||||||
opentelemetry-instrumentation-active_storage (0.3.1)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-active_support (~> 0.10)
|
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||||
opentelemetry-instrumentation-active_support (0.10.1)
|
opentelemetry-instrumentation-active_storage (0.1.1)
|
||||||
opentelemetry-instrumentation-base (~> 0.25)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-base (0.25.0)
|
opentelemetry-instrumentation-active_support (~> 0.7)
|
||||||
opentelemetry-api (~> 1.7)
|
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||||
|
opentelemetry-instrumentation-active_support (0.8.0)
|
||||||
|
opentelemetry-api (~> 1.0)
|
||||||
|
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||||
|
opentelemetry-instrumentation-base (0.23.0)
|
||||||
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-common (~> 0.21)
|
opentelemetry-common (~> 0.21)
|
||||||
opentelemetry-registry (~> 0.1)
|
opentelemetry-registry (~> 0.1)
|
||||||
opentelemetry-instrumentation-concurrent_ruby (0.24.0)
|
opentelemetry-instrumentation-concurrent_ruby (0.22.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.25)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-excon (0.26.0)
|
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.25)
|
opentelemetry-instrumentation-excon (0.24.0)
|
||||||
opentelemetry-instrumentation-faraday (0.30.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.25)
|
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||||
opentelemetry-instrumentation-http (0.27.0)
|
opentelemetry-instrumentation-faraday (0.28.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.25)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-http_client (0.26.0)
|
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.25)
|
opentelemetry-instrumentation-http (0.25.1)
|
||||||
opentelemetry-instrumentation-net_http (0.26.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.25)
|
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||||
opentelemetry-instrumentation-pg (0.32.0)
|
opentelemetry-instrumentation-http_client (0.24.0)
|
||||||
|
opentelemetry-api (~> 1.0)
|
||||||
|
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||||
|
opentelemetry-instrumentation-net_http (0.24.0)
|
||||||
|
opentelemetry-api (~> 1.0)
|
||||||
|
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||||
|
opentelemetry-instrumentation-pg (0.30.1)
|
||||||
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-helpers-sql
|
opentelemetry-helpers-sql
|
||||||
opentelemetry-helpers-sql-obfuscation
|
opentelemetry-helpers-sql-obfuscation
|
||||||
opentelemetry-instrumentation-base (~> 0.25)
|
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||||
opentelemetry-instrumentation-rack (0.29.0)
|
opentelemetry-instrumentation-rack (0.27.1)
|
||||||
opentelemetry-instrumentation-base (~> 0.25)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-rails (0.39.1)
|
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||||
opentelemetry-instrumentation-action_mailer (~> 0.6)
|
opentelemetry-instrumentation-rails (0.37.0)
|
||||||
opentelemetry-instrumentation-action_pack (~> 0.15)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-action_view (~> 0.11)
|
opentelemetry-instrumentation-action_mailer (~> 0.4.0)
|
||||||
opentelemetry-instrumentation-active_job (~> 0.10)
|
opentelemetry-instrumentation-action_pack (~> 0.13.0)
|
||||||
opentelemetry-instrumentation-active_record (~> 0.11)
|
opentelemetry-instrumentation-action_view (~> 0.9.0)
|
||||||
opentelemetry-instrumentation-active_storage (~> 0.3)
|
opentelemetry-instrumentation-active_job (~> 0.8.0)
|
||||||
opentelemetry-instrumentation-active_support (~> 0.10)
|
opentelemetry-instrumentation-active_record (~> 0.9.0)
|
||||||
opentelemetry-instrumentation-concurrent_ruby (~> 0.23)
|
opentelemetry-instrumentation-active_storage (~> 0.1.0)
|
||||||
opentelemetry-instrumentation-redis (0.28.0)
|
opentelemetry-instrumentation-active_support (~> 0.8.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.25)
|
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||||
opentelemetry-instrumentation-sidekiq (0.28.0)
|
opentelemetry-instrumentation-concurrent_ruby (~> 0.22.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.25)
|
opentelemetry-instrumentation-redis (0.26.1)
|
||||||
|
opentelemetry-api (~> 1.0)
|
||||||
|
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||||
|
opentelemetry-instrumentation-sidekiq (0.26.1)
|
||||||
|
opentelemetry-api (~> 1.0)
|
||||||
|
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||||
opentelemetry-registry (0.4.0)
|
opentelemetry-registry (0.4.0)
|
||||||
opentelemetry-api (~> 1.1)
|
opentelemetry-api (~> 1.1)
|
||||||
opentelemetry-sdk (1.10.0)
|
opentelemetry-sdk (1.9.0)
|
||||||
opentelemetry-api (~> 1.1)
|
opentelemetry-api (~> 1.1)
|
||||||
opentelemetry-common (~> 0.20)
|
opentelemetry-common (~> 0.20)
|
||||||
opentelemetry-registry (~> 0.2)
|
opentelemetry-registry (~> 0.2)
|
||||||
@@ -593,7 +615,7 @@ GEM
|
|||||||
playwright-ruby-client (1.55.0)
|
playwright-ruby-client (1.55.0)
|
||||||
concurrent-ruby (>= 1.1.6)
|
concurrent-ruby (>= 1.1.6)
|
||||||
mime-types (>= 3.0)
|
mime-types (>= 3.0)
|
||||||
pp (0.6.3)
|
pp (0.6.2)
|
||||||
prettyprint
|
prettyprint
|
||||||
premailer (1.27.0)
|
premailer (1.27.0)
|
||||||
addressable
|
addressable
|
||||||
@@ -604,10 +626,10 @@ GEM
|
|||||||
net-smtp
|
net-smtp
|
||||||
premailer (~> 1.7, >= 1.7.9)
|
premailer (~> 1.7, >= 1.7.9)
|
||||||
prettyprint (0.2.0)
|
prettyprint (0.2.0)
|
||||||
prism (1.5.2)
|
prism (1.4.0)
|
||||||
prometheus_exporter (2.3.0)
|
prometheus_exporter (2.3.0)
|
||||||
webrick
|
webrick
|
||||||
propshaft (1.3.1)
|
propshaft (1.2.1)
|
||||||
actionpack (>= 7.0.0)
|
actionpack (>= 7.0.0)
|
||||||
activesupport (>= 7.0.0)
|
activesupport (>= 7.0.0)
|
||||||
rack
|
rack
|
||||||
@@ -615,14 +637,14 @@ GEM
|
|||||||
date
|
date
|
||||||
stringio
|
stringio
|
||||||
public_suffix (6.0.2)
|
public_suffix (6.0.2)
|
||||||
puma (7.1.0)
|
puma (6.6.1)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
pundit (2.5.2)
|
pundit (2.5.1)
|
||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
raabro (1.4.0)
|
raabro (1.4.0)
|
||||||
racc (1.8.1)
|
racc (1.8.1)
|
||||||
rack (3.2.3)
|
rack (3.1.16)
|
||||||
rack-attack (6.8.0)
|
rack-attack (6.7.0)
|
||||||
rack (>= 1.0, < 4)
|
rack (>= 1.0, < 4)
|
||||||
rack-cors (3.0.0)
|
rack-cors (3.0.0)
|
||||||
logger
|
logger
|
||||||
@@ -647,20 +669,20 @@ GEM
|
|||||||
rack (>= 1.3)
|
rack (>= 1.3)
|
||||||
rackup (2.2.1)
|
rackup (2.2.1)
|
||||||
rack (>= 3)
|
rack (>= 3)
|
||||||
rails (8.0.3)
|
rails (8.0.2.1)
|
||||||
actioncable (= 8.0.3)
|
actioncable (= 8.0.2.1)
|
||||||
actionmailbox (= 8.0.3)
|
actionmailbox (= 8.0.2.1)
|
||||||
actionmailer (= 8.0.3)
|
actionmailer (= 8.0.2.1)
|
||||||
actionpack (= 8.0.3)
|
actionpack (= 8.0.2.1)
|
||||||
actiontext (= 8.0.3)
|
actiontext (= 8.0.2.1)
|
||||||
actionview (= 8.0.3)
|
actionview (= 8.0.2.1)
|
||||||
activejob (= 8.0.3)
|
activejob (= 8.0.2.1)
|
||||||
activemodel (= 8.0.3)
|
activemodel (= 8.0.2.1)
|
||||||
activerecord (= 8.0.3)
|
activerecord (= 8.0.2.1)
|
||||||
activestorage (= 8.0.3)
|
activestorage (= 8.0.2.1)
|
||||||
activesupport (= 8.0.3)
|
activesupport (= 8.0.2.1)
|
||||||
bundler (>= 1.15.0)
|
bundler (>= 1.15.0)
|
||||||
railties (= 8.0.3)
|
railties (= 8.0.2.1)
|
||||||
rails-dom-testing (2.3.0)
|
rails-dom-testing (2.3.0)
|
||||||
activesupport (>= 5.0.0)
|
activesupport (>= 5.0.0)
|
||||||
minitest
|
minitest
|
||||||
@@ -671,14 +693,13 @@ GEM
|
|||||||
rails-i18n (8.0.2)
|
rails-i18n (8.0.2)
|
||||||
i18n (>= 0.7, < 2)
|
i18n (>= 0.7, < 2)
|
||||||
railties (>= 8.0.0, < 9)
|
railties (>= 8.0.0, < 9)
|
||||||
railties (8.0.3)
|
railties (8.0.2.1)
|
||||||
actionpack (= 8.0.3)
|
actionpack (= 8.0.2.1)
|
||||||
activesupport (= 8.0.3)
|
activesupport (= 8.0.2.1)
|
||||||
irb (~> 1.13)
|
irb (~> 1.13)
|
||||||
rackup (>= 1.0.0)
|
rackup (>= 1.0.0)
|
||||||
rake (>= 12.2)
|
rake (>= 12.2)
|
||||||
thor (~> 1.0, >= 1.2.2)
|
thor (~> 1.0, >= 1.2.2)
|
||||||
tsort (>= 0.2)
|
|
||||||
zeitwerk (~> 2.6)
|
zeitwerk (~> 2.6)
|
||||||
rainbow (3.1.1)
|
rainbow (3.1.1)
|
||||||
rake (13.3.0)
|
rake (13.3.0)
|
||||||
@@ -691,27 +712,26 @@ GEM
|
|||||||
readline (~> 0.0)
|
readline (~> 0.0)
|
||||||
rdf-normalize (0.7.0)
|
rdf-normalize (0.7.0)
|
||||||
rdf (~> 3.3)
|
rdf (~> 3.3)
|
||||||
rdoc (6.15.0)
|
rdoc (6.14.2)
|
||||||
erb
|
erb
|
||||||
psych (>= 4.0.0)
|
psych (>= 4.0.0)
|
||||||
tsort
|
|
||||||
readline (0.0.4)
|
readline (0.0.4)
|
||||||
reline
|
reline
|
||||||
redcarpet (3.6.1)
|
redcarpet (3.6.1)
|
||||||
redis (4.8.1)
|
redis (4.8.1)
|
||||||
redis-client (0.26.1)
|
redis-client (0.25.3)
|
||||||
connection_pool
|
connection_pool
|
||||||
regexp_parser (2.11.3)
|
regexp_parser (2.11.2)
|
||||||
reline (0.6.2)
|
reline (0.6.2)
|
||||||
io-console (~> 0.5)
|
io-console (~> 0.5)
|
||||||
request_store (1.7.0)
|
request_store (1.7.0)
|
||||||
rack (>= 1.4)
|
rack (>= 1.4)
|
||||||
responders (3.2.0)
|
responders (3.1.1)
|
||||||
actionpack (>= 7.0)
|
actionpack (>= 5.2)
|
||||||
railties (>= 7.0)
|
railties (>= 5.2)
|
||||||
rexml (3.4.4)
|
rexml (3.4.4)
|
||||||
rotp (6.3.0)
|
rotp (6.3.0)
|
||||||
rouge (4.6.1)
|
rouge (4.6.0)
|
||||||
rpam2 (4.0.2)
|
rpam2 (4.0.2)
|
||||||
rqrcode (3.1.0)
|
rqrcode (3.1.0)
|
||||||
chunky_png (~> 1.0)
|
chunky_png (~> 1.0)
|
||||||
@@ -744,8 +764,8 @@ GEM
|
|||||||
rspec-expectations (~> 3.0)
|
rspec-expectations (~> 3.0)
|
||||||
rspec-mocks (~> 3.0)
|
rspec-mocks (~> 3.0)
|
||||||
sidekiq (>= 5, < 9)
|
sidekiq (>= 5, < 9)
|
||||||
rspec-support (3.13.6)
|
rspec-support (3.13.4)
|
||||||
rubocop (1.81.6)
|
rubocop (1.80.2)
|
||||||
json (~> 2.3)
|
json (~> 2.3)
|
||||||
language_server-protocol (~> 3.17.0.2)
|
language_server-protocol (~> 3.17.0.2)
|
||||||
lint_roller (~> 1.1.0)
|
lint_roller (~> 1.1.0)
|
||||||
@@ -753,10 +773,10 @@ GEM
|
|||||||
parser (>= 3.3.0.2)
|
parser (>= 3.3.0.2)
|
||||||
rainbow (>= 2.2.2, < 4.0)
|
rainbow (>= 2.2.2, < 4.0)
|
||||||
regexp_parser (>= 2.9.3, < 3.0)
|
regexp_parser (>= 2.9.3, < 3.0)
|
||||||
rubocop-ast (>= 1.47.1, < 2.0)
|
rubocop-ast (>= 1.46.0, < 2.0)
|
||||||
ruby-progressbar (~> 1.7)
|
ruby-progressbar (~> 1.7)
|
||||||
unicode-display_width (>= 2.4.0, < 4.0)
|
unicode-display_width (>= 2.4.0, < 4.0)
|
||||||
rubocop-ast (1.47.1)
|
rubocop-ast (1.46.0)
|
||||||
parser (>= 3.3.7.2)
|
parser (>= 3.3.7.2)
|
||||||
prism (~> 1.4)
|
prism (~> 1.4)
|
||||||
rubocop-capybara (2.22.1)
|
rubocop-capybara (2.22.1)
|
||||||
@@ -765,11 +785,11 @@ GEM
|
|||||||
rubocop-i18n (3.2.3)
|
rubocop-i18n (3.2.3)
|
||||||
lint_roller (~> 1.1)
|
lint_roller (~> 1.1)
|
||||||
rubocop (>= 1.72.1)
|
rubocop (>= 1.72.1)
|
||||||
rubocop-performance (1.26.1)
|
rubocop-performance (1.26.0)
|
||||||
lint_roller (~> 1.1)
|
lint_roller (~> 1.1)
|
||||||
rubocop (>= 1.75.0, < 2.0)
|
rubocop (>= 1.75.0, < 2.0)
|
||||||
rubocop-ast (>= 1.47.1, < 2.0)
|
rubocop-ast (>= 1.44.0, < 2.0)
|
||||||
rubocop-rails (2.33.4)
|
rubocop-rails (2.33.3)
|
||||||
activesupport (>= 4.2.0)
|
activesupport (>= 4.2.0)
|
||||||
lint_roller (~> 1.1)
|
lint_roller (~> 1.1)
|
||||||
rack (>= 1.1)
|
rack (>= 1.1)
|
||||||
@@ -791,11 +811,11 @@ GEM
|
|||||||
ruby-vips (2.2.5)
|
ruby-vips (2.2.5)
|
||||||
ffi (~> 1.12)
|
ffi (~> 1.12)
|
||||||
logger
|
logger
|
||||||
rubyzip (3.2.1)
|
rubyzip (3.1.0)
|
||||||
rufus-scheduler (3.9.2)
|
rufus-scheduler (3.9.2)
|
||||||
fugit (~> 1.1, >= 1.11.1)
|
fugit (~> 1.1, >= 1.11.1)
|
||||||
safety_net_attestation (0.5.0)
|
safety_net_attestation (0.4.0)
|
||||||
jwt (>= 2.0, < 4.0)
|
jwt (~> 2.0)
|
||||||
sanitize (7.0.0)
|
sanitize (7.0.0)
|
||||||
crass (~> 1.0.2)
|
crass (~> 1.0.2)
|
||||||
nokogiri (>= 1.16.8)
|
nokogiri (>= 1.16.8)
|
||||||
@@ -805,7 +825,7 @@ GEM
|
|||||||
securerandom (0.4.1)
|
securerandom (0.4.1)
|
||||||
shoulda-matchers (6.5.0)
|
shoulda-matchers (6.5.0)
|
||||||
activesupport (>= 5.2.0)
|
activesupport (>= 5.2.0)
|
||||||
sidekiq (8.0.8)
|
sidekiq (8.0.7)
|
||||||
connection_pool (>= 2.5.0)
|
connection_pool (>= 2.5.0)
|
||||||
json (>= 2.9.0)
|
json (>= 2.9.0)
|
||||||
logger (>= 1.6.2)
|
logger (>= 1.6.2)
|
||||||
@@ -822,9 +842,9 @@ GEM
|
|||||||
thor (>= 1.0, < 3.0)
|
thor (>= 1.0, < 3.0)
|
||||||
simple-navigation (4.4.0)
|
simple-navigation (4.4.0)
|
||||||
activesupport (>= 2.3.2)
|
activesupport (>= 2.3.2)
|
||||||
simple_form (5.4.0)
|
simple_form (5.3.1)
|
||||||
actionpack (>= 7.0)
|
actionpack (>= 5.2)
|
||||||
activemodel (>= 7.0)
|
activemodel (>= 5.2)
|
||||||
simplecov (0.22.0)
|
simplecov (0.22.0)
|
||||||
docile (~> 1.1)
|
docile (~> 1.1)
|
||||||
simplecov-html (~> 0.11)
|
simplecov-html (~> 0.11)
|
||||||
@@ -835,10 +855,10 @@ GEM
|
|||||||
stackprof (0.2.27)
|
stackprof (0.2.27)
|
||||||
starry (0.2.0)
|
starry (0.2.0)
|
||||||
base64
|
base64
|
||||||
stoplight (5.4.0)
|
stoplight (5.3.8)
|
||||||
zeitwerk
|
zeitwerk
|
||||||
stringio (3.1.7)
|
stringio (3.1.7)
|
||||||
strong_migrations (2.5.1)
|
strong_migrations (2.5.0)
|
||||||
activerecord (>= 7.1)
|
activerecord (>= 7.1)
|
||||||
swd (2.0.3)
|
swd (2.0.3)
|
||||||
activesupport (>= 3)
|
activesupport (>= 3)
|
||||||
@@ -859,7 +879,6 @@ GEM
|
|||||||
bindata (~> 2.4)
|
bindata (~> 2.4)
|
||||||
openssl (> 2.0)
|
openssl (> 2.0)
|
||||||
openssl-signature_algorithm (~> 1.0)
|
openssl-signature_algorithm (~> 1.0)
|
||||||
tsort (0.2.0)
|
|
||||||
tty-color (0.6.0)
|
tty-color (0.6.0)
|
||||||
tty-cursor (0.7.1)
|
tty-cursor (0.7.1)
|
||||||
tty-prompt (0.23.1)
|
tty-prompt (0.23.1)
|
||||||
@@ -880,10 +899,10 @@ GEM
|
|||||||
unf (0.1.4)
|
unf (0.1.4)
|
||||||
unf_ext
|
unf_ext
|
||||||
unf_ext (0.0.9.1)
|
unf_ext (0.0.9.1)
|
||||||
unicode-display_width (3.2.0)
|
unicode-display_width (3.1.5)
|
||||||
unicode-emoji (~> 4.1)
|
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||||
unicode-emoji (4.1.0)
|
unicode-emoji (4.0.4)
|
||||||
uri (1.0.4)
|
uri (1.0.3)
|
||||||
useragent (0.16.11)
|
useragent (0.16.11)
|
||||||
validate_url (1.0.15)
|
validate_url (1.0.15)
|
||||||
activemodel (>= 3.0.0)
|
activemodel (>= 3.0.0)
|
||||||
@@ -899,19 +918,19 @@ GEM
|
|||||||
zeitwerk (~> 2.2)
|
zeitwerk (~> 2.2)
|
||||||
warden (1.2.9)
|
warden (1.2.9)
|
||||||
rack (>= 2.0.9)
|
rack (>= 2.0.9)
|
||||||
webauthn (3.4.3)
|
webauthn (3.4.1)
|
||||||
android_key_attestation (~> 0.3.0)
|
android_key_attestation (~> 0.3.0)
|
||||||
bindata (~> 2.4)
|
bindata (~> 2.4)
|
||||||
cbor (~> 0.5.9)
|
cbor (~> 0.5.9)
|
||||||
cose (~> 1.1)
|
cose (~> 1.1)
|
||||||
openssl (>= 2.2)
|
openssl (>= 2.2)
|
||||||
safety_net_attestation (~> 0.5.0)
|
safety_net_attestation (~> 0.4.0)
|
||||||
tpm-key_attestation (~> 0.14.0)
|
tpm-key_attestation (~> 0.14.0)
|
||||||
webfinger (2.1.3)
|
webfinger (2.1.3)
|
||||||
activesupport
|
activesupport
|
||||||
faraday (~> 2.0)
|
faraday (~> 2.0)
|
||||||
faraday-follow_redirects
|
faraday-follow_redirects
|
||||||
webmock (3.26.0)
|
webmock (3.25.1)
|
||||||
addressable (>= 2.8.0)
|
addressable (>= 2.8.0)
|
||||||
crack (>= 0.3.2)
|
crack (>= 0.3.2)
|
||||||
hashdiff (>= 0.4.0, < 2.0.0)
|
hashdiff (>= 0.4.0, < 2.0.0)
|
||||||
@@ -969,7 +988,7 @@ DEPENDENCIES
|
|||||||
flatware-rspec
|
flatware-rspec
|
||||||
fog-core (<= 2.6.0)
|
fog-core (<= 2.6.0)
|
||||||
fog-openstack (~> 1.0)
|
fog-openstack (~> 1.0)
|
||||||
haml-rails (~> 3.0)
|
haml-rails (~> 2.0)
|
||||||
haml_lint
|
haml_lint
|
||||||
hcaptcha (~> 7.1)
|
hcaptcha (~> 7.1)
|
||||||
hiredis (~> 0.6)
|
hiredis (~> 0.6)
|
||||||
@@ -1009,20 +1028,20 @@ DEPENDENCIES
|
|||||||
omniauth-saml (~> 2.0)
|
omniauth-saml (~> 2.0)
|
||||||
omniauth_openid_connect (~> 0.8.0)
|
omniauth_openid_connect (~> 0.8.0)
|
||||||
opentelemetry-api (~> 1.7.0)
|
opentelemetry-api (~> 1.7.0)
|
||||||
opentelemetry-exporter-otlp (~> 0.31.0)
|
opentelemetry-exporter-otlp (~> 0.30.0)
|
||||||
opentelemetry-instrumentation-active_job (~> 0.10.0)
|
opentelemetry-instrumentation-active_job (~> 0.8.0)
|
||||||
opentelemetry-instrumentation-active_model_serializers (~> 0.24.0)
|
opentelemetry-instrumentation-active_model_serializers (~> 0.22.0)
|
||||||
opentelemetry-instrumentation-concurrent_ruby (~> 0.24.0)
|
opentelemetry-instrumentation-concurrent_ruby (~> 0.22.0)
|
||||||
opentelemetry-instrumentation-excon (~> 0.26.0)
|
opentelemetry-instrumentation-excon (~> 0.24.0)
|
||||||
opentelemetry-instrumentation-faraday (~> 0.30.0)
|
opentelemetry-instrumentation-faraday (~> 0.28.0)
|
||||||
opentelemetry-instrumentation-http (~> 0.27.0)
|
opentelemetry-instrumentation-http (~> 0.25.0)
|
||||||
opentelemetry-instrumentation-http_client (~> 0.26.0)
|
opentelemetry-instrumentation-http_client (~> 0.24.0)
|
||||||
opentelemetry-instrumentation-net_http (~> 0.26.0)
|
opentelemetry-instrumentation-net_http (~> 0.24.0)
|
||||||
opentelemetry-instrumentation-pg (~> 0.32.0)
|
opentelemetry-instrumentation-pg (~> 0.30.0)
|
||||||
opentelemetry-instrumentation-rack (~> 0.29.0)
|
opentelemetry-instrumentation-rack (~> 0.27.0)
|
||||||
opentelemetry-instrumentation-rails (~> 0.39.0)
|
opentelemetry-instrumentation-rails (~> 0.37.0)
|
||||||
opentelemetry-instrumentation-redis (~> 0.28.0)
|
opentelemetry-instrumentation-redis (~> 0.26.0)
|
||||||
opentelemetry-instrumentation-sidekiq (~> 0.28.0)
|
opentelemetry-instrumentation-sidekiq (~> 0.26.0)
|
||||||
opentelemetry-sdk (~> 1.4)
|
opentelemetry-sdk (~> 1.4)
|
||||||
ox (~> 2.14)
|
ox (~> 2.14)
|
||||||
parslet
|
parslet
|
||||||
@@ -1033,7 +1052,7 @@ DEPENDENCIES
|
|||||||
prometheus_exporter (~> 2.2)
|
prometheus_exporter (~> 2.2)
|
||||||
propshaft
|
propshaft
|
||||||
public_suffix (~> 6.0)
|
public_suffix (~> 6.0)
|
||||||
puma (~> 7.0)
|
puma (~> 6.3)
|
||||||
pundit (~> 2.3)
|
pundit (~> 2.3)
|
||||||
rack-attack (~> 6.6)
|
rack-attack (~> 6.6)
|
||||||
rack-cors
|
rack-cors
|
||||||
@@ -1081,11 +1100,10 @@ DEPENDENCIES
|
|||||||
webauthn (~> 3.0)
|
webauthn (~> 3.0)
|
||||||
webmock (~> 3.18)
|
webmock (~> 3.18)
|
||||||
webpush!
|
webpush!
|
||||||
websocket-driver (~> 0.8)
|
|
||||||
xorcist (~> 1.1)
|
xorcist (~> 1.1)
|
||||||
|
|
||||||
RUBY VERSION
|
RUBY VERSION
|
||||||
ruby 3.4.1p0
|
ruby 3.4.1p0
|
||||||
|
|
||||||
BUNDLED WITH
|
BUNDLED WITH
|
||||||
2.7.2
|
2.7.1
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ Mastodon is a **free, open-source social network server** based on [ActivityPub]
|
|||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
- **Ruby** 3.2+
|
- **Ruby** 3.2+
|
||||||
- **PostgreSQL** 14+
|
- **PostgreSQL** 13+
|
||||||
- **Redis** 7.0+
|
- **Redis** 7.0+
|
||||||
- **Node.js** 20+
|
- **Node.js** 20+
|
||||||
|
|
||||||
|
|||||||
@@ -71,10 +71,6 @@ class AccountsController < ApplicationController
|
|||||||
params[:username]
|
params[:username]
|
||||||
end
|
end
|
||||||
|
|
||||||
def account_id_param
|
|
||||||
params[:id]
|
|
||||||
end
|
|
||||||
|
|
||||||
def skip_temporary_suspension_response?
|
def skip_temporary_suspension_response?
|
||||||
request.format == :json
|
request.format == :json
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class ActivityPub::LikesController < ActivityPub::BaseController
|
|||||||
|
|
||||||
def likes_collection_presenter
|
def likes_collection_presenter
|
||||||
ActivityPub::CollectionPresenter.new(
|
ActivityPub::CollectionPresenter.new(
|
||||||
id: ActivityPub::TagManager.instance.likes_uri_for(@status),
|
id: account_status_likes_url(@account, @status),
|
||||||
type: :unordered,
|
type: :unordered,
|
||||||
size: @status.favourites_count
|
size: @status.favourites_count
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -73,8 +73,6 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def set_account
|
def set_account
|
||||||
return super if params[:account_username].present? || params[:account_id].present?
|
@account = params[:account_username].present? ? Account.find_local!(username_param) : Account.representative
|
||||||
|
|
||||||
@account = Account.representative
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController
|
|||||||
|
|
||||||
def replies_collection_presenter
|
def replies_collection_presenter
|
||||||
page = ActivityPub::CollectionPresenter.new(
|
page = ActivityPub::CollectionPresenter.new(
|
||||||
id: ActivityPub::TagManager.instance.replies_uri_for(@status, page_params),
|
id: account_status_replies_url(@account, @status, page_params),
|
||||||
type: :unordered,
|
type: :unordered,
|
||||||
part_of: account_status_replies_url(@account, @status),
|
part_of: account_status_replies_url(@account, @status),
|
||||||
next: next_page,
|
next: next_page,
|
||||||
@@ -47,7 +47,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController
|
|||||||
return page if page_requested?
|
return page if page_requested?
|
||||||
|
|
||||||
ActivityPub::CollectionPresenter.new(
|
ActivityPub::CollectionPresenter.new(
|
||||||
id: ActivityPub::TagManager.instance.replies_uri_for(@status),
|
id: account_status_replies_url(@account, @status),
|
||||||
type: :unordered,
|
type: :unordered,
|
||||||
first: page
|
first: page
|
||||||
)
|
)
|
||||||
@@ -66,7 +66,8 @@ class ActivityPub::RepliesController < ActivityPub::BaseController
|
|||||||
# Only consider remote accounts
|
# Only consider remote accounts
|
||||||
return nil if @replies.size < DESCENDANTS_LIMIT
|
return nil if @replies.size < DESCENDANTS_LIMIT
|
||||||
|
|
||||||
ActivityPub::TagManager.instance.replies_uri_for(
|
account_status_replies_url(
|
||||||
|
@account,
|
||||||
@status,
|
@status,
|
||||||
page: true,
|
page: true,
|
||||||
min_id: @replies&.last&.id,
|
min_id: @replies&.last&.id,
|
||||||
@@ -76,7 +77,8 @@ class ActivityPub::RepliesController < ActivityPub::BaseController
|
|||||||
# For now, we're serving only self-replies, but next page might be other accounts
|
# For now, we're serving only self-replies, but next page might be other accounts
|
||||||
next_only_other_accounts = @replies&.last&.account_id != @account.id || @replies.size < DESCENDANTS_LIMIT
|
next_only_other_accounts = @replies&.last&.account_id != @account.id || @replies.size < DESCENDANTS_LIMIT
|
||||||
|
|
||||||
ActivityPub::TagManager.instance.replies_uri_for(
|
account_status_replies_url(
|
||||||
|
@account,
|
||||||
@status,
|
@status,
|
||||||
page: true,
|
page: true,
|
||||||
min_id: next_only_other_accounts ? nil : @replies&.last&.id,
|
min_id: next_only_other_accounts ? nil : @replies&.last&.id,
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class ActivityPub::SharesController < ActivityPub::BaseController
|
|||||||
|
|
||||||
def shares_collection_presenter
|
def shares_collection_presenter
|
||||||
ActivityPub::CollectionPresenter.new(
|
ActivityPub::CollectionPresenter.new(
|
||||||
id: ActivityPub::TagManager.instance.shares_uri_for(@status),
|
id: account_status_shares_url(@account, @status),
|
||||||
type: :unordered,
|
type: :unordered,
|
||||||
size: @status.reblogs_count
|
size: @status.reblogs_count
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,16 +9,10 @@ module Admin
|
|||||||
|
|
||||||
@pending_appeals_count = Appeal.pending.async_count
|
@pending_appeals_count = Appeal.pending.async_count
|
||||||
@pending_reports_count = Report.unresolved.async_count
|
@pending_reports_count = Report.unresolved.async_count
|
||||||
@pending_tags_count = pending_tags.async_count
|
@pending_tags_count = Tag.pending_review.async_count
|
||||||
@pending_users_count = User.pending.async_count
|
@pending_users_count = User.pending.async_count
|
||||||
@system_checks = Admin::SystemCheck.perform(current_user)
|
@system_checks = Admin::SystemCheck.perform(current_user)
|
||||||
@time_period = (29.days.ago.to_date...Time.now.utc.to_date)
|
@time_period = (29.days.ago.to_date...Time.now.utc.to_date)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def pending_tags
|
|
||||||
::Trends::TagFilter.new(status: :pending_review).results
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ class Api::V1::Statuses::InteractionPoliciesController < Api::V1::Statuses::Base
|
|||||||
include Api::InteractionPoliciesConcern
|
include Api::InteractionPoliciesConcern
|
||||||
|
|
||||||
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }
|
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }
|
||||||
|
before_action -> { check_feature_enabled }
|
||||||
|
|
||||||
def update
|
def update
|
||||||
authorize @status, :update?
|
authorize @status, :update?
|
||||||
@@ -21,8 +22,12 @@ class Api::V1::Statuses::InteractionPoliciesController < Api::V1::Statuses::Base
|
|||||||
params.permit(:quote_approval_policy)
|
params.permit(:quote_approval_policy)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def check_feature_enabled
|
||||||
|
raise ActionController::RoutingError unless Mastodon::Feature.outgoing_quotes_enabled?
|
||||||
|
end
|
||||||
|
|
||||||
def broadcast_updates!
|
def broadcast_updates!
|
||||||
DistributionWorker.perform_async(@status.id, { 'update' => true, 'skip_notifications' => true })
|
DistributionWorker.perform_async(@status.id, { 'update' => true })
|
||||||
ActivityPub::StatusUpdateDistributionWorker.perform_async(@status.id, { 'updated_at' => Time.now.utc.iso8601 })
|
ActivityPub::StatusUpdateDistributionWorker.perform_async(@status.id, { 'updated_at' => Time.now.utc.iso8601 })
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ class Api::V1::Statuses::QuotesController < Api::V1::Statuses::BaseController
|
|||||||
before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, only: :index
|
before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, only: :index
|
||||||
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: :revoke
|
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: :revoke
|
||||||
|
|
||||||
before_action :set_statuses, only: :index
|
before_action :check_owner!
|
||||||
|
|
||||||
before_action :set_quote, only: :revoke
|
before_action :set_quote, only: :revoke
|
||||||
after_action :insert_pagination_headers, only: :index
|
after_action :insert_pagination_headers, only: :index
|
||||||
|
|
||||||
def index
|
def index
|
||||||
cache_if_unauthenticated!
|
cache_if_unauthenticated!
|
||||||
|
@statuses = load_statuses
|
||||||
render json: @statuses, each_serializer: REST::StatusSerializer
|
render json: @statuses, each_serializer: REST::StatusSerializer
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -24,26 +24,18 @@ class Api::V1::Statuses::QuotesController < Api::V1::Statuses::BaseController
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def check_owner!
|
||||||
|
authorize @status, :list_quotes?
|
||||||
|
end
|
||||||
|
|
||||||
def set_quote
|
def set_quote
|
||||||
@quote = @status.quotes.find_by!(status_id: params[:id])
|
@quote = @status.quotes.find_by!(status_id: params[:id])
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_statuses
|
def load_statuses
|
||||||
scope = default_statuses
|
scope = default_statuses
|
||||||
scope = scope.not_excluded_by_account(current_account) unless current_account.nil?
|
scope = scope.not_excluded_by_account(current_account) unless current_account.nil?
|
||||||
@statuses = scope.merge(paginated_quotes).to_a
|
scope.merge(paginated_quotes).to_a
|
||||||
|
|
||||||
# Store next page info before filtering
|
|
||||||
@records_continue = @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT)
|
|
||||||
@pagination_since_id = @statuses.first.quote.id unless @statuses.empty?
|
|
||||||
@pagination_max_id = @statuses.last.quote.id if @records_continue
|
|
||||||
|
|
||||||
if current_account&.id != @status.account_id
|
|
||||||
domains = @statuses.filter_map(&:account_domain).uniq
|
|
||||||
account_ids = @statuses.map(&:account_id).uniq
|
|
||||||
relations = current_account&.relations_map(account_ids, domains) || {}
|
|
||||||
@statuses.reject! { |status| StatusFilter.new(status, current_account, relations).filtered? }
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def default_statuses
|
def default_statuses
|
||||||
@@ -66,9 +58,15 @@ class Api::V1::Statuses::QuotesController < Api::V1::Statuses::BaseController
|
|||||||
api_v1_status_quotes_url pagination_params(since_id: pagination_since_id) unless @statuses.empty?
|
api_v1_status_quotes_url pagination_params(since_id: pagination_since_id) unless @statuses.empty?
|
||||||
end
|
end
|
||||||
|
|
||||||
attr_reader :pagination_max_id, :pagination_since_id
|
def pagination_max_id
|
||||||
|
@statuses.last.quote.id
|
||||||
|
end
|
||||||
|
|
||||||
|
def pagination_since_id
|
||||||
|
@statuses.first.quote.id
|
||||||
|
end
|
||||||
|
|
||||||
def records_continue?
|
def records_continue?
|
||||||
@records_continue
|
@statuses.size == limit_param(DEFAULT_STATUSES_LIMIT)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -159,7 +159,9 @@ class Api::V1::StatusesController < Api::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def set_quoted_status
|
def set_quoted_status
|
||||||
@quoted_status = Status.find(status_params[:quoted_status_id])&.proper if status_params[:quoted_status_id].present?
|
return unless Mastodon::Feature.outgoing_quotes_enabled?
|
||||||
|
|
||||||
|
@quoted_status = Status.find(status_params[:quoted_status_id]) if status_params[:quoted_status_id].present?
|
||||||
authorize(@quoted_status, :quote?) if @quoted_status.present?
|
authorize(@quoted_status, :quote?) if @quoted_status.present?
|
||||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||||
# TODO: distinguish between non-existing and non-quotable posts
|
# TODO: distinguish between non-existing and non-quotable posts
|
||||||
|
|||||||
@@ -3,8 +3,14 @@
|
|||||||
class Api::V1::Timelines::BaseController < Api::BaseController
|
class Api::V1::Timelines::BaseController < Api::BaseController
|
||||||
after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
|
after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
|
||||||
|
|
||||||
|
before_action :require_user!, if: :require_auth?
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def require_auth?
|
||||||
|
!Setting.timeline_preview
|
||||||
|
end
|
||||||
|
|
||||||
def pagination_collection
|
def pagination_collection
|
||||||
@statuses
|
@statuses
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
class Api::V1::Timelines::HomeController < Api::V1::Timelines::BaseController
|
class Api::V1::Timelines::HomeController < Api::V1::Timelines::BaseController
|
||||||
include AsyncRefreshesConcern
|
include AsyncRefreshesConcern
|
||||||
|
|
||||||
before_action -> { doorkeeper_authorize! :read, :'read:statuses' }
|
before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, only: [:show]
|
||||||
before_action :require_user!
|
before_action :require_user!, only: [:show]
|
||||||
|
|
||||||
PERMITTED_PARAMS = %i(local limit).freeze
|
PERMITTED_PARAMS = %i(local limit).freeze
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class Api::V1::Timelines::LinkController < Api::V1::Timelines::TopicController
|
class Api::V1::Timelines::LinkController < Api::V1::Timelines::BaseController
|
||||||
before_action -> { authorize_if_got_token! :read, :'read:statuses' }
|
before_action -> { authorize_if_got_token! :read, :'read:statuses' }
|
||||||
before_action :set_preview_card
|
before_action :set_preview_card
|
||||||
before_action :set_statuses
|
before_action :set_statuses
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
class Api::V1::Timelines::PublicController < Api::V1::Timelines::BaseController
|
class Api::V1::Timelines::PublicController < Api::V1::Timelines::BaseController
|
||||||
before_action -> { authorize_if_got_token! :read, :'read:statuses' }
|
before_action -> { authorize_if_got_token! :read, :'read:statuses' }
|
||||||
before_action :require_user!, if: :require_auth?
|
|
||||||
|
|
||||||
PERMITTED_PARAMS = %i(local remote limit only_media allow_local_only).freeze
|
PERMITTED_PARAMS = %i(local remote limit only_media allow_local_only).freeze
|
||||||
|
|
||||||
@@ -14,16 +13,6 @@ class Api::V1::Timelines::PublicController < Api::V1::Timelines::BaseController
|
|||||||
|
|
||||||
private
|
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
|
def load_statuses
|
||||||
preloaded_public_statuses_page
|
preloaded_public_statuses_page
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class Api::V1::Timelines::TagController < Api::V1::Timelines::TopicController
|
class Api::V1::Timelines::TagController < Api::V1::Timelines::BaseController
|
||||||
before_action -> { authorize_if_got_token! :read, :'read:statuses' }
|
before_action -> { authorize_if_got_token! :read, :'read:statuses' }
|
||||||
before_action :load_tag
|
before_action :load_tag
|
||||||
|
|
||||||
@@ -14,6 +14,10 @@ class Api::V1::Timelines::TagController < Api::V1::Timelines::TopicController
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def require_auth?
|
||||||
|
!Setting.timeline_preview
|
||||||
|
end
|
||||||
|
|
||||||
def load_tag
|
def load_tag
|
||||||
@tag = Tag.find_normalized(params[:id])
|
@tag = Tag.find_normalized(params[:id])
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
class Api::V1::Timelines::TopicController < Api::V1::Timelines::BaseController
|
|
||||||
before_action :require_user!, if: :require_auth?
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def require_auth?
|
|
||||||
if truthy_param?(:local)
|
|
||||||
Setting.local_topic_feed_access != 'public'
|
|
||||||
elsif truthy_param?(:remote)
|
|
||||||
Setting.remote_topic_feed_access != 'public'
|
|
||||||
else
|
|
||||||
Setting.local_topic_feed_access != 'public' || Setting.remote_topic_feed_access != 'public'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -89,7 +89,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def check_enabled_registrations
|
def check_enabled_registrations
|
||||||
redirect_to new_user_session_path, alert: I18n.t('devise.failure.closed_registrations', email: Setting.site_contact_email) unless allowed_registration?(request.remote_ip, @invite)
|
redirect_to root_path unless allowed_registration?(request.remote_ip, @invite)
|
||||||
end
|
end
|
||||||
|
|
||||||
def invite_code
|
def invite_code
|
||||||
|
|||||||
@@ -18,11 +18,7 @@ module AccountOwnedConcern
|
|||||||
end
|
end
|
||||||
|
|
||||||
def set_account
|
def set_account
|
||||||
@account = username_param.present? ? Account.find_local!(username_param) : Account.local.find(account_id_param)
|
@account = Account.find_local!(username_param)
|
||||||
end
|
|
||||||
|
|
||||||
def account_id_param
|
|
||||||
params[:account_id]
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def username_param
|
def username_param
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ module Api::InteractionPoliciesConcern
|
|||||||
extend ActiveSupport::Concern
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
def quote_approval_policy
|
def quote_approval_policy
|
||||||
|
return nil unless Mastodon::Feature.outgoing_quotes_enabled?
|
||||||
|
|
||||||
case status_params[:quote_approval_policy].presence || current_user.setting_default_quote_policy
|
case status_params[:quote_approval_policy].presence || current_user.setting_default_quote_policy
|
||||||
when 'public'
|
when 'public'
|
||||||
Status::QUOTE_APPROVAL_POLICY_FLAGS[:public] << 16
|
Status::QUOTE_APPROVAL_POLICY_FLAGS[:public] << 16
|
||||||
|
|||||||
@@ -6,9 +6,6 @@ module AsyncRefreshesConcern
|
|||||||
def add_async_refresh_header(async_refresh, retry_seconds: 3)
|
def add_async_refresh_header(async_refresh, retry_seconds: 3)
|
||||||
return unless async_refresh.running?
|
return unless async_refresh.running?
|
||||||
|
|
||||||
value = "id=\"#{async_refresh.id}\", retry=#{retry_seconds}"
|
response.headers['Mastodon-Async-Refresh'] = "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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -58,22 +58,20 @@ class FollowerAccountsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def collection_presenter
|
def collection_presenter
|
||||||
options = {}
|
options = { type: :ordered }
|
||||||
options[:size] = @account.followers_count unless Setting.hide_followers_count || @account.user&.setting_hide_followers_count
|
options[:size] = @account.followers_count unless Setting.hide_followers_count || @account.user&.setting_hide_followers_count
|
||||||
if page_requested?
|
if page_requested?
|
||||||
ActivityPub::CollectionPresenter.new(
|
ActivityPub::CollectionPresenter.new(
|
||||||
id: page_url(params.fetch(:page, 1)),
|
id: account_followers_url(@account, page: params.fetch(:page, 1)),
|
||||||
type: :ordered,
|
|
||||||
items: follows.map { |follow| ActivityPub::TagManager.instance.uri_for(follow.account) },
|
items: follows.map { |follow| ActivityPub::TagManager.instance.uri_for(follow.account) },
|
||||||
part_of: ActivityPub::TagManager.instance.followers_uri_for(@account),
|
part_of: account_followers_url(@account),
|
||||||
next: next_page_url,
|
next: next_page_url,
|
||||||
prev: prev_page_url,
|
prev: prev_page_url,
|
||||||
**options
|
**options
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
ActivityPub::CollectionPresenter.new(
|
ActivityPub::CollectionPresenter.new(
|
||||||
id: ActivityPub::TagManager.instance.followers_uri_for(@account),
|
id: account_followers_url(@account),
|
||||||
type: :ordered,
|
|
||||||
first: page_url(1),
|
first: page_url(1),
|
||||||
**options
|
**options
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ class FollowingAccountsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def page_url(page)
|
def page_url(page)
|
||||||
ActivityPub::TagManager.instance.following_uri_for(@account, page: page) unless page.nil?
|
account_following_index_url(@account, page: page) unless page.nil?
|
||||||
end
|
end
|
||||||
|
|
||||||
def next_page_url
|
def next_page_url
|
||||||
@@ -63,17 +63,17 @@ class FollowingAccountsController < ApplicationController
|
|||||||
def collection_presenter
|
def collection_presenter
|
||||||
if page_requested?
|
if page_requested?
|
||||||
ActivityPub::CollectionPresenter.new(
|
ActivityPub::CollectionPresenter.new(
|
||||||
id: page_url(params.fetch(:page, 1)),
|
id: account_following_index_url(@account, page: params.fetch(:page, 1)),
|
||||||
type: :ordered,
|
type: :ordered,
|
||||||
size: @account.following_count,
|
size: @account.following_count,
|
||||||
items: follows.map { |follow| ActivityPub::TagManager.instance.uri_for(follow.target_account) },
|
items: follows.map { |follow| ActivityPub::TagManager.instance.uri_for(follow.target_account) },
|
||||||
part_of: ActivityPub::TagManager.instance.following_uri_for(@account),
|
part_of: account_following_index_url(@account),
|
||||||
next: next_page_url,
|
next: next_page_url,
|
||||||
prev: prev_page_url
|
prev: prev_page_url
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
ActivityPub::CollectionPresenter.new(
|
ActivityPub::CollectionPresenter.new(
|
||||||
id: ActivityPub::TagManager.instance.following_uri_for(@account),
|
id: account_following_index_url(@account),
|
||||||
type: :ordered,
|
type: :ordered,
|
||||||
size: @account.following_count,
|
size: @account.following_count,
|
||||||
first: page_url(1)
|
first: page_url(1)
|
||||||
|
|||||||
@@ -113,7 +113,6 @@ module ApplicationHelper
|
|||||||
end
|
end
|
||||||
|
|
||||||
def material_symbol(icon, attributes = {})
|
def material_symbol(icon, attributes = {})
|
||||||
whitespace = attributes.delete(:whitespace) { true }
|
|
||||||
safe_join(
|
safe_join(
|
||||||
[
|
[
|
||||||
inline_svg_tag(
|
inline_svg_tag(
|
||||||
@@ -122,7 +121,7 @@ module ApplicationHelper
|
|||||||
role: :img,
|
role: :img,
|
||||||
data: attributes[:data]
|
data: attributes[:data]
|
||||||
),
|
),
|
||||||
whitespace ? ' ' : '',
|
' ',
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -21,13 +21,7 @@ module HomeHelper
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
account_url = if account.suspended?
|
link_to(path || ActivityPub::TagManager.instance.url_for(account), class: 'account__display-name') do
|
||||||
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
|
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)
|
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 +
|
end +
|
||||||
|
|||||||
@@ -46,14 +46,6 @@ module StatusesHelper
|
|||||||
status.preloadable_poll.options.map { |o| "[ ] #{o}" }.join("\n")
|
status.preloadable_poll.options.map { |o| "[ ] #{o}" }.join("\n")
|
||||||
end
|
end
|
||||||
|
|
||||||
def status_classnames(status, is_quote)
|
|
||||||
if is_quote
|
|
||||||
'status--is-quote'
|
|
||||||
elsif status.quote.present?
|
|
||||||
'status--has-quote'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def status_description(status)
|
def status_description(status)
|
||||||
components = [[media_summary(status), status_text_summary(status)].compact_blank.join(' · ')]
|
components = [[media_summary(status), status_text_summary(status)].compact_blank.join(' · ')]
|
||||||
|
|
||||||
@@ -65,20 +57,6 @@ module StatusesHelper
|
|||||||
components.compact_blank.join("\n\n")
|
components.compact_blank.join("\n\n")
|
||||||
end
|
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)
|
def visibility_icon(status)
|
||||||
VISIBLITY_ICONS[status.visibility.to_sym]
|
VISIBLITY_ICONS[status.visibility.to_sym]
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
{
|
|
||||||
"global": {
|
|
||||||
"class": "className",
|
|
||||||
"id": true,
|
|
||||||
"title": true,
|
|
||||||
"dir": true,
|
|
||||||
"lang": true
|
|
||||||
},
|
|
||||||
"tags": {
|
|
||||||
"p": {},
|
|
||||||
"br": {
|
|
||||||
"children": false
|
|
||||||
},
|
|
||||||
"span": {
|
|
||||||
"attributes": {
|
|
||||||
"translate": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"a": {
|
|
||||||
"attributes": {
|
|
||||||
"href": true,
|
|
||||||
"rel": true,
|
|
||||||
"translate": true,
|
|
||||||
"target": true,
|
|
||||||
"title": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"abbr": {
|
|
||||||
"attributes": {
|
|
||||||
"title": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"del": {},
|
|
||||||
"s": {},
|
|
||||||
"pre": {},
|
|
||||||
"blockquote": {
|
|
||||||
"attributes": {
|
|
||||||
"cite": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"code": {},
|
|
||||||
"b": {},
|
|
||||||
"strong": {},
|
|
||||||
"u": {},
|
|
||||||
"sub": {},
|
|
||||||
"sup": {},
|
|
||||||
"i": {},
|
|
||||||
"img": {
|
|
||||||
"children": false,
|
|
||||||
"attributes": {
|
|
||||||
"src": true,
|
|
||||||
"alt": true,
|
|
||||||
"title": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"em": {},
|
|
||||||
"h1": {},
|
|
||||||
"h2": {},
|
|
||||||
"h3": {},
|
|
||||||
"h4": {},
|
|
||||||
"h5": {},
|
|
||||||
"ul": {},
|
|
||||||
"ol": {
|
|
||||||
"attributes": {
|
|
||||||
"start": true,
|
|
||||||
"reversed": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"li": {
|
|
||||||
"attributes": {
|
|
||||||
"value": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ruby": {},
|
|
||||||
"rt": {},
|
|
||||||
"rp": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
|
|
||||||
import Rails from '@rails/ujs';
|
import Rails from '@rails/ujs';
|
||||||
import { decode, ValidationError } from 'blurhash';
|
|
||||||
|
|
||||||
import ready from '../mastodon/ready';
|
import ready from '../mastodon/ready';
|
||||||
|
|
||||||
@@ -363,46 +362,6 @@ ready(() => {
|
|||||||
document.querySelectorAll('[data-admin-component]').forEach((element) => {
|
document.querySelectorAll('[data-admin-component]').forEach((element) => {
|
||||||
void mountReactComponent(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) => {
|
}).catch((reason: unknown) => {
|
||||||
throw reason;
|
throw reason;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -197,25 +197,19 @@ export function directCompose(account) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @callback ComposeSuccessCallback
|
|
||||||
* @param {Object} status
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {null | string} overridePrivacy
|
* @param {null | string} overridePrivacy
|
||||||
* @param {undefined | ComposeSuccessCallback} successCallback
|
* @param {undefined | Function} successCallback
|
||||||
*/
|
*/
|
||||||
export function submitCompose(overridePrivacy = null, successCallback = undefined) {
|
export function submitCompose(overridePrivacy = null, successCallback = undefined) {
|
||||||
return function (dispatch, getState) {
|
return function (dispatch, getState) {
|
||||||
let status = getState().getIn(['compose', 'text'], '');
|
let status = getState().getIn(['compose', 'text'], '');
|
||||||
const media = getState().getIn(['compose', 'media_attachments']);
|
const media = getState().getIn(['compose', 'media_attachments']);
|
||||||
const statusId = getState().getIn(['compose', 'id'], null);
|
const statusId = getState().getIn(['compose', 'id'], null);
|
||||||
const hasQuote = !!getState().getIn(['compose', 'quoted_status_id']);
|
|
||||||
const spoilers = getState().getIn(['compose', 'spoiler']) || getState().getIn(['local_settings', 'always_show_spoilers_field']);
|
const spoilers = getState().getIn(['compose', 'spoiler']) || getState().getIn(['local_settings', 'always_show_spoilers_field']);
|
||||||
const spoiler_text = spoilers ? getState().getIn(['compose', 'spoiler_text'], '') : '';
|
let spoilerText = spoilers ? getState().getIn(['compose', 'spoiler_text'], '') : '';
|
||||||
|
|
||||||
if (!(status?.length || media.size !== 0 || (hasQuote && spoiler_text?.length))) {
|
if ((!status || !status.length) && media.size === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,12 +245,12 @@ export function submitCompose(overridePrivacy = null, successCallback = undefine
|
|||||||
method: statusId === null ? 'post' : 'put',
|
method: statusId === null ? 'post' : 'put',
|
||||||
data: {
|
data: {
|
||||||
status,
|
status,
|
||||||
spoiler_text,
|
|
||||||
content_type: getState().getIn(['compose', 'content_type']),
|
content_type: getState().getIn(['compose', 'content_type']),
|
||||||
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
|
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
|
||||||
media_ids: media.map(item => item.get('id')),
|
media_ids: media.map(item => item.get('id')),
|
||||||
media_attributes,
|
media_attributes,
|
||||||
sensitive: getState().getIn(['compose', 'sensitive']) || (spoiler_text.length > 0 && media.size !== 0),
|
sensitive: getState().getIn(['compose', 'sensitive']) || (spoilerText.length > 0 && media.size !== 0),
|
||||||
|
spoiler_text: spoilerText,
|
||||||
visibility: visibility,
|
visibility: visibility,
|
||||||
poll: getState().getIn(['compose', 'poll'], null),
|
poll: getState().getIn(['compose', 'poll'], null),
|
||||||
language: getState().getIn(['compose', 'language']),
|
language: getState().getIn(['compose', 'language']),
|
||||||
@@ -658,7 +652,6 @@ export function fetchComposeSuggestions(token) {
|
|||||||
fetchComposeSuggestionsEmojis(dispatch, getState, token);
|
fetchComposeSuggestionsEmojis(dispatch, getState, token);
|
||||||
break;
|
break;
|
||||||
case '#':
|
case '#':
|
||||||
case '#':
|
|
||||||
fetchComposeSuggestionsTags(dispatch, getState, token);
|
fetchComposeSuggestionsTags(dispatch, getState, token);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
@@ -700,11 +693,11 @@ export function selectComposeSuggestion(position, token, suggestion, path) {
|
|||||||
|
|
||||||
dispatch(useEmoji(suggestion));
|
dispatch(useEmoji(suggestion));
|
||||||
} else if (suggestion.type === 'hashtag') {
|
} else if (suggestion.type === 'hashtag') {
|
||||||
completion = suggestion.name.slice(token.length - 1);
|
completion = `#${suggestion.name}`;
|
||||||
startPosition = position + token.length;
|
|
||||||
} else if (suggestion.type === 'account') {
|
|
||||||
completion = `@${getState().getIn(['accounts', suggestion.id, 'acct'])}`;
|
|
||||||
startPosition = position - 1;
|
startPosition = position - 1;
|
||||||
|
} else if (suggestion.type === 'account') {
|
||||||
|
completion = getState().getIn(['accounts', suggestion.id, 'acct']);
|
||||||
|
startPosition = position;
|
||||||
}
|
}
|
||||||
|
|
||||||
// We don't want to replace hashtags that vary only in case due to accessibility, but we need to fire off an event so that
|
// We don't want to replace hashtags that vary only in case due to accessibility, but we need to fire off an event so that
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { createAction } from '@reduxjs/toolkit';
|
|||||||
import type { List as ImmutableList, Map as ImmutableMap } from 'immutable';
|
import type { List as ImmutableList, Map as ImmutableMap } from 'immutable';
|
||||||
|
|
||||||
import { apiUpdateMedia } from 'flavours/glitch/api/compose';
|
import { apiUpdateMedia } from 'flavours/glitch/api/compose';
|
||||||
import { apiGetSearch } from 'flavours/glitch/api/search';
|
|
||||||
import type { ApiMediaAttachmentJSON } from 'flavours/glitch/api_types/media_attachments';
|
import type { ApiMediaAttachmentJSON } from 'flavours/glitch/api_types/media_attachments';
|
||||||
import type { MediaAttachment } from 'flavours/glitch/models/media_attachment';
|
import type { MediaAttachment } from 'flavours/glitch/models/media_attachment';
|
||||||
import {
|
import {
|
||||||
@@ -17,14 +16,9 @@ import type { Status } from '../models/status';
|
|||||||
|
|
||||||
import { showAlert } from './alerts';
|
import { showAlert } from './alerts';
|
||||||
import { focusCompose } from './compose';
|
import { focusCompose } from './compose';
|
||||||
import { importFetchedStatuses } from './importer';
|
|
||||||
import { openModal } from './modal';
|
import { openModal } from './modal';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
quoteErrorEdit: {
|
|
||||||
id: 'quote_error.edit',
|
|
||||||
defaultMessage: 'Quotes cannot be added when editing a post.',
|
|
||||||
},
|
|
||||||
quoteErrorUpload: {
|
quoteErrorUpload: {
|
||||||
id: 'quote_error.upload',
|
id: 'quote_error.upload',
|
||||||
defaultMessage: 'Quoting is not allowed with media attachments.',
|
defaultMessage: 'Quoting is not allowed with media attachments.',
|
||||||
@@ -128,9 +122,7 @@ export const quoteComposeByStatus = createAppThunk(
|
|||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (composeState.get('id')) {
|
if (composeState.get('poll')) {
|
||||||
dispatch(showAlert({ message: messages.quoteErrorEdit }));
|
|
||||||
} else if (composeState.get('poll')) {
|
|
||||||
dispatch(showAlert({ message: messages.quoteErrorPoll }));
|
dispatch(showAlert({ message: messages.quoteErrorPoll }));
|
||||||
} else if (
|
} else if (
|
||||||
composeState.get('is_uploading') ||
|
composeState.get('is_uploading') ||
|
||||||
@@ -173,42 +165,6 @@ export const quoteComposeById = createAppThunk(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export const pasteLinkCompose = createDataLoadingThunk(
|
|
||||||
'compose/pasteLink',
|
|
||||||
async ({ url }: { url: string }) => {
|
|
||||||
return await apiGetSearch({
|
|
||||||
q: url,
|
|
||||||
type: 'statuses',
|
|
||||||
resolve: true,
|
|
||||||
limit: 2,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
(data, { dispatch, getState }) => {
|
|
||||||
const composeState = getState().compose;
|
|
||||||
|
|
||||||
if (
|
|
||||||
composeState.get('quoted_status_id') ||
|
|
||||||
composeState.get('is_submitting') ||
|
|
||||||
composeState.get('poll') ||
|
|
||||||
composeState.get('is_uploading') ||
|
|
||||||
composeState.get('id')
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
|
|
||||||
dispatch(importFetchedStatuses(data.statuses));
|
|
||||||
|
|
||||||
if (
|
|
||||||
data.statuses.length === 1 &&
|
|
||||||
data.statuses[0] &&
|
|
||||||
['automatic', 'manual'].includes(
|
|
||||||
data.statuses[0].quote_approval?.current_user ?? 'denied',
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
dispatch(quoteComposeById(data.statuses[0].id));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const quoteComposeCancel = createAction('compose/quoteComposeCancel');
|
export const quoteComposeCancel = createAction('compose/quoteComposeCancel');
|
||||||
|
|
||||||
export const setComposeQuotePolicy = createAction<ApiQuotePolicy>(
|
export const setComposeQuotePolicy = createAction<ApiQuotePolicy>(
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import escapeTextContentForBrowser from 'escape-html';
|
import escapeTextContentForBrowser from 'escape-html';
|
||||||
|
|
||||||
|
import { makeEmojiMap } from 'flavours/glitch/models/custom_emoji';
|
||||||
|
|
||||||
|
import emojify from '../../features/emoji/emoji';
|
||||||
import { autoHideCW } from '../../utils/content_warning';
|
import { autoHideCW } from '../../utils/content_warning';
|
||||||
|
|
||||||
const domParser = new DOMParser();
|
const domParser = new DOMParser();
|
||||||
@@ -77,10 +80,11 @@ export function normalizeStatus(status, normalOldStatus, settings) {
|
|||||||
} else {
|
} else {
|
||||||
const spoilerText = normalStatus.spoiler_text || '';
|
const spoilerText = normalStatus.spoiler_text || '';
|
||||||
const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
|
const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
|
||||||
|
const emojiMap = makeEmojiMap(normalStatus.emojis);
|
||||||
|
|
||||||
normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
|
normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
|
||||||
normalStatus.contentHtml = normalStatus.content;
|
normalStatus.contentHtml = emojify(normalStatus.content, emojiMap);
|
||||||
normalStatus.spoilerHtml = escapeTextContentForBrowser(spoilerText);
|
normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap);
|
||||||
normalStatus.hidden = (spoilerText.length > 0 || normalStatus.sensitive) && autoHideCW(settings, spoilerText);
|
normalStatus.hidden = (spoilerText.length > 0 || normalStatus.sensitive) && autoHideCW(settings, spoilerText);
|
||||||
|
|
||||||
// Remove quote fallback link from the DOM so it doesn't mess with paragraph margins
|
// Remove quote fallback link from the DOM so it doesn't mess with paragraph margins
|
||||||
@@ -116,12 +120,14 @@ export function normalizeStatus(status, normalOldStatus, settings) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeStatusTranslation(translation, status) {
|
export function normalizeStatusTranslation(translation, status) {
|
||||||
|
const emojiMap = makeEmojiMap(status.get('emojis').toJS());
|
||||||
|
|
||||||
const normalTranslation = {
|
const normalTranslation = {
|
||||||
detected_source_language: translation.detected_source_language,
|
detected_source_language: translation.detected_source_language,
|
||||||
language: translation.language,
|
language: translation.language,
|
||||||
provider: translation.provider,
|
provider: translation.provider,
|
||||||
contentHtml: translation.content,
|
contentHtml: emojify(translation.content, emojiMap),
|
||||||
spoilerHtml: escapeTextContentForBrowser(translation.spoiler_text),
|
spoilerHtml: emojify(escapeTextContentForBrowser(translation.spoiler_text), emojiMap),
|
||||||
spoiler_text: translation.spoiler_text,
|
spoiler_text: translation.spoiler_text,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -135,8 +141,9 @@ export function normalizeStatusTranslation(translation, status) {
|
|||||||
|
|
||||||
export function normalizeAnnouncement(announcement) {
|
export function normalizeAnnouncement(announcement) {
|
||||||
const normalAnnouncement = { ...announcement };
|
const normalAnnouncement = { ...announcement };
|
||||||
|
const emojiMap = makeEmojiMap(normalAnnouncement.emojis);
|
||||||
|
|
||||||
normalAnnouncement.contentHtml = normalAnnouncement.content;
|
normalAnnouncement.contentHtml = emojify(normalAnnouncement.content, emojiMap);
|
||||||
|
|
||||||
return normalAnnouncement;
|
return normalAnnouncement;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,8 @@ import { importFetchedStatuses } from './importer';
|
|||||||
|
|
||||||
export const fetchContext = createDataLoadingThunk(
|
export const fetchContext = createDataLoadingThunk(
|
||||||
'status/context',
|
'status/context',
|
||||||
({ statusId }: { statusId: string; prefetchOnly?: boolean }) =>
|
({ statusId }: { statusId: string }) => apiGetContext(statusId),
|
||||||
apiGetContext(statusId),
|
({ context, refresh }, { dispatch }) => {
|
||||||
({ context, refresh }, { dispatch, actionArg: { prefetchOnly = false } }) => {
|
|
||||||
const statuses = context.ancestors.concat(context.descendants);
|
const statuses = context.ancestors.concat(context.descendants);
|
||||||
|
|
||||||
dispatch(importFetchedStatuses(statuses));
|
dispatch(importFetchedStatuses(statuses));
|
||||||
@@ -19,7 +18,6 @@ export const fetchContext = createDataLoadingThunk(
|
|||||||
return {
|
return {
|
||||||
context,
|
context,
|
||||||
refresh,
|
refresh,
|
||||||
prefetchOnly,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -28,14 +26,6 @@ export const completeContextRefresh = createAction<{ statusId: string }>(
|
|||||||
'status/context/complete',
|
'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(
|
export const setStatusQuotePolicy = createDataLoadingThunk(
|
||||||
'status/setQuotePolicy',
|
'status/setQuotePolicy',
|
||||||
({ statusId, policy }: { statusId: string; policy: ApiQuotePolicy }) => {
|
({ statusId, policy }: { statusId: string; policy: ApiQuotePolicy }) => {
|
||||||
|
|||||||
@@ -32,20 +32,13 @@ import {
|
|||||||
const randomUpTo = max =>
|
const randomUpTo = max =>
|
||||||
Math.floor(Math.random() * Math.floor(max));
|
Math.floor(Math.random() * Math.floor(max));
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {import('flavours/glitch/store').AppDispatch} Dispatch
|
|
||||||
* @typedef {import('flavours/glitch/store').GetState} GetState
|
|
||||||
* @typedef {import('redux').UnknownAction} UnknownAction
|
|
||||||
* @typedef {function(Dispatch, GetState): Promise<void>} FallbackFunction
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} timelineId
|
* @param {string} timelineId
|
||||||
* @param {string} channelName
|
* @param {string} channelName
|
||||||
* @param {Object.<string, string>} params
|
* @param {Object.<string, string>} params
|
||||||
* @param {Object} options
|
* @param {Object} options
|
||||||
* @param {FallbackFunction} [options.fallback]
|
* @param {function(Function, Function): Promise<void>} [options.fallback]
|
||||||
* @param {function(): UnknownAction} [options.fillGaps]
|
* @param {function(): void} [options.fillGaps]
|
||||||
* @param {function(object): boolean} [options.accept]
|
* @param {function(object): boolean} [options.accept]
|
||||||
* @returns {function(): void}
|
* @returns {function(): void}
|
||||||
*/
|
*/
|
||||||
@@ -53,14 +46,13 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
|
|||||||
const { messages } = getLocale();
|
const { messages } = getLocale();
|
||||||
|
|
||||||
return connectStream(channelName, params, (dispatch, getState) => {
|
return connectStream(channelName, params, (dispatch, getState) => {
|
||||||
// @ts-ignore
|
|
||||||
const locale = getState().getIn(['meta', 'locale']);
|
const locale = getState().getIn(['meta', 'locale']);
|
||||||
|
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
let pollingId;
|
let pollingId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {FallbackFunction} fallback
|
* @param {function(Function, Function): Promise<void>} fallback
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const useFallback = async fallback => {
|
const useFallback = async fallback => {
|
||||||
@@ -140,7 +132,7 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Dispatch} dispatch
|
* @param {Function} dispatch
|
||||||
*/
|
*/
|
||||||
async function refreshHomeTimelineAndNotification(dispatch) {
|
async function refreshHomeTimelineAndNotification(dispatch) {
|
||||||
await dispatch(expandHomeTimeline({ maxId: undefined }));
|
await dispatch(expandHomeTimeline({ maxId: undefined }));
|
||||||
@@ -159,11 +151,7 @@ async function refreshHomeTimelineAndNotification(dispatch) {
|
|||||||
* @returns {function(): void}
|
* @returns {function(): void}
|
||||||
*/
|
*/
|
||||||
export const connectUserStream = () =>
|
export const connectUserStream = () =>
|
||||||
connectTimelineStream('home', 'user', {}, {
|
connectTimelineStream('home', 'user', {}, { fallback: refreshHomeTimelineAndNotification, fillGaps: fillHomeTimelineGaps });
|
||||||
fallback: refreshHomeTimelineAndNotification,
|
|
||||||
// @ts-expect-error
|
|
||||||
fillGaps: fillHomeTimelineGaps
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Object} options
|
* @param {Object} options
|
||||||
@@ -171,10 +159,7 @@ export const connectUserStream = () =>
|
|||||||
* @returns {function(): void}
|
* @returns {function(): void}
|
||||||
*/
|
*/
|
||||||
export const connectCommunityStream = ({ onlyMedia } = {}) =>
|
export const connectCommunityStream = ({ onlyMedia } = {}) =>
|
||||||
connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`, {}, {
|
connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`, {}, { fillGaps: () => (fillCommunityTimelineGaps({ onlyMedia })) });
|
||||||
// @ts-expect-error
|
|
||||||
fillGaps: () => (fillCommunityTimelineGaps({ onlyMedia }))
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Object} options
|
* @param {Object} options
|
||||||
@@ -184,10 +169,7 @@ export const connectCommunityStream = ({ onlyMedia } = {}) =>
|
|||||||
* @returns {function(): void}
|
* @returns {function(): void}
|
||||||
*/
|
*/
|
||||||
export const connectPublicStream = ({ onlyMedia, onlyRemote, allowLocalOnly } = {}) =>
|
export const connectPublicStream = ({ onlyMedia, onlyRemote, allowLocalOnly } = {}) =>
|
||||||
connectTimelineStream(`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, `public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, {}, {
|
connectTimelineStream(`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, `public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, {}, { fillGaps: () => fillPublicTimelineGaps({ onlyMedia, onlyRemote, allowLocalOnly }) });
|
||||||
// @ts-expect-error
|
|
||||||
fillGaps: () => fillPublicTimelineGaps({ onlyMedia, onlyRemote, allowLocalOnly })
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} columnId
|
* @param {string} columnId
|
||||||
@@ -210,7 +192,4 @@ export const connectDirectStream = () =>
|
|||||||
* @returns {function(): void}
|
* @returns {function(): void}
|
||||||
*/
|
*/
|
||||||
export const connectListStream = listId =>
|
export const connectListStream = listId =>
|
||||||
connectTimelineStream(`list:${listId}`, 'list', { list: listId }, {
|
connectTimelineStream(`list:${listId}`, 'list', { list: listId }, { fillGaps: () => fillListTimelineGaps(listId) });
|
||||||
// @ts-expect-error
|
|
||||||
fillGaps: () => fillListTimelineGaps(listId)
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
// See app/serializers/rest/announcement_serializer.rb
|
|
||||||
|
|
||||||
import type { ApiCustomEmojiJSON } from './custom_emoji';
|
|
||||||
import type { ApiMentionJSON, ApiStatusJSON, ApiTagJSON } from './statuses';
|
|
||||||
|
|
||||||
export interface ApiAnnouncementJSON {
|
|
||||||
id: string;
|
|
||||||
content: string;
|
|
||||||
starts_at: null | string;
|
|
||||||
ends_at: null | string;
|
|
||||||
all_day: boolean;
|
|
||||||
published_at: string;
|
|
||||||
updated_at: null | string;
|
|
||||||
read: boolean;
|
|
||||||
mentions: ApiMentionJSON[];
|
|
||||||
statuses: ApiStatusJSON[];
|
|
||||||
tags: ApiTagJSON[];
|
|
||||||
emojis: ApiCustomEmojiJSON[];
|
|
||||||
reactions: ApiAnnouncementReactionJSON[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApiAnnouncementReactionJSON {
|
|
||||||
name: string;
|
|
||||||
count: number;
|
|
||||||
me: boolean;
|
|
||||||
url?: string;
|
|
||||||
static_url?: string;
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,6 @@ import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
|||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import { EmojiHTML } from '@/flavours/glitch/components/emoji/html';
|
|
||||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||||
import {
|
import {
|
||||||
blockAccount,
|
blockAccount,
|
||||||
@@ -34,7 +33,7 @@ import { me } from 'flavours/glitch/initial_state';
|
|||||||
import type { MenuItem } from 'flavours/glitch/models/dropdown_menu';
|
import type { MenuItem } from 'flavours/glitch/models/dropdown_menu';
|
||||||
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
|
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
|
||||||
|
|
||||||
import { Permalink } from '../permalink';
|
import { Permalink } from './permalink';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
||||||
@@ -334,10 +333,9 @@ export const Account: React.FC<AccountProps> = ({
|
|||||||
{account &&
|
{account &&
|
||||||
withBio &&
|
withBio &&
|
||||||
(account.note.length > 0 ? (
|
(account.note.length > 0 ? (
|
||||||
<EmojiHTML
|
<div
|
||||||
className='account__note translate'
|
className='account__note translate'
|
||||||
htmlString={account.note_emojified}
|
dangerouslySetInnerHTML={{ __html: account.note_emojified }}
|
||||||
extraEmojis={account.emojis}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className='account__note account__note--missing'>
|
<div className='account__note account__note--missing'>
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import classNames from 'classnames';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
import { useLinks } from 'flavours/glitch/hooks/useLinks';
|
||||||
|
|
||||||
|
import { EmojiHTML } from '../features/emoji/emoji_html';
|
||||||
import { useAppSelector } from '../store';
|
import { useAppSelector } from '../store';
|
||||||
|
import { isModernEmojiEnabled } from '../utils/environment';
|
||||||
import { EmojiHTML } from './emoji/html';
|
|
||||||
import { useElementHandledLink } from './status/handled_link';
|
|
||||||
|
|
||||||
interface AccountBioProps {
|
interface AccountBioProps {
|
||||||
className: string;
|
className: string;
|
||||||
@@ -16,16 +17,22 @@ export const AccountBio: React.FC<AccountBioProps> = ({
|
|||||||
accountId,
|
accountId,
|
||||||
showDropdown = false,
|
showDropdown = false,
|
||||||
}) => {
|
}) => {
|
||||||
const htmlHandlers = useElementHandledLink({
|
const handleClick = useLinks(showDropdown);
|
||||||
hashtagAccountId: showDropdown ? accountId : undefined,
|
const handleNodeChange = useCallback(
|
||||||
});
|
(node: HTMLDivElement | null) => {
|
||||||
|
if (!showDropdown || !node || node.childNodes.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
addDropdownToHashtags(node, accountId);
|
||||||
|
},
|
||||||
|
[showDropdown, accountId],
|
||||||
|
);
|
||||||
const note = useAppSelector((state) => {
|
const note = useAppSelector((state) => {
|
||||||
const account = state.accounts.get(accountId);
|
const account = state.accounts.get(accountId);
|
||||||
if (!account) {
|
if (!account) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
return account.note_emojified;
|
return isModernEmojiEnabled() ? account.note : account.note_emojified;
|
||||||
});
|
});
|
||||||
const extraEmojis = useAppSelector((state) => {
|
const extraEmojis = useAppSelector((state) => {
|
||||||
const account = state.accounts.get(accountId);
|
const account = state.accounts.get(accountId);
|
||||||
@@ -37,11 +44,33 @@ export const AccountBio: React.FC<AccountBioProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EmojiHTML
|
<div
|
||||||
htmlString={note}
|
className={`${className} translate`}
|
||||||
extraEmojis={extraEmojis}
|
onClickCapture={handleClick}
|
||||||
className={classNames(className, 'translate')}
|
ref={handleNodeChange}
|
||||||
{...htmlHandlers}
|
>
|
||||||
/>
|
<EmojiHTML htmlString={note} extraEmojis={extraEmojis} />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function addDropdownToHashtags(node: HTMLElement | null, accountId: string) {
|
||||||
|
if (!node) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const childNode of node.childNodes) {
|
||||||
|
if (!(childNode instanceof HTMLElement)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
childNode instanceof HTMLAnchorElement &&
|
||||||
|
(childNode.classList.contains('hashtag') ||
|
||||||
|
childNode.innerText.startsWith('#')) &&
|
||||||
|
!childNode.dataset.menuHashtag
|
||||||
|
) {
|
||||||
|
childNode.dataset.menuHashtag = accountId;
|
||||||
|
} else if (childNode.childNodes.length > 0) {
|
||||||
|
addDropdownToHashtags(childNode, accountId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,70 +1,42 @@
|
|||||||
import { useIntl } from 'react-intl';
|
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||||
import { Icon } from 'flavours/glitch/components/icon';
|
import { Icon } from 'flavours/glitch/components/icon';
|
||||||
|
import { useLinks } from 'flavours/glitch/hooks/useLinks';
|
||||||
import type { Account } from 'flavours/glitch/models/account';
|
import type { Account } from 'flavours/glitch/models/account';
|
||||||
|
|
||||||
import { CustomEmojiProvider } from './emoji/context';
|
export const AccountFields: React.FC<{
|
||||||
import { EmojiHTML } from './emoji/html';
|
fields: Account['fields'];
|
||||||
import { useElementHandledLink } from './status/handled_link';
|
limit: number;
|
||||||
|
}> = ({ fields, limit = -1 }) => {
|
||||||
export const AccountFields: React.FC<Pick<Account, 'fields' | 'emojis'>> = ({
|
const handleClick = useLinks();
|
||||||
fields,
|
|
||||||
emojis,
|
|
||||||
}) => {
|
|
||||||
const intl = useIntl();
|
|
||||||
const htmlHandlers = useElementHandledLink();
|
|
||||||
|
|
||||||
if (fields.size === 0) {
|
if (fields.size === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CustomEmojiProvider emojis={emojis}>
|
<div className='account-fields' onClickCapture={handleClick}>
|
||||||
{fields.map((pair, i) => (
|
{fields.take(limit).map((pair, i) => (
|
||||||
<dl key={i} className={classNames({ verified: pair.verified_at })}>
|
<dl
|
||||||
<EmojiHTML
|
key={i}
|
||||||
as='dt'
|
className={classNames({ verified: pair.get('verified_at') })}
|
||||||
htmlString={pair.name_emojified}
|
>
|
||||||
|
<dt
|
||||||
|
dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }}
|
||||||
className='translate'
|
className='translate'
|
||||||
{...htmlHandlers}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<dd className='translate' title={pair.value_plain ?? ''}>
|
<dd className='translate' title={pair.get('value_plain') ?? ''}>
|
||||||
{pair.verified_at && (
|
{pair.get('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' />
|
<Icon id='check' icon={CheckIcon} className='verified__mark' />
|
||||||
</span>
|
)}
|
||||||
)}{' '}
|
<span
|
||||||
<EmojiHTML
|
dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }}
|
||||||
as='span'
|
|
||||||
htmlString={pair.value_emojified}
|
|
||||||
{...htmlHandlers}
|
|
||||||
/>
|
/>
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
))}
|
))}
|
||||||
</CustomEmojiProvider>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const dateFormatOptions: Intl.DateTimeFormatOptions = {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ const meta = {
|
|||||||
component: Alert,
|
component: Alert,
|
||||||
args: {
|
args: {
|
||||||
isActive: true,
|
isActive: true,
|
||||||
isLoading: false,
|
|
||||||
animateFrom: 'side',
|
animateFrom: 'side',
|
||||||
title: '',
|
title: '',
|
||||||
message: '',
|
message: '',
|
||||||
@@ -21,12 +20,6 @@ const meta = {
|
|||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
description: 'Animate to the active (displayed) state of the alert',
|
description: 'Animate to the active (displayed) state of the alert',
|
||||||
},
|
},
|
||||||
isLoading: {
|
|
||||||
control: 'boolean',
|
|
||||||
type: 'boolean',
|
|
||||||
description:
|
|
||||||
'Display a loading indicator in the alert, replacing the dismiss button if present',
|
|
||||||
},
|
|
||||||
animateFrom: {
|
animateFrom: {
|
||||||
control: 'radio',
|
control: 'radio',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
@@ -115,11 +108,3 @@ export const InSizedContainer: Story = {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const WithLoadingIndicator: Story = {
|
|
||||||
args: {
|
|
||||||
...WithDismissButton.args,
|
|
||||||
isLoading: true,
|
|
||||||
},
|
|
||||||
render: InSizedContainer.render,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { useIntl } from 'react-intl';
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||||
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
|
||||||
|
|
||||||
import { IconButton } from '../icon_button';
|
import { IconButton } from '../icon_button';
|
||||||
|
|
||||||
@@ -11,23 +10,21 @@ import { IconButton } from '../icon_button';
|
|||||||
* Snackbar/Toast-style notification component.
|
* Snackbar/Toast-style notification component.
|
||||||
*/
|
*/
|
||||||
export const Alert: React.FC<{
|
export const Alert: React.FC<{
|
||||||
|
isActive?: boolean;
|
||||||
|
animateFrom?: 'side' | 'below';
|
||||||
title?: string;
|
title?: string;
|
||||||
message: string;
|
message: string;
|
||||||
action?: string;
|
action?: string;
|
||||||
onActionClick?: () => void;
|
onActionClick?: () => void;
|
||||||
onDismiss?: () => void;
|
onDismiss?: () => void;
|
||||||
isActive?: boolean;
|
|
||||||
isLoading?: boolean;
|
|
||||||
animateFrom?: 'side' | 'below';
|
|
||||||
}> = ({
|
}> = ({
|
||||||
|
isActive,
|
||||||
|
animateFrom = 'side',
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
action,
|
action,
|
||||||
onActionClick,
|
onActionClick,
|
||||||
onDismiss,
|
onDismiss,
|
||||||
isActive,
|
|
||||||
isLoading,
|
|
||||||
animateFrom = 'side',
|
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
@@ -54,13 +51,7 @@ export const Alert: React.FC<{
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isLoading && (
|
{onDismiss && (
|
||||||
<span className='notification-bar__loading-indicator'>
|
|
||||||
<LoadingIndicator />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{onDismiss && !isLoading && (
|
|
||||||
<IconButton
|
<IconButton
|
||||||
title={intl.formatMessage({
|
title={intl.formatMessage({
|
||||||
id: 'dismissable_banner.dismiss',
|
id: 'dismissable_banner.dismiss',
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export default class AutosuggestInput extends ImmutablePureComponent {
|
|||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
autoFocus: true,
|
autoFocus: true,
|
||||||
searchTokens: ['@', '@', ':', '#', '#'],
|
searchTokens: ['@', ':', '#'],
|
||||||
};
|
};
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const textAtCursorMatchesToken = (str, caretPosition) => {
|
|||||||
word = str.slice(left, right + caretPosition);
|
word = str.slice(left, right + caretPosition);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!word || word.trim().length < 3 || ['@', '@', ':', '#', '#'].indexOf(word[0]) === -1) {
|
if (!word || word.trim().length < 3 || ['@', ':', '#'].indexOf(word[0]) === -1) {
|
||||||
return [null, null];
|
return [null, null];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +150,10 @@ const AutosuggestTextarea = forwardRef(({
|
|||||||
}, [suggestions, onSuggestionSelected, textareaRef]);
|
}, [suggestions, onSuggestionSelected, textareaRef]);
|
||||||
|
|
||||||
const handlePaste = useCallback((e) => {
|
const handlePaste = useCallback((e) => {
|
||||||
onPaste(e);
|
if (e.clipboardData && e.clipboardData.files.length === 1) {
|
||||||
|
onPaste(e.clipboardData.files);
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
}, [onPaste]);
|
}, [onPaste]);
|
||||||
|
|
||||||
// Show the suggestions again whenever they change and the textarea is focused
|
// Show the suggestions again whenever they change and the textarea is focused
|
||||||
|
|||||||
@@ -30,12 +30,9 @@ const Blurhash: React.FC<Props> = ({
|
|||||||
try {
|
try {
|
||||||
const pixels = decode(hash, width, height);
|
const pixels = decode(hash, width, height);
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
const imageData = ctx?.createImageData(width, height);
|
const imageData = new ImageData(pixels, width, height);
|
||||||
imageData?.data.set(pixels);
|
|
||||||
|
|
||||||
if (imageData) {
|
|
||||||
ctx?.putImageData(imageData, 0, 0);
|
ctx?.putImageData(imageData, 0, 0);
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Blurhash decoding failure', { err, hash });
|
console.error('Blurhash decoding failure', { err, hash });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,13 @@
|
|||||||
import type { List } from 'immutable';
|
|
||||||
|
|
||||||
import type { CustomEmoji } from '../models/custom_emoji';
|
|
||||||
import type { Status } from '../models/status';
|
|
||||||
|
|
||||||
import { EmojiHTML } from './emoji/html';
|
|
||||||
import type { IconName } from './media_icon';
|
import type { IconName } from './media_icon';
|
||||||
import { MediaIcon } from './media_icon';
|
import { MediaIcon } from './media_icon';
|
||||||
import { StatusBanner, BannerVariant } from './status_banner';
|
import { StatusBanner, BannerVariant } from './status_banner';
|
||||||
|
|
||||||
export const ContentWarning: React.FC<{
|
export const ContentWarning: React.FC<{
|
||||||
status: Status;
|
text: string;
|
||||||
expanded?: boolean;
|
expanded?: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
icons?: IconName[];
|
icons?: IconName[];
|
||||||
}> = ({ status, expanded, onClick, icons }) => {
|
}> = ({ text, expanded, onClick, icons }) => (
|
||||||
const hasSpoiler = !!status.get('spoiler_text');
|
|
||||||
if (!hasSpoiler) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const text =
|
|
||||||
status.getIn(['translation', 'spoilerHtml']) || status.get('spoilerHtml');
|
|
||||||
if (typeof text !== 'string' || text.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StatusBanner
|
<StatusBanner
|
||||||
expanded={expanded}
|
expanded={expanded}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
@@ -38,11 +20,6 @@ export const ContentWarning: React.FC<{
|
|||||||
key={`icon-${icon}`}
|
key={`icon-${icon}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<EmojiHTML
|
<span dangerouslySetInnerHTML={{ __html: text }} />
|
||||||
as='span'
|
|
||||||
htmlString={text}
|
|
||||||
extraEmojis={status.get('emoji') as List<CustomEmoji>}
|
|
||||||
/>
|
|
||||||
</StatusBanner>
|
</StatusBanner>
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
|||||||
@@ -2,28 +2,30 @@ import type { ComponentPropsWithoutRef, FC } from 'react';
|
|||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import { AnimateEmojiProvider } from '../emoji/context';
|
import { EmojiHTML } from '@/flavours/glitch/features/emoji/emoji_html';
|
||||||
import { EmojiHTML } from '../emoji/html';
|
import { isModernEmojiEnabled } from '@/flavours/glitch/utils/environment';
|
||||||
|
|
||||||
import { Skeleton } from '../skeleton';
|
import { Skeleton } from '../skeleton';
|
||||||
|
|
||||||
import type { DisplayNameProps } from './index';
|
import type { DisplayNameProps } from './index';
|
||||||
|
|
||||||
export const DisplayNameWithoutDomain: FC<
|
export const DisplayNameWithoutDomain: FC<
|
||||||
Omit<DisplayNameProps, 'variant'> & ComponentPropsWithoutRef<'span'>
|
Omit<DisplayNameProps, 'variant' | 'localDomain'> &
|
||||||
> = ({ account, className, children, localDomain: _, ...props }) => {
|
ComponentPropsWithoutRef<'span'>
|
||||||
|
> = ({ account, className, children, ...props }) => {
|
||||||
return (
|
return (
|
||||||
<AnimateEmojiProvider
|
<span {...props} className={classNames('display-name', className)}>
|
||||||
{...props}
|
|
||||||
as='span'
|
|
||||||
className={classNames('display-name', className)}
|
|
||||||
>
|
|
||||||
<bdi>
|
<bdi>
|
||||||
{account ? (
|
{account ? (
|
||||||
<EmojiHTML
|
<EmojiHTML
|
||||||
className='display-name__html'
|
className='display-name__html'
|
||||||
htmlString={account.get('display_name_html')}
|
htmlString={
|
||||||
|
isModernEmojiEnabled()
|
||||||
|
? account.get('display_name')
|
||||||
|
: account.get('display_name_html')
|
||||||
|
}
|
||||||
|
shallow
|
||||||
as='strong'
|
as='strong'
|
||||||
extraEmojis={account.get('emojis')}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<strong className='display-name__html'>
|
<strong className='display-name__html'>
|
||||||
@@ -32,6 +34,6 @@ export const DisplayNameWithoutDomain: FC<
|
|||||||
)}
|
)}
|
||||||
</bdi>
|
</bdi>
|
||||||
{children}
|
{children}
|
||||||
</AnimateEmojiProvider>
|
</span>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,24 +1,23 @@
|
|||||||
import type { ComponentPropsWithoutRef, FC } from 'react';
|
import type { ComponentPropsWithoutRef, FC } from 'react';
|
||||||
|
|
||||||
import { EmojiHTML } from '../emoji/html';
|
import { EmojiHTML } from '@/flavours/glitch/features/emoji/emoji_html';
|
||||||
|
import { isModernEmojiEnabled } from '@/flavours/glitch/utils/environment';
|
||||||
|
|
||||||
import type { DisplayNameProps } from './index';
|
import type { DisplayNameProps } from './index';
|
||||||
|
|
||||||
export const DisplayNameSimple: FC<
|
export const DisplayNameSimple: FC<
|
||||||
Omit<DisplayNameProps, 'variant'> & ComponentPropsWithoutRef<'span'>
|
Omit<DisplayNameProps, 'variant' | 'localDomain'> &
|
||||||
> = ({ account, localDomain: _, ...props }) => {
|
ComponentPropsWithoutRef<'span'>
|
||||||
|
> = ({ account, ...props }) => {
|
||||||
if (!account) {
|
if (!account) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const accountName = isModernEmojiEnabled()
|
||||||
|
? account.get('display_name')
|
||||||
|
: account.get('display_name_html');
|
||||||
return (
|
return (
|
||||||
<bdi>
|
<bdi>
|
||||||
<EmojiHTML
|
<EmojiHTML {...props} htmlString={accountName} shallow as='span' />
|
||||||
{...props}
|
|
||||||
as='span'
|
|
||||||
htmlString={account.get('display_name_html')}
|
|
||||||
extraEmojis={account.get('emojis')}
|
|
||||||
/>
|
|
||||||
</bdi>
|
</bdi>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
import type { MouseEventHandler, PropsWithChildren } from 'react';
|
|
||||||
import {
|
|
||||||
createContext,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
|
|
||||||
import { cleanExtraEmojis } from '@/flavours/glitch/features/emoji/normalize';
|
|
||||||
import { autoPlayGif } from '@/flavours/glitch/initial_state';
|
|
||||||
import { polymorphicForwardRef } from '@/types/polymorphic';
|
|
||||||
import type {
|
|
||||||
CustomEmojiMapArg,
|
|
||||||
ExtraCustomEmojiMap,
|
|
||||||
} from 'flavours/glitch/features/emoji/types';
|
|
||||||
|
|
||||||
// Animation context
|
|
||||||
export const AnimateEmojiContext = createContext<boolean | null>(null);
|
|
||||||
|
|
||||||
// Polymorphic provider component
|
|
||||||
type AnimateEmojiProviderProps = Required<PropsWithChildren> & {
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AnimateEmojiProvider = polymorphicForwardRef<
|
|
||||||
'div',
|
|
||||||
AnimateEmojiProviderProps
|
|
||||||
>(
|
|
||||||
(
|
|
||||||
{
|
|
||||||
children,
|
|
||||||
as: Wrapper = 'div',
|
|
||||||
className,
|
|
||||||
onMouseEnter,
|
|
||||||
onMouseLeave,
|
|
||||||
...props
|
|
||||||
},
|
|
||||||
ref,
|
|
||||||
) => {
|
|
||||||
const [animate, setAnimate] = useState(autoPlayGif ?? false);
|
|
||||||
|
|
||||||
const handleEnter: MouseEventHandler<HTMLDivElement> = useCallback(
|
|
||||||
(event) => {
|
|
||||||
onMouseEnter?.(event);
|
|
||||||
if (!autoPlayGif) {
|
|
||||||
setAnimate(true);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onMouseEnter],
|
|
||||||
);
|
|
||||||
const handleLeave: MouseEventHandler<HTMLDivElement> = useCallback(
|
|
||||||
(event) => {
|
|
||||||
onMouseLeave?.(event);
|
|
||||||
if (!autoPlayGif) {
|
|
||||||
setAnimate(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onMouseLeave],
|
|
||||||
);
|
|
||||||
|
|
||||||
// If there's a parent context or GIFs autoplay, we don't need handlers.
|
|
||||||
const parentContext = useContext(AnimateEmojiContext);
|
|
||||||
if (parentContext !== null) {
|
|
||||||
return (
|
|
||||||
<Wrapper {...props} className={className} ref={ref}>
|
|
||||||
{children}
|
|
||||||
</Wrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Wrapper
|
|
||||||
{...props}
|
|
||||||
className={className}
|
|
||||||
onMouseEnter={handleEnter}
|
|
||||||
onMouseLeave={handleLeave}
|
|
||||||
ref={ref}
|
|
||||||
>
|
|
||||||
<AnimateEmojiContext.Provider value={animate}>
|
|
||||||
{children}
|
|
||||||
</AnimateEmojiContext.Provider>
|
|
||||||
</Wrapper>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
AnimateEmojiProvider.displayName = 'AnimateEmojiProvider';
|
|
||||||
|
|
||||||
// Handle custom emoji
|
|
||||||
export const CustomEmojiContext = createContext<ExtraCustomEmojiMap>({});
|
|
||||||
|
|
||||||
export const CustomEmojiProvider = ({
|
|
||||||
children,
|
|
||||||
emojis: rawEmojis,
|
|
||||||
}: PropsWithChildren<{ emojis?: CustomEmojiMapArg }>) => {
|
|
||||||
const emojis = useMemo(() => cleanExtraEmojis(rawEmojis) ?? {}, [rawEmojis]);
|
|
||||||
return (
|
|
||||||
<CustomEmojiContext.Provider value={emojis}>
|
|
||||||
{children}
|
|
||||||
</CustomEmojiContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import type { ComponentProps } from 'react';
|
|
||||||
|
|
||||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
||||||
|
|
||||||
import { importCustomEmojiData } from '@/flavours/glitch/features/emoji/loader';
|
|
||||||
|
|
||||||
import { Emoji } from './index';
|
|
||||||
|
|
||||||
type EmojiProps = ComponentProps<typeof Emoji> & { state: string };
|
|
||||||
|
|
||||||
const meta = {
|
|
||||||
title: 'Components/Emoji',
|
|
||||||
component: Emoji,
|
|
||||||
args: {
|
|
||||||
code: '🖤',
|
|
||||||
state: 'auto',
|
|
||||||
},
|
|
||||||
argTypes: {
|
|
||||||
code: {
|
|
||||||
name: 'Emoji',
|
|
||||||
},
|
|
||||||
state: {
|
|
||||||
control: {
|
|
||||||
type: 'select',
|
|
||||||
labels: {
|
|
||||||
auto: 'Auto',
|
|
||||||
native: 'Native',
|
|
||||||
twemoji: 'Twemoji',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
options: ['auto', 'native', 'twemoji'],
|
|
||||||
name: 'Emoji Style',
|
|
||||||
mapping: {
|
|
||||||
auto: { meta: { emoji_style: 'auto' } },
|
|
||||||
native: { meta: { emoji_style: 'native' } },
|
|
||||||
twemoji: { meta: { emoji_style: 'twemoji' } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
render(args) {
|
|
||||||
void importCustomEmojiData();
|
|
||||||
return <Emoji {...args} />;
|
|
||||||
},
|
|
||||||
} satisfies Meta<EmojiProps>;
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
|
|
||||||
type Story = StoryObj<typeof meta>;
|
|
||||||
|
|
||||||
export const Default: Story = {};
|
|
||||||
|
|
||||||
export const CustomEmoji: Story = {
|
|
||||||
args: {
|
|
||||||
code: ':custom:',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
|
|
||||||
import type { CustomEmojiMapArg } from '@/flavours/glitch/features/emoji/types';
|
|
||||||
import type {
|
|
||||||
OnAttributeHandler,
|
|
||||||
OnElementHandler,
|
|
||||||
} from '@/flavours/glitch/utils/html';
|
|
||||||
import { htmlStringToComponents } from '@/flavours/glitch/utils/html';
|
|
||||||
import { polymorphicForwardRef } from '@/types/polymorphic';
|
|
||||||
|
|
||||||
import { AnimateEmojiProvider, CustomEmojiProvider } from './context';
|
|
||||||
import { textToEmojis } from './index';
|
|
||||||
|
|
||||||
export interface EmojiHTMLProps {
|
|
||||||
htmlString: string;
|
|
||||||
extraEmojis?: CustomEmojiMapArg;
|
|
||||||
className?: string;
|
|
||||||
onElement?: OnElementHandler;
|
|
||||||
onAttribute?: OnAttributeHandler;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const EmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
|
|
||||||
(
|
|
||||||
{
|
|
||||||
extraEmojis,
|
|
||||||
htmlString,
|
|
||||||
as: asProp = 'div', // Rename for syntax highlighting
|
|
||||||
className = '',
|
|
||||||
onElement,
|
|
||||||
onAttribute,
|
|
||||||
...props
|
|
||||||
},
|
|
||||||
ref,
|
|
||||||
) => {
|
|
||||||
const contents = useMemo(
|
|
||||||
() =>
|
|
||||||
htmlStringToComponents(htmlString, {
|
|
||||||
onText: textToEmojis,
|
|
||||||
onElement,
|
|
||||||
onAttribute,
|
|
||||||
}),
|
|
||||||
[htmlString, onAttribute, onElement],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CustomEmojiProvider emojis={extraEmojis}>
|
|
||||||
<AnimateEmojiProvider
|
|
||||||
{...props}
|
|
||||||
as={asProp}
|
|
||||||
className={className}
|
|
||||||
ref={ref}
|
|
||||||
>
|
|
||||||
{contents}
|
|
||||||
</AnimateEmojiProvider>
|
|
||||||
</CustomEmojiProvider>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
EmojiHTML.displayName = 'EmojiHTML';
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import type { FC } from 'react';
|
|
||||||
import { useContext, useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { EMOJI_TYPE_CUSTOM } from '@/flavours/glitch/features/emoji/constants';
|
|
||||||
import { useEmojiAppState } from '@/flavours/glitch/features/emoji/mode';
|
|
||||||
import { unicodeHexToUrl } from '@/flavours/glitch/features/emoji/normalize';
|
|
||||||
import {
|
|
||||||
isStateLoaded,
|
|
||||||
loadEmojiDataToState,
|
|
||||||
shouldRenderImage,
|
|
||||||
stringToEmojiState,
|
|
||||||
tokenizeText,
|
|
||||||
} from '@/flavours/glitch/features/emoji/render';
|
|
||||||
|
|
||||||
import { AnimateEmojiContext, CustomEmojiContext } from './context';
|
|
||||||
|
|
||||||
interface EmojiProps {
|
|
||||||
code: string;
|
|
||||||
showFallback?: boolean;
|
|
||||||
showLoading?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Emoji: FC<EmojiProps> = ({
|
|
||||||
code,
|
|
||||||
showFallback = true,
|
|
||||||
showLoading = true,
|
|
||||||
}) => {
|
|
||||||
const customEmoji = useContext(CustomEmojiContext);
|
|
||||||
|
|
||||||
// First, set the emoji state based on the input code.
|
|
||||||
const [state, setState] = useState(() =>
|
|
||||||
stringToEmojiState(code, customEmoji),
|
|
||||||
);
|
|
||||||
|
|
||||||
// If we don't have data, then load emoji data asynchronously.
|
|
||||||
const appState = useEmojiAppState();
|
|
||||||
useEffect(() => {
|
|
||||||
if (state !== null) {
|
|
||||||
void loadEmojiDataToState(state, appState.currentLocale).then(setState);
|
|
||||||
}
|
|
||||||
}, [appState.currentLocale, state]);
|
|
||||||
|
|
||||||
const animate = useContext(AnimateEmojiContext);
|
|
||||||
const fallback = showFallback ? code : null;
|
|
||||||
|
|
||||||
// If the code is invalid or we otherwise know it's not valid, show the fallback.
|
|
||||||
if (!state) {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!shouldRenderImage(state, appState.mode)) {
|
|
||||||
return code;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isStateLoaded(state)) {
|
|
||||||
if (showLoading) {
|
|
||||||
return <span className='emojione emoji-loading' title={code} />;
|
|
||||||
}
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.type === EMOJI_TYPE_CUSTOM) {
|
|
||||||
const shortcode = `:${state.code}:`;
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
src={animate ? state.data.url : state.data.static_url}
|
|
||||||
alt={shortcode}
|
|
||||||
title={shortcode}
|
|
||||||
className='emojione custom-emoji'
|
|
||||||
loading='lazy'
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const src = unicodeHexToUrl(state.code, appState.darkTheme);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
src={src}
|
|
||||||
alt={state.data.unicode}
|
|
||||||
title={state.data.label}
|
|
||||||
className='emojione'
|
|
||||||
loading='lazy'
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Takes a text string and converts it to an array of React nodes.
|
|
||||||
* @param text The text to be tokenized and converted.
|
|
||||||
*/
|
|
||||||
export function textToEmojis(text: string) {
|
|
||||||
return tokenizeText(text).map((token, index) => {
|
|
||||||
if (typeof token === 'string') {
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
return <Emoji code={token.code} key={`emoji-${token.code}-${index}`} />;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A helper component for managing the rendering of components that
|
|
||||||
* need to stay in the DOM a bit longer to finish their CSS exit animation.
|
|
||||||
*
|
|
||||||
* In the future, replace this component with plain CSS once that is feasible.
|
|
||||||
* This will require broader support for `transition-behavior: allow-discrete`
|
|
||||||
* and https://developer.mozilla.org/en-US/docs/Web/CSS/overlay.
|
|
||||||
*/
|
|
||||||
export const ExitAnimationWrapper: React.FC<{
|
|
||||||
/**
|
|
||||||
* Set this to true to indicate that the nested component should be rendered
|
|
||||||
*/
|
|
||||||
isActive: boolean;
|
|
||||||
/**
|
|
||||||
* How long the component should be rendered after `isActive` was set to `false`
|
|
||||||
*/
|
|
||||||
delayMs?: number;
|
|
||||||
/**
|
|
||||||
* Set this to true to also delay the entry of the nested component until after
|
|
||||||
* another one has exited full.
|
|
||||||
*/
|
|
||||||
withEntryDelay?: boolean;
|
|
||||||
/**
|
|
||||||
* Render prop that provides the nested component with the `delayedIsActive` flag
|
|
||||||
*/
|
|
||||||
children: (delayedIsActive: boolean) => React.ReactNode;
|
|
||||||
}> = ({ isActive = false, delayMs = 500, withEntryDelay, children }) => {
|
|
||||||
const [delayedIsActive, setDelayedIsActive] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isActive && !withEntryDelay) {
|
|
||||||
setDelayedIsActive(true);
|
|
||||||
|
|
||||||
return () => '';
|
|
||||||
} else {
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
setDelayedIsActive(isActive);
|
|
||||||
}, delayMs);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [isActive, delayMs, withEntryDelay]);
|
|
||||||
|
|
||||||
if (!isActive && !delayedIsActive) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return children(isActive && delayedIsActive);
|
|
||||||
};
|
|
||||||
@@ -20,7 +20,7 @@ import { useDrag } from '@use-gesture/react';
|
|||||||
import { expandAccountFeaturedTimeline } from '@/flavours/glitch/actions/timelines';
|
import { expandAccountFeaturedTimeline } from '@/flavours/glitch/actions/timelines';
|
||||||
import { Icon } from '@/flavours/glitch/components/icon';
|
import { Icon } from '@/flavours/glitch/components/icon';
|
||||||
import { IconButton } from '@/flavours/glitch/components/icon_button';
|
import { IconButton } from '@/flavours/glitch/components/icon_button';
|
||||||
import { StatusQuoteManager } from '@/flavours/glitch/components/status_quoted';
|
import StatusContainer from '@/flavours/glitch/containers/status_container';
|
||||||
import { usePrevious } from '@/flavours/glitch/hooks/usePrevious';
|
import { usePrevious } from '@/flavours/glitch/hooks/usePrevious';
|
||||||
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
|
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
|
||||||
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
|
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
|
||||||
@@ -218,7 +218,12 @@ const FeaturedCarouselItem: React.FC<
|
|||||||
ref={handleRef}
|
ref={handleRef}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<StatusQuoteManager id={statusId} contextType='account' withCounters />
|
<StatusContainer
|
||||||
|
// @ts-expect-error inferred props are wrong
|
||||||
|
id={statusId}
|
||||||
|
contextType='account'
|
||||||
|
withCounters
|
||||||
|
/>
|
||||||
</animated.div>
|
</animated.div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { useIdentity } from '@/flavours/glitch/identity_context';
|
|||||||
import {
|
import {
|
||||||
fetchRelationships,
|
fetchRelationships,
|
||||||
followAccount,
|
followAccount,
|
||||||
unmuteAccount,
|
|
||||||
} from 'flavours/glitch/actions/accounts';
|
} from 'flavours/glitch/actions/accounts';
|
||||||
import { openModal } from 'flavours/glitch/actions/modal';
|
import { openModal } from 'flavours/glitch/actions/modal';
|
||||||
import { Button } from 'flavours/glitch/components/button';
|
import { Button } from 'flavours/glitch/components/button';
|
||||||
@@ -16,50 +15,17 @@ import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
|||||||
import { me } from 'flavours/glitch/initial_state';
|
import { me } from 'flavours/glitch/initial_state';
|
||||||
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
||||||
|
|
||||||
import { useBreakpoint } from '../features/ui/hooks/useBreakpoint';
|
const messages = defineMessages({
|
||||||
|
|
||||||
const longMessages = defineMessages({
|
|
||||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
||||||
unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' },
|
|
||||||
unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' },
|
|
||||||
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
||||||
followBack: { id: 'account.follow_back', defaultMessage: 'Follow back' },
|
followBack: { id: 'account.follow_back', defaultMessage: 'Follow back' },
|
||||||
followRequest: {
|
|
||||||
id: 'account.follow_request',
|
|
||||||
defaultMessage: 'Request to follow',
|
|
||||||
},
|
|
||||||
followRequestCancel: {
|
|
||||||
id: 'account.follow_request_cancel',
|
|
||||||
defaultMessage: 'Cancel request',
|
|
||||||
},
|
|
||||||
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
|
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const shortMessages = {
|
|
||||||
...longMessages, // Align type signature of shortMessages and longMessages
|
|
||||||
...defineMessages({
|
|
||||||
followBack: {
|
|
||||||
id: 'account.follow_back_short',
|
|
||||||
defaultMessage: 'Follow back',
|
|
||||||
},
|
|
||||||
followRequest: {
|
|
||||||
id: 'account.follow_request_short',
|
|
||||||
defaultMessage: 'Request',
|
|
||||||
},
|
|
||||||
followRequestCancel: {
|
|
||||||
id: 'account.follow_request_cancel_short',
|
|
||||||
defaultMessage: 'Cancel',
|
|
||||||
},
|
|
||||||
editProfile: { id: 'account.edit_profile_short', defaultMessage: 'Edit' },
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const FollowButton: React.FC<{
|
export const FollowButton: React.FC<{
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
labelLength?: 'auto' | 'short' | 'long';
|
}> = ({ accountId, compact }) => {
|
||||||
className?: string;
|
|
||||||
}> = ({ accountId, compact, labelLength = 'auto', className }) => {
|
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { signedIn } = useIdentity();
|
const { signedIn } = useIdentity();
|
||||||
@@ -94,60 +60,29 @@ export const FollowButton: React.FC<{
|
|||||||
|
|
||||||
if (accountId === me) {
|
if (accountId === me) {
|
||||||
return;
|
return;
|
||||||
} else if (relationship.muting) {
|
} else if (account && (relationship.following || relationship.requested)) {
|
||||||
dispatch(unmuteAccount(accountId));
|
|
||||||
} else if (account && relationship.following) {
|
|
||||||
dispatch(
|
dispatch(
|
||||||
openModal({ modalType: 'CONFIRM_UNFOLLOW', modalProps: { account } }),
|
openModal({ modalType: 'CONFIRM_UNFOLLOW', modalProps: { account } }),
|
||||||
);
|
);
|
||||||
} else if (account && relationship.requested) {
|
|
||||||
dispatch(
|
|
||||||
openModal({
|
|
||||||
modalType: 'CONFIRM_WITHDRAW_REQUEST',
|
|
||||||
modalProps: { account },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} else if (relationship.blocking) {
|
|
||||||
dispatch(
|
|
||||||
openModal({
|
|
||||||
modalType: 'CONFIRM_UNBLOCK',
|
|
||||||
modalProps: { account },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
dispatch(followAccount(accountId));
|
dispatch(followAccount(accountId));
|
||||||
}
|
}
|
||||||
}, [dispatch, accountId, relationship, account, signedIn]);
|
}, [dispatch, accountId, relationship, account, signedIn]);
|
||||||
|
|
||||||
const isNarrow = useBreakpoint('narrow');
|
|
||||||
const useShortLabel =
|
|
||||||
labelLength === 'short' || (labelLength === 'auto' && isNarrow);
|
|
||||||
const messages = useShortLabel ? shortMessages : longMessages;
|
|
||||||
|
|
||||||
const followMessage = account?.locked
|
|
||||||
? messages.followRequest
|
|
||||||
: messages.follow;
|
|
||||||
|
|
||||||
let label;
|
let label;
|
||||||
|
|
||||||
if (!signedIn) {
|
if (!signedIn) {
|
||||||
label = intl.formatMessage(followMessage);
|
label = intl.formatMessage(messages.follow);
|
||||||
} else if (accountId === me) {
|
} else if (accountId === me) {
|
||||||
label = intl.formatMessage(messages.edit_profile);
|
label = intl.formatMessage(messages.edit_profile);
|
||||||
} else if (!relationship) {
|
} else if (!relationship) {
|
||||||
label = <LoadingIndicator />;
|
label = <LoadingIndicator />;
|
||||||
} else if (relationship.muting) {
|
} else if (relationship.following || relationship.requested) {
|
||||||
label = intl.formatMessage(messages.unmute);
|
|
||||||
} else if (relationship.following) {
|
|
||||||
label = intl.formatMessage(messages.unfollow);
|
label = intl.formatMessage(messages.unfollow);
|
||||||
} else if (relationship.blocking) {
|
} else if (relationship.followed_by) {
|
||||||
label = intl.formatMessage(messages.unblock);
|
|
||||||
} else if (relationship.requested) {
|
|
||||||
label = intl.formatMessage(messages.followRequestCancel);
|
|
||||||
} else if (relationship.followed_by && !account?.locked) {
|
|
||||||
label = intl.formatMessage(messages.followBack);
|
label = intl.formatMessage(messages.followBack);
|
||||||
} else {
|
} else {
|
||||||
label = intl.formatMessage(followMessage);
|
label = intl.formatMessage(messages.follow);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (accountId === me) {
|
if (accountId === me) {
|
||||||
@@ -156,7 +91,7 @@ export const FollowButton: React.FC<{
|
|||||||
href='/settings/profile'
|
href='/settings/profile'
|
||||||
target='_blank'
|
target='_blank'
|
||||||
rel='noopener'
|
rel='noopener'
|
||||||
className={classNames(className, 'button button-secondary', {
|
className={classNames('button button-secondary', {
|
||||||
'button--compact': compact,
|
'button--compact': compact,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
@@ -170,12 +105,13 @@ export const FollowButton: React.FC<{
|
|||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
disabled={
|
disabled={
|
||||||
relationship?.blocked_by ||
|
relationship?.blocked_by ||
|
||||||
|
relationship?.blocking ||
|
||||||
(!(relationship?.following || relationship?.requested) &&
|
(!(relationship?.following || relationship?.requested) &&
|
||||||
(account?.suspended || !!account?.moved))
|
(account?.suspended || !!account?.moved))
|
||||||
}
|
}
|
||||||
secondary={following}
|
secondary={following}
|
||||||
compact={compact}
|
compact={compact}
|
||||||
className={classNames(className, { 'button--destructive': following })}
|
className={following ? 'button--destructive' : undefined}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ function isNodeLinkHashtag(element: Node): element is HTMLLinkElement {
|
|||||||
return (
|
return (
|
||||||
element instanceof HTMLAnchorElement &&
|
element instanceof HTMLAnchorElement &&
|
||||||
// it may be a <a> starting with a hashtag
|
// it may be a <a> starting with a hashtag
|
||||||
(element.textContent.startsWith('#') ||
|
(element.textContent?.[0] === '#' ||
|
||||||
// or a #<a>
|
// or a #<a>
|
||||||
element.previousSibling?.textContent?.[
|
element.previousSibling?.textContent?.[
|
||||||
element.previousSibling.textContent.length - 1
|
element.previousSibling.textContent.length - 1
|
||||||
|
|||||||
@@ -109,14 +109,7 @@ export const HoverCardAccount = forwardRef<
|
|||||||
accountId={account.id}
|
accountId={account.id}
|
||||||
className='hover-card__bio'
|
className='hover-card__bio'
|
||||||
/>
|
/>
|
||||||
|
<AccountFields fields={account.fields} limit={2} />
|
||||||
<div className='account-fields'>
|
|
||||||
<AccountFields
|
|
||||||
fields={account.fields.take(2)}
|
|
||||||
emojis={account.emojis}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{note && note.length > 0 && (
|
{note && note.length > 0 && (
|
||||||
<dl className='hover-card__note'>
|
<dl className='hover-card__note'>
|
||||||
<dt className='hover-card__note-label'>
|
<dt className='hover-card__note-label'>
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
||||||
import { expect } from 'storybook/test';
|
|
||||||
|
|
||||||
import { HTMLBlock } from './index';
|
|
||||||
|
|
||||||
const meta = {
|
|
||||||
title: 'Components/HTMLBlock',
|
|
||||||
component: HTMLBlock,
|
|
||||||
args: {
|
|
||||||
htmlString: `<p>Hello, world!</p>
|
|
||||||
<p><a href="#">A link</a></p>
|
|
||||||
<p>This should be filtered out: <button>Bye!</button></p>
|
|
||||||
<p>This also has emoji: 🖤</p>`,
|
|
||||||
},
|
|
||||||
argTypes: {
|
|
||||||
extraEmojis: {
|
|
||||||
table: {
|
|
||||||
disable: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onElement: {
|
|
||||||
table: {
|
|
||||||
disable: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onAttribute: {
|
|
||||||
table: {
|
|
||||||
disable: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
render(args) {
|
|
||||||
return (
|
|
||||||
// Just for visual clarity in Storybook.
|
|
||||||
<HTMLBlock
|
|
||||||
{...args}
|
|
||||||
style={{
|
|
||||||
border: '1px solid black',
|
|
||||||
padding: '1rem',
|
|
||||||
minWidth: '300px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
// Force Twemoji to demonstrate emoji rendering.
|
|
||||||
parameters: {
|
|
||||||
state: {
|
|
||||||
meta: {
|
|
||||||
emoji_style: 'twemoji',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} satisfies Meta<typeof HTMLBlock>;
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
|
|
||||||
type Story = StoryObj<typeof meta>;
|
|
||||||
|
|
||||||
export const Default: Story = {
|
|
||||||
async play({ canvas }) {
|
|
||||||
const link = canvas.queryByRole('link');
|
|
||||||
await expect(link).toBeInTheDocument();
|
|
||||||
const button = canvas.queryByRole('button');
|
|
||||||
await expect(button).not.toBeInTheDocument();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { useCallback } from 'react';
|
|
||||||
|
|
||||||
import type { OnElementHandler } from '@/flavours/glitch/utils/html';
|
|
||||||
import { polymorphicForwardRef } from '@/types/polymorphic';
|
|
||||||
|
|
||||||
import type { EmojiHTMLProps } from '../emoji/html';
|
|
||||||
import { EmojiHTML } from '../emoji/html';
|
|
||||||
import { useElementHandledLink } from '../status/handled_link';
|
|
||||||
|
|
||||||
export const HTMLBlock = polymorphicForwardRef<
|
|
||||||
'div',
|
|
||||||
EmojiHTMLProps & Parameters<typeof useElementHandledLink>[0]
|
|
||||||
>(
|
|
||||||
({
|
|
||||||
onElement: onParentElement,
|
|
||||||
hrefToMention,
|
|
||||||
hashtagAccountId,
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
const { onElement: onLinkElement } = useElementHandledLink({
|
|
||||||
hrefToMention,
|
|
||||||
hashtagAccountId,
|
|
||||||
});
|
|
||||||
const onElement: OnElementHandler = useCallback(
|
|
||||||
(...args) => onParentElement?.(...args) ?? onLinkElement(...args),
|
|
||||||
[onLinkElement, onParentElement],
|
|
||||||
);
|
|
||||||
return <EmojiHTML {...props} onElement={onElement} />;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -8,12 +8,13 @@ import classNames from 'classnames';
|
|||||||
import { animated, useSpring } from '@react-spring/web';
|
import { animated, useSpring } from '@react-spring/web';
|
||||||
import escapeTextContentForBrowser from 'escape-html';
|
import escapeTextContentForBrowser from 'escape-html';
|
||||||
|
|
||||||
import { EmojiHTML } from '@/flavours/glitch/components/emoji/html';
|
|
||||||
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||||
import { openModal } from 'flavours/glitch/actions/modal';
|
import { openModal } from 'flavours/glitch/actions/modal';
|
||||||
import { fetchPoll, vote } from 'flavours/glitch/actions/polls';
|
import { fetchPoll, vote } from 'flavours/glitch/actions/polls';
|
||||||
import { Icon } from 'flavours/glitch/components/icon';
|
import { Icon } from 'flavours/glitch/components/icon';
|
||||||
|
import emojify from 'flavours/glitch/features/emoji/emoji';
|
||||||
import { useIdentity } from 'flavours/glitch/identity_context';
|
import { useIdentity } from 'flavours/glitch/identity_context';
|
||||||
|
import { makeEmojiMap } from 'flavours/glitch/models/custom_emoji';
|
||||||
import type * as Model from 'flavours/glitch/models/poll';
|
import type * as Model from 'flavours/glitch/models/poll';
|
||||||
import type { Status } from 'flavours/glitch/models/status';
|
import type { Status } from 'flavours/glitch/models/status';
|
||||||
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
||||||
@@ -233,11 +234,12 @@ const PollOption: React.FC<PollOptionProps> = (props) => {
|
|||||||
let titleHtml = option.translation?.titleHtml ?? option.titleHtml;
|
let titleHtml = option.translation?.titleHtml ?? option.titleHtml;
|
||||||
|
|
||||||
if (!titleHtml) {
|
if (!titleHtml) {
|
||||||
titleHtml = escapeTextContentForBrowser(title);
|
const emojiMap = makeEmojiMap(poll.emojis);
|
||||||
|
titleHtml = emojify(escapeTextContentForBrowser(title), emojiMap);
|
||||||
}
|
}
|
||||||
|
|
||||||
return titleHtml;
|
return titleHtml;
|
||||||
}, [option, title]);
|
}, [option, poll, title]);
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
const handleOptionChange = useCallback(() => {
|
const handleOptionChange = useCallback(() => {
|
||||||
@@ -303,11 +305,10 @@ const PollOption: React.FC<PollOptionProps> = (props) => {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<EmojiHTML
|
<span
|
||||||
className='poll__option__text translate'
|
className='poll__option__text translate'
|
||||||
lang={lang}
|
lang={lang}
|
||||||
htmlString={titleHtml}
|
dangerouslySetInnerHTML={{ __html: titleHtml }}
|
||||||
extraEmojis={poll.emojis}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!!voted && (
|
{!!voted && (
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { PropsWithChildren } from 'react';
|
import type { PropsWithChildren } from 'react';
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
|
|
||||||
import type { useLocation } from 'react-router';
|
|
||||||
import { Router as OriginalRouter, useHistory } from 'react-router';
|
import { Router as OriginalRouter, useHistory } from 'react-router';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -19,9 +18,7 @@ interface MastodonLocationState {
|
|||||||
mastodonModalKey?: string;
|
mastodonModalKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LocationState = MastodonLocationState | null | undefined;
|
type LocationState = MastodonLocationState | null | undefined;
|
||||||
|
|
||||||
export type MastodonLocation = ReturnType<typeof useLocation<LocationState>>;
|
|
||||||
|
|
||||||
type HistoryPath = Path | LocationDescriptor<LocationState>;
|
type HistoryPath = Path | LocationDescriptor<LocationState>;
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { connect } from 'react-redux';
|
|||||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||||
import { throttle } from 'lodash';
|
import { throttle } from 'lodash';
|
||||||
|
|
||||||
import { ScrollContainer } from 'flavours/glitch/containers/scroll_container';
|
import ScrollContainer from 'flavours/glitch/containers/scroll_container';
|
||||||
|
|
||||||
import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container';
|
import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container';
|
||||||
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../features/ui/util/fullscreen';
|
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../features/ui/util/fullscreen';
|
||||||
@@ -399,7 +399,7 @@ class ScrollableList extends PureComponent {
|
|||||||
|
|
||||||
if (trackScroll) {
|
if (trackScroll) {
|
||||||
return (
|
return (
|
||||||
<ScrollContainer scrollKey={scrollKey} childRef={this.setRef}>
|
<ScrollContainer scrollKey={scrollKey}>
|
||||||
{scrollableArea}
|
{scrollableArea}
|
||||||
</ScrollContainer>
|
</ScrollContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -118,7 +118,6 @@ class Status extends ImmutablePureComponent {
|
|||||||
prepend: PropTypes.string,
|
prepend: PropTypes.string,
|
||||||
withDismiss: PropTypes.bool,
|
withDismiss: PropTypes.bool,
|
||||||
isQuotedPost: PropTypes.bool,
|
isQuotedPost: PropTypes.bool,
|
||||||
shouldHighlightOnMount: PropTypes.bool,
|
|
||||||
getScrollPosition: PropTypes.func,
|
getScrollPosition: PropTypes.func,
|
||||||
updateScrollBottom: PropTypes.func,
|
updateScrollBottom: PropTypes.func,
|
||||||
expanded: PropTypes.bool,
|
expanded: PropTypes.bool,
|
||||||
@@ -706,7 +705,6 @@ class Status extends ImmutablePureComponent {
|
|||||||
muted: this.props.muted,
|
muted: this.props.muted,
|
||||||
'status--is-quote': isQuotedPost,
|
'status--is-quote': isQuotedPost,
|
||||||
'status--has-quote': !!status.get('quote'),
|
'status--has-quote': !!status.get('quote'),
|
||||||
'status--highlighted-entry': this.props.shouldHighlightOnMount,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
data-id={status.get('id')}
|
data-id={status.get('id')}
|
||||||
@@ -739,7 +737,7 @@ class Status extends ImmutablePureComponent {
|
|||||||
</header>
|
</header>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ContentWarning status={status} expanded={expanded} onClick={this.handleExpandedToggle} icons={mediaIcons} />
|
{status.get('spoiler_text').length > 0 && <ContentWarning text={status.getIn(['translation', 'spoilerHtml']) || status.get('spoilerHtml')} expanded={expanded} onClick={this.handleExpandedToggle} icons={mediaIcons} />}
|
||||||
|
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<>
|
<>
|
||||||
@@ -750,6 +748,8 @@ class Status extends ImmutablePureComponent {
|
|||||||
collapsible
|
collapsible
|
||||||
media={media}
|
media={media}
|
||||||
onCollapsedToggle={this.handleCollapsedToggle}
|
onCollapsedToggle={this.handleCollapsedToggle}
|
||||||
|
tagLinks={settings.get('tag_misleading_links')}
|
||||||
|
rewriteMentions={settings.get('rewrite_mentions')}
|
||||||
{...statusContentProps}
|
{...statusContentProps}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
|
|||||||
import type { StatusVisibility } from '@/flavours/glitch/api_types/statuses';
|
import type { StatusVisibility } from '@/flavours/glitch/api_types/statuses';
|
||||||
import { statusFactoryState } from '@/testing/factories';
|
import { statusFactoryState } from '@/testing/factories';
|
||||||
|
|
||||||
import { BoostButton } from './boost_button';
|
import { LegacyReblogButton, StatusBoostButton } from './boost_button';
|
||||||
|
|
||||||
interface StoryProps {
|
interface StoryProps {
|
||||||
visibility: StatusVisibility;
|
visibility: StatusVisibility;
|
||||||
@@ -38,7 +38,10 @@ const meta = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
render: (args) => (
|
render: (args) => (
|
||||||
<BoostButton status={argsToStatus(args)} counters={args.reblogCount > 0} />
|
<StatusBoostButton
|
||||||
|
status={argsToStatus(args)}
|
||||||
|
counters={args.reblogCount > 0}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
} satisfies Meta<StoryProps>;
|
} satisfies Meta<StoryProps>;
|
||||||
|
|
||||||
@@ -75,3 +78,12 @@ export const Mine: Story = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const Legacy: Story = {
|
||||||
|
render: (args) => (
|
||||||
|
<LegacyReblogButton
|
||||||
|
status={argsToStatus(args)}
|
||||||
|
counters={args.reblogCount > 0}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import classNames from 'classnames';
|
|||||||
import { quoteComposeById } from '@/flavours/glitch/actions/compose_typed';
|
import { quoteComposeById } from '@/flavours/glitch/actions/compose_typed';
|
||||||
import { toggleReblog } from '@/flavours/glitch/actions/interactions';
|
import { toggleReblog } from '@/flavours/glitch/actions/interactions';
|
||||||
import { openModal } from '@/flavours/glitch/actions/modal';
|
import { openModal } from '@/flavours/glitch/actions/modal';
|
||||||
import { quickBoosting } from '@/flavours/glitch/initial_state';
|
|
||||||
import type { ActionMenuItem } from '@/flavours/glitch/models/dropdown_menu';
|
import type { ActionMenuItem } from '@/flavours/glitch/models/dropdown_menu';
|
||||||
import type { Status } from '@/flavours/glitch/models/status';
|
import type { Status } from '@/flavours/glitch/models/status';
|
||||||
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
|
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
|
||||||
|
import { isFeatureEnabled } from '@/flavours/glitch/utils/environment';
|
||||||
import type { SomeRequired } from '@/flavours/glitch/utils/types';
|
import type { SomeRequired } from '@/flavours/glitch/utils/types';
|
||||||
|
|
||||||
import type { RenderItemFn, RenderItemFnHandlers } from '../dropdown_menu';
|
import type { RenderItemFn, RenderItemFnHandlers } from '../dropdown_menu';
|
||||||
@@ -25,55 +25,6 @@ import {
|
|||||||
selectStatusState,
|
selectStatusState,
|
||||||
} from './boost_button_utils';
|
} from './boost_button_utils';
|
||||||
|
|
||||||
const StandaloneBoostButton: FC<ReblogButtonProps> = ({ status, counters }) => {
|
|
||||||
const intl = useIntl();
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
|
|
||||||
const statusState = useAppSelector((state) =>
|
|
||||||
selectStatusState(state, status),
|
|
||||||
);
|
|
||||||
const { title, meta, iconComponent, disabled } = useMemo(
|
|
||||||
() => boostItemState(statusState),
|
|
||||||
[statusState],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleClick: MouseEventHandler = useCallback(
|
|
||||||
(event) => {
|
|
||||||
if (statusState.isLoggedIn) {
|
|
||||||
dispatch(toggleReblog(status.get('id') as string, event.shiftKey));
|
|
||||||
} else {
|
|
||||||
dispatch(
|
|
||||||
openModal({
|
|
||||||
modalType: 'INTERACTION',
|
|
||||||
modalProps: {
|
|
||||||
accountId: status.getIn(['account', 'id']),
|
|
||||||
url: status.get('uri'),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[dispatch, status, statusState.isLoggedIn],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<IconButton
|
|
||||||
disabled={disabled}
|
|
||||||
active={!!status.get('reblogged')}
|
|
||||||
title={intl.formatMessage(meta ?? title)}
|
|
||||||
icon='retweet'
|
|
||||||
iconComponent={iconComponent}
|
|
||||||
onClick={!disabled ? handleClick : undefined}
|
|
||||||
counter={
|
|
||||||
counters
|
|
||||||
? (status.get('reblogs_count') as number) +
|
|
||||||
(status.get('quotes_count') as number)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderMenuItem: RenderItemFn<ActionMenuItem> = (
|
const renderMenuItem: RenderItemFn<ActionMenuItem> = (
|
||||||
item,
|
item,
|
||||||
index,
|
index,
|
||||||
@@ -96,7 +47,10 @@ interface ReblogButtonProps {
|
|||||||
|
|
||||||
type ActionMenuItemWithIcon = SomeRequired<ActionMenuItem, 'icon'>;
|
type ActionMenuItemWithIcon = SomeRequired<ActionMenuItem, 'icon'>;
|
||||||
|
|
||||||
const BoostOrQuoteMenu: FC<ReblogButtonProps> = ({ status, counters }) => {
|
export const StatusBoostButton: FC<ReblogButtonProps> = ({
|
||||||
|
status,
|
||||||
|
counters,
|
||||||
|
}) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const statusState = useAppSelector((state) =>
|
const statusState = useAppSelector((state) =>
|
||||||
@@ -239,8 +193,64 @@ const ReblogMenuItem: FC<ReblogMenuItemProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Switch between the standalone boost button or the
|
// Legacy helpers
|
||||||
// "Boost or quote" menu based on the quickBoosting preference
|
|
||||||
export const BoostButton = quickBoosting
|
// Switch between the legacy and new reblog button based on feature flag.
|
||||||
? StandaloneBoostButton
|
export const BoostButton: FC<ReblogButtonProps> = (props) => {
|
||||||
: BoostOrQuoteMenu;
|
if (isFeatureEnabled('outgoing_quotes')) {
|
||||||
|
return <StatusBoostButton {...props} />;
|
||||||
|
}
|
||||||
|
return <LegacyReblogButton {...props} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LegacyReblogButton: FC<ReblogButtonProps> = ({
|
||||||
|
status,
|
||||||
|
counters,
|
||||||
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const statusState = useAppSelector((state) =>
|
||||||
|
selectStatusState(state, status),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { title, meta, iconComponent, disabled } = useMemo(
|
||||||
|
() => boostItemState(statusState),
|
||||||
|
[statusState],
|
||||||
|
);
|
||||||
|
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const handleClick: MouseEventHandler = useCallback(
|
||||||
|
(event) => {
|
||||||
|
if (statusState.isLoggedIn) {
|
||||||
|
dispatch(toggleReblog(status.get('id') as string, event.shiftKey));
|
||||||
|
} else {
|
||||||
|
dispatch(
|
||||||
|
openModal({
|
||||||
|
modalType: 'INTERACTION',
|
||||||
|
modalProps: {
|
||||||
|
accountId: status.getIn(['account', 'id']),
|
||||||
|
url: status.get('uri'),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[dispatch, status, statusState.isLoggedIn],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
disabled={disabled}
|
||||||
|
active={!!status.get('reblogged')}
|
||||||
|
title={intl.formatMessage(meta ?? title)}
|
||||||
|
icon='retweet'
|
||||||
|
iconComponent={iconComponent}
|
||||||
|
onClick={!disabled ? handleClick : undefined}
|
||||||
|
counter={
|
||||||
|
counters
|
||||||
|
? (status.get('reblogs_count') as number) +
|
||||||
|
(status.get('quotes_count') as number)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
||||||
|
|
||||||
import { HashtagMenuController } from '@/flavours/glitch/features/ui/components/hashtag_menu_controller';
|
|
||||||
import { accountFactoryState } from '@/testing/factories';
|
|
||||||
|
|
||||||
import { HoverCardController } from '../hover_card_controller';
|
|
||||||
|
|
||||||
import type { HandledLinkProps } from './handled_link';
|
|
||||||
import { HandledLink } from './handled_link';
|
|
||||||
|
|
||||||
type HandledLinkStoryProps = Pick<
|
|
||||||
HandledLinkProps,
|
|
||||||
'href' | 'text' | 'prevText'
|
|
||||||
> & {
|
|
||||||
mentionAccount: 'local' | 'remote' | 'none';
|
|
||||||
hashtagAccount: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const meta = {
|
|
||||||
title: 'Components/Status/HandledLink',
|
|
||||||
render({ mentionAccount, hashtagAccount, ...args }) {
|
|
||||||
let mention: HandledLinkProps['mention'] | undefined;
|
|
||||||
if (mentionAccount === 'local') {
|
|
||||||
mention = { id: '1', acct: 'testuser', username: 'testuser' };
|
|
||||||
} else if (mentionAccount === 'remote') {
|
|
||||||
mention = {
|
|
||||||
id: '2',
|
|
||||||
acct: 'remoteuser@mastodon.social',
|
|
||||||
username: 'remoteuser',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
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!',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
import { useCallback, useEffect, useRef } from 'react';
|
|
||||||
import type { ComponentProps, FC } from 'react';
|
|
||||||
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
import type { ApiMentionJSON } from '@/flavours/glitch/api_types/statuses';
|
|
||||||
import { useAppSelector } from '@/flavours/glitch/store';
|
|
||||||
import type { OnElementHandler } from '@/flavours/glitch/utils/html';
|
|
||||||
import { decode as decodeIDNA } from 'flavours/glitch/utils/idna';
|
|
||||||
|
|
||||||
export interface HandledLinkProps {
|
|
||||||
href: string;
|
|
||||||
text: string;
|
|
||||||
prevText?: string;
|
|
||||||
hashtagAccountId?: string;
|
|
||||||
mention?: Pick<ApiMentionJSON, 'id' | 'acct' | 'username'>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const textMatchesTarget = (text: string, origin: string, host: string) => {
|
|
||||||
return (
|
|
||||||
text === origin ||
|
|
||||||
text === host ||
|
|
||||||
text.startsWith(origin + '/') ||
|
|
||||||
text.startsWith(host + '/') ||
|
|
||||||
'www.' + text === host ||
|
|
||||||
('www.' + text).startsWith(host + '/')
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isLinkMisleading = (link: HTMLAnchorElement) => {
|
|
||||||
const linkTextParts: string[] = [];
|
|
||||||
|
|
||||||
// Reconstruct visible text, as we do not have much control over how links
|
|
||||||
// from remote software look, and we can't rely on `innerText` because the
|
|
||||||
// `invisible` class does not set `display` to `none`.
|
|
||||||
|
|
||||||
const walk = (node: Node) => {
|
|
||||||
if (node instanceof Text) {
|
|
||||||
linkTextParts.push(node.textContent);
|
|
||||||
} else if (node instanceof HTMLElement) {
|
|
||||||
if (node.classList.contains('invisible')) return;
|
|
||||||
for (const child of node.childNodes) {
|
|
||||||
walk(child);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
walk(link);
|
|
||||||
|
|
||||||
const linkText = linkTextParts.join('');
|
|
||||||
const targetURL = new URL(link.href);
|
|
||||||
|
|
||||||
if (targetURL.protocol === 'magnet:') {
|
|
||||||
return !linkText.startsWith('magnet:');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetURL.protocol === 'xmpp:') {
|
|
||||||
return !(
|
|
||||||
linkText === targetURL.href || 'xmpp:' + linkText === targetURL.href
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The following may not work with international domain names
|
|
||||||
if (
|
|
||||||
textMatchesTarget(linkText, targetURL.origin, targetURL.host) ||
|
|
||||||
textMatchesTarget(linkText.toLowerCase(), targetURL.origin, targetURL.host)
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The link hasn't been recognized, maybe it features an international domain name
|
|
||||||
const hostname = decodeIDNA(targetURL.hostname).normalize('NFKC');
|
|
||||||
const host = targetURL.host.replace(targetURL.hostname, hostname);
|
|
||||||
const origin = targetURL.origin.replace(targetURL.host, host);
|
|
||||||
const text = linkText.normalize('NFKC');
|
|
||||||
return !(
|
|
||||||
textMatchesTarget(text, origin, host) ||
|
|
||||||
textMatchesTarget(text.toLowerCase(), origin, host)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const tagMisleadingLink = (link: HTMLAnchorElement) => {
|
|
||||||
try {
|
|
||||||
if (isLinkMisleading(link)) {
|
|
||||||
const url = new URL(link.href);
|
|
||||||
const tag = document.createElement('span');
|
|
||||||
tag.classList.add('link-origin-tag');
|
|
||||||
switch (url.protocol) {
|
|
||||||
case 'xmpp:':
|
|
||||||
tag.textContent = `[${url.href}]`;
|
|
||||||
break;
|
|
||||||
case 'magnet:':
|
|
||||||
tag.textContent = '(magnet)';
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
tag.textContent = `[${url.host}]`;
|
|
||||||
}
|
|
||||||
link.insertAdjacentText('beforeend', ' ');
|
|
||||||
link.insertAdjacentElement('beforeend', tag);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// The URL is invalid, remove the href just to be safe
|
|
||||||
if (e instanceof TypeError) link.removeAttribute('href');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const HandledLink: FC<HandledLinkProps & ComponentProps<'a'>> = ({
|
|
||||||
href,
|
|
||||||
text,
|
|
||||||
prevText,
|
|
||||||
hashtagAccountId,
|
|
||||||
mention,
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
const rewriteMentions = useAppSelector(
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
|
||||||
(state) => state.local_settings.get('rewrite_mentions', 'no') as string,
|
|
||||||
);
|
|
||||||
const tagLinks = useAppSelector(
|
|
||||||
(state) =>
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
|
||||||
state.local_settings.get('tag_misleading_links', false) as string,
|
|
||||||
);
|
|
||||||
|
|
||||||
const linkRef = useRef<HTMLAnchorElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (tagLinks && linkRef.current) tagMisleadingLink(linkRef.current);
|
|
||||||
}, [tagLinks]);
|
|
||||||
|
|
||||||
// Handle hashtags
|
|
||||||
if (
|
|
||||||
text.startsWith('#') ||
|
|
||||||
prevText?.endsWith('#') ||
|
|
||||||
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 (mention) {
|
|
||||||
// glitch-soc feature to rewrite mentions
|
|
||||||
if (rewriteMentions !== 'no') {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
className={classNames('mention', className)}
|
|
||||||
to={`/@${mention.acct}`}
|
|
||||||
title={`@${mention.acct}`}
|
|
||||||
data-hover-card-account={mention.id}
|
|
||||||
>
|
|
||||||
@
|
|
||||||
<span>
|
|
||||||
{rewriteMentions === 'acct' ? mention.acct : mention.username}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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'
|
|
||||||
ref={linkRef}
|
|
||||||
>
|
|
||||||
{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 };
|
|
||||||
};
|
|
||||||
@@ -22,13 +22,13 @@ import { accountAdminLink, statusAdminLink } from 'flavours/glitch/utils/backend
|
|||||||
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
|
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
|
||||||
|
|
||||||
import { Dropdown } from 'flavours/glitch/components/dropdown_menu';
|
import { Dropdown } from 'flavours/glitch/components/dropdown_menu';
|
||||||
import { me, quickBoosting } from '../../initial_state';
|
import { me } from '../../initial_state';
|
||||||
|
|
||||||
import { IconButton } from '../icon_button';
|
import { IconButton } from '../icon_button';
|
||||||
import { RelativeTimestamp } from '../relative_timestamp';
|
import { RelativeTimestamp } from '../relative_timestamp';
|
||||||
|
import { isFeatureEnabled } from '../../utils/environment';
|
||||||
import { BoostButton } from '../status/boost_button';
|
import { BoostButton } from '../status/boost_button';
|
||||||
import { RemoveQuoteHint } from './remove_quote_hint';
|
import { RemoveQuoteHint } from './remove_quote_hint';
|
||||||
import { quoteItemState, selectStatusState } from '../status/boost_button_utils';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
||||||
@@ -69,7 +69,6 @@ const mapStateToProps = (state, { status }) => {
|
|||||||
const quotedStatusId = status.getIn(['quote', 'quoted_status']);
|
const quotedStatusId = status.getIn(['quote', 'quoted_status']);
|
||||||
return ({
|
return ({
|
||||||
quotedAccountId: quotedStatusId ? state.getIn(['statuses', quotedStatusId, 'account']) : null,
|
quotedAccountId: quotedStatusId ? state.getIn(['statuses', quotedStatusId, 'account']) : null,
|
||||||
statusQuoteState: selectStatusState(state, status),
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -77,7 +76,6 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||||||
static propTypes = {
|
static propTypes = {
|
||||||
identity: identityContextPropShape,
|
identity: identityContextPropShape,
|
||||||
status: ImmutablePropTypes.map.isRequired,
|
status: ImmutablePropTypes.map.isRequired,
|
||||||
statusQuoteState: PropTypes.object,
|
|
||||||
quotedAccountId: PropTypes.string,
|
quotedAccountId: PropTypes.string,
|
||||||
contextType: PropTypes.string,
|
contextType: PropTypes.string,
|
||||||
onReply: PropTypes.func,
|
onReply: PropTypes.func,
|
||||||
@@ -125,10 +123,6 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
handleQuoteClick = () => {
|
|
||||||
this.props.onQuote(this.props.status);
|
|
||||||
};
|
|
||||||
|
|
||||||
handleShareClick = () => {
|
handleShareClick = () => {
|
||||||
navigator.share({
|
navigator.share({
|
||||||
url: this.props.status.get('url'),
|
url: this.props.status.get('url'),
|
||||||
@@ -221,7 +215,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { status, statusQuoteState, quotedAccountId, contextType, intl, withDismiss, withCounters, showReplyCount, scrollKey } = this.props;
|
const { status, quotedAccountId, contextType, intl, withDismiss, withCounters, showReplyCount, scrollKey } = this.props;
|
||||||
const { signedIn, permissions } = this.props.identity;
|
const { signedIn, permissions } = this.props.identity;
|
||||||
|
|
||||||
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
|
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
|
||||||
@@ -250,19 +244,6 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||||||
menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
|
menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (quickBoosting && signedIn) {
|
|
||||||
const quoteItem = quoteItemState(statusQuoteState);
|
|
||||||
menu.push(null);
|
|
||||||
menu.push({
|
|
||||||
text: intl.formatMessage(quoteItem.title),
|
|
||||||
description: quoteItem.meta
|
|
||||||
? intl.formatMessage(quoteItem.meta)
|
|
||||||
: undefined,
|
|
||||||
disabled: quoteItem.disabled,
|
|
||||||
action: this.handleQuoteClick,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (signedIn) {
|
if (signedIn) {
|
||||||
menu.push(null);
|
menu.push(null);
|
||||||
|
|
||||||
@@ -273,7 +254,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||||||
|
|
||||||
if (writtenByMe || withDismiss) {
|
if (writtenByMe || withDismiss) {
|
||||||
menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
|
menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
|
||||||
if (writtenByMe && !['private', 'direct'].includes(status.get('visibility'))) {
|
if (writtenByMe && isFeatureEnabled('outgoing_quotes') && !['private', 'direct'].includes(status.get('visibility'))) {
|
||||||
menu.push({ text: intl.formatMessage(messages.quotePolicyChange), action: this.handleQuotePolicyChange });
|
menu.push({ text: intl.formatMessage(messages.quotePolicyChange), action: this.handleQuotePolicyChange });
|
||||||
}
|
}
|
||||||
menu.push(null);
|
menu.push(null);
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import { useCallback, useRef, useId } from 'react';
|
|||||||
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import { AnimateEmojiProvider } from './emoji/context';
|
|
||||||
|
|
||||||
export enum BannerVariant {
|
export enum BannerVariant {
|
||||||
Warning = 'warning',
|
Warning = 'warning',
|
||||||
Filter = 'filter',
|
Filter = 'filter',
|
||||||
@@ -36,7 +34,8 @@ export const StatusBanner: React.FC<{
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
// Element clicks are passed on to button
|
// Element clicks are passed on to button
|
||||||
<AnimateEmojiProvider
|
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||||
|
<div
|
||||||
className={
|
className={
|
||||||
variant === BannerVariant.Warning
|
variant === BannerVariant.Warning
|
||||||
? 'content-warning'
|
? 'content-warning'
|
||||||
@@ -70,6 +69,6 @@ export const StatusBanner: React.FC<{
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</AnimateEmojiProvider>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,19 +13,77 @@ import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'
|
|||||||
import { Icon } from 'flavours/glitch/components/icon';
|
import { Icon } from 'flavours/glitch/components/icon';
|
||||||
import { Poll } from 'flavours/glitch/components/poll';
|
import { Poll } from 'flavours/glitch/components/poll';
|
||||||
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
|
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
|
||||||
import { languages as preloadedLanguages } from 'flavours/glitch/initial_state';
|
import { autoPlayGif, languages as preloadedLanguages } from 'flavours/glitch/initial_state';
|
||||||
|
import { decode as decodeIDNA } from 'flavours/glitch/utils/idna';
|
||||||
import { EmojiHTML } from './emoji/html';
|
import { EmojiHTML } from '../features/emoji/emoji_html';
|
||||||
import { HandledLink } from './status/handled_link';
|
import { isModernEmojiEnabled } from '../utils/environment';
|
||||||
|
|
||||||
const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
|
const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
|
||||||
|
|
||||||
|
const textMatchesTarget = (text, origin, host) => {
|
||||||
|
return (text === origin || text === host
|
||||||
|
|| text.startsWith(origin + '/') || text.startsWith(host + '/')
|
||||||
|
|| 'www.' + text === host || ('www.' + text).startsWith(host + '/'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLinkMisleading = (link) => {
|
||||||
|
let linkTextParts = [];
|
||||||
|
|
||||||
|
// Reconstruct visible text, as we do not have much control over how links
|
||||||
|
// from remote software look, and we can't rely on `innerText` because the
|
||||||
|
// `invisible` class does not set `display` to `none`.
|
||||||
|
|
||||||
|
const walk = (node) => {
|
||||||
|
switch (node.nodeType) {
|
||||||
|
case Node.TEXT_NODE:
|
||||||
|
linkTextParts.push(node.textContent);
|
||||||
|
break;
|
||||||
|
case Node.ELEMENT_NODE: {
|
||||||
|
if (node.classList.contains('invisible')) return;
|
||||||
|
const children = node.childNodes;
|
||||||
|
for (let i = 0; i < children.length; i++) {
|
||||||
|
walk(children[i]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
walk(link);
|
||||||
|
|
||||||
|
const linkText = linkTextParts.join('');
|
||||||
|
const targetURL = new URL(link.href);
|
||||||
|
|
||||||
|
if (targetURL.protocol === 'magnet:') {
|
||||||
|
return !linkText.startsWith('magnet:');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetURL.protocol === 'xmpp:') {
|
||||||
|
return !(linkText === targetURL.href || 'xmpp:' + linkText === targetURL.href);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The following may not work with international domain names
|
||||||
|
if (textMatchesTarget(linkText, targetURL.origin, targetURL.host) || textMatchesTarget(linkText.toLowerCase(), targetURL.origin, targetURL.host)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The link hasn't been recognized, maybe it features an international domain name
|
||||||
|
const hostname = decodeIDNA(targetURL.hostname).normalize('NFKC');
|
||||||
|
const host = targetURL.host.replace(targetURL.hostname, hostname);
|
||||||
|
const origin = targetURL.origin.replace(targetURL.host, host);
|
||||||
|
const text = linkText.normalize('NFKC');
|
||||||
|
return !(textMatchesTarget(text, origin, host) || textMatchesTarget(text.toLowerCase(), origin, host));
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {any} status
|
* @param {any} status
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
export function getStatusContent(status) {
|
export function getStatusContent(status) {
|
||||||
|
if (isModernEmojiEnabled()) {
|
||||||
|
return status.getIn(['translation', 'content']) || status.get('content');
|
||||||
|
}
|
||||||
return status.getIn(['translation', 'contentHtml']) || status.get('contentHtml');
|
return status.getIn(['translation', 'contentHtml']) || status.get('contentHtml');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,17 +128,6 @@ const mapStateToProps = state => ({
|
|||||||
languages: state.getIn(['server', 'translationLanguages', 'items']),
|
languages: state.getIn(['server', 'translationLanguages', 'items']),
|
||||||
});
|
});
|
||||||
|
|
||||||
const compareUrls = (href1, href2) => {
|
|
||||||
try {
|
|
||||||
const url1 = new URL(href1);
|
|
||||||
const url2 = new URL(href2);
|
|
||||||
|
|
||||||
return url1.origin === url2.origin && url1.pathname === url2.pathname && url1.search === url2.search;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
class StatusContent extends PureComponent {
|
class StatusContent extends PureComponent {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
identity: identityContextPropShape,
|
identity: identityContextPropShape,
|
||||||
@@ -90,6 +137,8 @@ class StatusContent extends PureComponent {
|
|||||||
onClick: PropTypes.func,
|
onClick: PropTypes.func,
|
||||||
collapsible: PropTypes.bool,
|
collapsible: PropTypes.bool,
|
||||||
onCollapsedToggle: PropTypes.func,
|
onCollapsedToggle: PropTypes.func,
|
||||||
|
tagLinks: PropTypes.bool,
|
||||||
|
rewriteMentions: PropTypes.string,
|
||||||
languages: ImmutablePropTypes.map,
|
languages: ImmutablePropTypes.map,
|
||||||
intl: PropTypes.object,
|
intl: PropTypes.object,
|
||||||
// from react-router
|
// from react-router
|
||||||
@@ -98,14 +147,83 @@ class StatusContent extends PureComponent {
|
|||||||
history: PropTypes.object.isRequired
|
history: PropTypes.object.isRequired
|
||||||
};
|
};
|
||||||
|
|
||||||
|
static defaultProps = {
|
||||||
|
tagLinks: true,
|
||||||
|
rewriteMentions: 'no',
|
||||||
|
};
|
||||||
|
|
||||||
_updateStatusLinks () {
|
_updateStatusLinks () {
|
||||||
const node = this.node;
|
const node = this.node;
|
||||||
|
const { tagLinks, rewriteMentions } = this.props;
|
||||||
|
|
||||||
if (!node) {
|
if (!node) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { status, onCollapsedToggle } = this.props;
|
const { status, onCollapsedToggle } = this.props;
|
||||||
|
const links = node.querySelectorAll('a');
|
||||||
|
|
||||||
|
let link, mention;
|
||||||
|
|
||||||
|
for (var i = 0; i < links.length; ++i) {
|
||||||
|
link = links[i];
|
||||||
|
|
||||||
|
if (link.classList.contains('status-link')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
link.classList.add('status-link');
|
||||||
|
|
||||||
|
mention = this.props.status.get('mentions').find(item => link.href === item.get('url'));
|
||||||
|
|
||||||
|
if (mention) {
|
||||||
|
link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
|
||||||
|
link.setAttribute('title', `@${mention.get('acct')}`);
|
||||||
|
link.setAttribute('data-hover-card-account', mention.get('id'));
|
||||||
|
if (rewriteMentions !== 'no') {
|
||||||
|
while (link.firstChild) link.removeChild(link.firstChild);
|
||||||
|
link.appendChild(document.createTextNode('@'));
|
||||||
|
const acctSpan = document.createElement('span');
|
||||||
|
acctSpan.textContent = rewriteMentions === 'acct' ? mention.get('acct') : mention.get('username');
|
||||||
|
link.appendChild(acctSpan);
|
||||||
|
}
|
||||||
|
} else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
|
||||||
|
link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
|
||||||
|
link.setAttribute('data-menu-hashtag', this.props.status.getIn(['account', 'id']));
|
||||||
|
} else {
|
||||||
|
link.setAttribute('title', link.href);
|
||||||
|
link.classList.add('unhandled-link');
|
||||||
|
|
||||||
|
link.setAttribute('target', '_blank');
|
||||||
|
link.setAttribute('rel', 'noopener nofollow');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (tagLinks && isLinkMisleading(link)) {
|
||||||
|
// Add a tag besides the link to display its origin
|
||||||
|
|
||||||
|
const url = new URL(link.href);
|
||||||
|
const tag = document.createElement('span');
|
||||||
|
tag.classList.add('link-origin-tag');
|
||||||
|
switch (url.protocol) {
|
||||||
|
case 'xmpp:':
|
||||||
|
tag.textContent = `[${url.href}]`;
|
||||||
|
break;
|
||||||
|
case 'magnet:':
|
||||||
|
tag.textContent = '(magnet)';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
tag.textContent = `[${url.host}]`;
|
||||||
|
}
|
||||||
|
link.insertAdjacentText('beforeend', ' ');
|
||||||
|
link.insertAdjacentElement('beforeend', tag);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// The URL is invalid, remove the href just to be safe
|
||||||
|
if (tagLinks && e instanceof TypeError) link.removeAttribute('href');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (status.get('collapsed', null) === null && onCollapsedToggle) {
|
if (status.get('collapsed', null) === null && onCollapsedToggle) {
|
||||||
const { collapsible, onClick } = this.props;
|
const { collapsible, onClick } = this.props;
|
||||||
|
|
||||||
@@ -119,6 +237,32 @@ class StatusContent extends PureComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleMouseEnter = ({ currentTarget }) => {
|
||||||
|
if (autoPlayGif) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emojis = currentTarget.querySelectorAll('.custom-emoji');
|
||||||
|
|
||||||
|
for (var i = 0; i < emojis.length; i++) {
|
||||||
|
let emoji = emojis[i];
|
||||||
|
emoji.src = emoji.getAttribute('data-original');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleMouseLeave = ({ currentTarget }) => {
|
||||||
|
if (autoPlayGif) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emojis = currentTarget.querySelectorAll('.custom-emoji');
|
||||||
|
|
||||||
|
for (var i = 0; i < emojis.length; i++) {
|
||||||
|
let emoji = emojis[i];
|
||||||
|
emoji.src = emoji.getAttribute('data-static');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
componentDidMount () {
|
componentDidMount () {
|
||||||
this._updateStatusLinks();
|
this._updateStatusLinks();
|
||||||
}
|
}
|
||||||
@@ -127,6 +271,22 @@ class StatusContent extends PureComponent {
|
|||||||
this._updateStatusLinks();
|
this._updateStatusLinks();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMentionClick = (mention, e) => {
|
||||||
|
if (this.props.history && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.props.history.push(`/@${mention.get('acct')}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onHashtagClick = (hashtag, e) => {
|
||||||
|
hashtag = hashtag.replace(/^#/, '');
|
||||||
|
|
||||||
|
if (this.props.history && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.props.history.push(`/tags/${hashtag}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
handleMouseDown = (e) => {
|
handleMouseDown = (e) => {
|
||||||
this.startXY = [e.clientX, e.clientY];
|
this.startXY = [e.clientX, e.clientY];
|
||||||
};
|
};
|
||||||
@@ -162,27 +322,6 @@ class StatusContent extends PureComponent {
|
|||||||
this.node = c;
|
this.node = c;
|
||||||
};
|
};
|
||||||
|
|
||||||
handleElement = (element, { key, ...props }, children) => {
|
|
||||||
if (element instanceof HTMLAnchorElement) {
|
|
||||||
const mention = this.props.status.get('mentions').find(item => compareUrls(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.classList.contains('quote-inline')) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { status, intl, statusContent } = this.props;
|
const { status, intl, statusContent } = this.props;
|
||||||
|
|
||||||
@@ -215,19 +354,12 @@ class StatusContent extends PureComponent {
|
|||||||
if (this.props.onClick) {
|
if (this.props.onClick) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div className={classNames} ref={this.setRef} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
||||||
className={classNames}
|
|
||||||
ref={this.setRef}
|
|
||||||
onMouseDown={this.handleMouseDown}
|
|
||||||
onMouseUp={this.handleMouseUp}
|
|
||||||
key='status-content'
|
|
||||||
>
|
|
||||||
<EmojiHTML
|
<EmojiHTML
|
||||||
className='status__content__text status__content__text--visible translate'
|
className='status__content__text status__content__text--visible translate'
|
||||||
lang={language}
|
lang={language}
|
||||||
htmlString={content}
|
htmlString={content}
|
||||||
extraEmojis={status.get('emojis')}
|
extraEmojis={status.get('emojis')}
|
||||||
onElement={this.handleElement}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{poll}
|
{poll}
|
||||||
@@ -239,13 +371,12 @@ class StatusContent extends PureComponent {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<div className={classNames} ref={this.setRef}>
|
<div className={classNames} ref={this.setRef} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
||||||
<EmojiHTML
|
<EmojiHTML
|
||||||
className='status__content__text status__content__text--visible translate'
|
className='status__content__text status__content__text--visible translate'
|
||||||
lang={language}
|
lang={language}
|
||||||
htmlString={content}
|
htmlString={content}
|
||||||
extraEmojis={status.get('emojis')}
|
extraEmojis={status.get('emojis')}
|
||||||
onElement={this.handleElement}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{poll}
|
{poll}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
@@ -12,7 +12,6 @@ import type { Status } from 'flavours/glitch/models/status';
|
|||||||
import type { RootState } from 'flavours/glitch/store';
|
import type { RootState } from 'flavours/glitch/store';
|
||||||
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
||||||
|
|
||||||
import { fetchRelationships } from '../actions/accounts';
|
|
||||||
import { revealAccount } from '../actions/accounts_typed';
|
import { revealAccount } from '../actions/accounts_typed';
|
||||||
import { fetchStatus } from '../actions/statuses';
|
import { fetchStatus } from '../actions/statuses';
|
||||||
import { makeGetStatusWithExtraInfo } from '../selectors';
|
import { makeGetStatusWithExtraInfo } from '../selectors';
|
||||||
@@ -83,62 +82,6 @@ const LimitedAccountHint: React.FC<{ accountId: string }> = ({ accountId }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const FilteredQuote: React.FC<{
|
|
||||||
reveal: VoidFunction;
|
|
||||||
quotedAccountId: string;
|
|
||||||
quoteState: string;
|
|
||||||
}> = ({ reveal, quotedAccountId, quoteState }) => {
|
|
||||||
const account = useAppSelector((state) =>
|
|
||||||
quotedAccountId ? state.accounts.get(quotedAccountId) : undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
const quoteAuthorName = account?.acct;
|
|
||||||
const domain = quoteAuthorName?.split('@')[1];
|
|
||||||
|
|
||||||
let message;
|
|
||||||
|
|
||||||
switch (quoteState) {
|
|
||||||
case 'blocked_account':
|
|
||||||
message = (
|
|
||||||
<FormattedMessage
|
|
||||||
id='status.quote_error.blocked_account_hint.title'
|
|
||||||
defaultMessage="This post is hidden because you've blocked @{name}."
|
|
||||||
values={{ name: quoteAuthorName }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case 'blocked_domain':
|
|
||||||
message = (
|
|
||||||
<FormattedMessage
|
|
||||||
id='status.quote_error.blocked_domain_hint.title'
|
|
||||||
defaultMessage="This post is hidden because you've blocked {domain}."
|
|
||||||
values={{ domain }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case 'muted_account':
|
|
||||||
message = (
|
|
||||||
<FormattedMessage
|
|
||||||
id='status.quote_error.muted_account_hint.title'
|
|
||||||
defaultMessage="This post is hidden because you've muted @{name}."
|
|
||||||
values={{ name: quoteAuthorName }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{message}
|
|
||||||
<button onClick={reveal} className='link-button'>
|
|
||||||
<FormattedMessage
|
|
||||||
id='status.quote_error.limited_account_hint.action'
|
|
||||||
defaultMessage='Show anyway'
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface QuotedStatusProps {
|
interface QuotedStatusProps {
|
||||||
quote: QuoteMap;
|
quote: QuoteMap;
|
||||||
contextType?: string;
|
contextType?: string;
|
||||||
@@ -186,11 +129,6 @@ export const QuotedStatus: React.FC<QuotedStatusProps> = ({
|
|||||||
const isLoaded = loadingState === 'complete';
|
const isLoaded = loadingState === 'complete';
|
||||||
|
|
||||||
const isFetchingQuoteRef = useRef(false);
|
const isFetchingQuoteRef = useRef(false);
|
||||||
const [revealed, setRevealed] = useState(false);
|
|
||||||
|
|
||||||
const reveal = useCallback(() => {
|
|
||||||
setRevealed(true);
|
|
||||||
}, [setRevealed]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoaded) {
|
if (isLoaded) {
|
||||||
@@ -210,10 +148,6 @@ export const QuotedStatus: React.FC<QuotedStatusProps> = ({
|
|||||||
}
|
}
|
||||||
}, [shouldFetchQuote, quotedStatusId, parentQuotePostId, dispatch]);
|
}, [shouldFetchQuote, quotedStatusId, parentQuotePostId, dispatch]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (accountId && hiddenAccount) dispatch(fetchRelationships([accountId]));
|
|
||||||
}, [accountId, hiddenAccount, dispatch]);
|
|
||||||
|
|
||||||
const isFilteredAndHidden = loadingState === 'filtered';
|
const isFilteredAndHidden = loadingState === 'filtered';
|
||||||
|
|
||||||
let quoteError: React.ReactNode = null;
|
let quoteError: React.ReactNode = null;
|
||||||
@@ -250,20 +184,6 @@ export const QuotedStatus: React.FC<QuotedStatusProps> = ({
|
|||||||
defaultMessage='Post removed by author'
|
defaultMessage='Post removed by author'
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (
|
|
||||||
(quoteState === 'blocked_account' ||
|
|
||||||
quoteState === 'blocked_domain' ||
|
|
||||||
quoteState === 'muted_account') &&
|
|
||||||
!revealed &&
|
|
||||||
accountId
|
|
||||||
) {
|
|
||||||
quoteError = (
|
|
||||||
<FilteredQuote
|
|
||||||
quoteState={quoteState}
|
|
||||||
reveal={reveal}
|
|
||||||
quotedAccountId={accountId}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (
|
} else if (
|
||||||
!status ||
|
!status ||
|
||||||
!quotedStatusId ||
|
!quotedStatusId ||
|
||||||
|
|||||||
@@ -1,24 +1,21 @@
|
|||||||
import { EmojiHTML } from '@/flavours/glitch/components/emoji/html';
|
|
||||||
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||||
|
|
||||||
import type { OnAttributeHandler } from '../utils/html';
|
|
||||||
|
|
||||||
import { Icon } from './icon';
|
import { Icon } from './icon';
|
||||||
|
|
||||||
const onAttribute: OnAttributeHandler = (name, value, tagName) => {
|
const domParser = new DOMParser();
|
||||||
if (name === 'rel' && tagName === 'a') {
|
|
||||||
if (value === 'me') {
|
const stripRelMe = (html: string) => {
|
||||||
return null;
|
const document = domParser.parseFromString(html, 'text/html').documentElement;
|
||||||
}
|
|
||||||
return [
|
document.querySelectorAll<HTMLAnchorElement>('a[rel]').forEach((link) => {
|
||||||
name,
|
link.rel = link.rel
|
||||||
value
|
|
||||||
.split(' ')
|
.split(' ')
|
||||||
.filter((x) => x !== 'me')
|
.filter((x: string) => x !== 'me')
|
||||||
.join(' '),
|
.join(' ');
|
||||||
];
|
});
|
||||||
}
|
|
||||||
return undefined;
|
const body = document.querySelector('body');
|
||||||
|
return body ? { __html: body.innerHTML } : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -27,6 +24,6 @@ interface Props {
|
|||||||
export const VerifiedBadge: React.FC<Props> = ({ link }) => (
|
export const VerifiedBadge: React.FC<Props> = ({ link }) => (
|
||||||
<span className='verified-badge'>
|
<span className='verified-badge'>
|
||||||
<Icon id='check' icon={CheckIcon} className='verified-badge__mark' />
|
<Icon id='check' icon={CheckIcon} className='verified-badge__mark' />
|
||||||
<EmojiHTML as='span' htmlString={link} onAttribute={onAttribute} />
|
<span dangerouslySetInnerHTML={stripRelMe(link)} />
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { fetchServer } from 'flavours/glitch/actions/server';
|
|||||||
import { hydrateStore } from 'flavours/glitch/actions/store';
|
import { hydrateStore } from 'flavours/glitch/actions/store';
|
||||||
import { Router } from 'flavours/glitch/components/router';
|
import { Router } from 'flavours/glitch/components/router';
|
||||||
import Compose from 'flavours/glitch/features/standalone/compose';
|
import Compose from 'flavours/glitch/features/standalone/compose';
|
||||||
import { initialState } from 'flavours/glitch/initial_state';
|
import initialState from 'flavours/glitch/initial_state';
|
||||||
import { IntlProvider } from 'flavours/glitch/locales';
|
import { IntlProvider } from 'flavours/glitch/locales';
|
||||||
import { store } from 'flavours/glitch/store';
|
import { store } from 'flavours/glitch/store';
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Route } from 'react-router-dom';
|
|||||||
|
|
||||||
import { Provider as ReduxProvider } from 'react-redux';
|
import { Provider as ReduxProvider } from 'react-redux';
|
||||||
|
|
||||||
|
import { ScrollContext } from 'react-router-scroll-4';
|
||||||
|
|
||||||
import { fetchCustomEmojis } from 'flavours/glitch/actions/custom_emojis';
|
import { fetchCustomEmojis } from 'flavours/glitch/actions/custom_emojis';
|
||||||
import { checkDeprecatedLocalSettings } from 'flavours/glitch/actions/local_settings';
|
import { checkDeprecatedLocalSettings } from 'flavours/glitch/actions/local_settings';
|
||||||
@@ -14,14 +15,12 @@ import ErrorBoundary from 'flavours/glitch/components/error_boundary';
|
|||||||
import { Router } from 'flavours/glitch/components/router';
|
import { Router } from 'flavours/glitch/components/router';
|
||||||
import UI from 'flavours/glitch/features/ui';
|
import UI from 'flavours/glitch/features/ui';
|
||||||
import { IdentityContext, createIdentityContext } from 'flavours/glitch/identity_context';
|
import { IdentityContext, createIdentityContext } from 'flavours/glitch/identity_context';
|
||||||
import { initialState, title as siteTitle } from 'flavours/glitch/initial_state';
|
import initialState, { title as siteTitle } from 'flavours/glitch/initial_state';
|
||||||
import { IntlProvider } from 'flavours/glitch/locales';
|
import { IntlProvider } from 'flavours/glitch/locales';
|
||||||
import { store } from 'flavours/glitch/store';
|
import { store } from 'flavours/glitch/store';
|
||||||
import { isProduction } from 'flavours/glitch/utils/environment';
|
import { isProduction } from 'flavours/glitch/utils/environment';
|
||||||
import { BodyScrollLock } from 'flavours/glitch/features/ui/components/body_scroll_lock';
|
import { BodyScrollLock } from 'flavours/glitch/features/ui/components/body_scroll_lock';
|
||||||
|
|
||||||
import { ScrollContext } from './scroll_container/scroll_context';
|
|
||||||
|
|
||||||
const title = isProduction() ? siteTitle : `${siteTitle} (Dev)`;
|
const title = isProduction() ? siteTitle : `${siteTitle} (Dev)`;
|
||||||
|
|
||||||
const hydrateAction = hydrateStore(initialState);
|
const hydrateAction = hydrateStore(initialState);
|
||||||
@@ -51,6 +50,10 @@ export default class Mastodon extends PureComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
shouldUpdateScroll (prevRouterProps, { location }) {
|
||||||
|
return !(location.state?.mastodonModalKey && location.state?.mastodonModalKey !== prevRouterProps?.location?.state?.mastodonModalKey);
|
||||||
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
return (
|
return (
|
||||||
<IdentityContext.Provider value={this.identity}>
|
<IdentityContext.Provider value={this.identity}>
|
||||||
@@ -58,7 +61,7 @@ export default class Mastodon extends PureComponent {
|
|||||||
<ReduxProvider store={store}>
|
<ReduxProvider store={store}>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<Router>
|
<Router>
|
||||||
<ScrollContext>
|
<ScrollContext shouldUpdateScroll={this.shouldUpdateScroll}>
|
||||||
<Route path='/' component={UI} />
|
<Route path='/' component={UI} />
|
||||||
</ScrollContext>
|
</ScrollContext>
|
||||||
<BodyScrollLock />
|
<BodyScrollLock />
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { ScrollContainer as OriginalScrollContainer } from 'react-router-scroll-4';
|
||||||
|
|
||||||
|
// ScrollContainer is used to automatically scroll to the top when pushing a
|
||||||
|
// new history state and remembering the scroll position when going back.
|
||||||
|
// There are a few things we need to do differently, though.
|
||||||
|
const defaultShouldUpdateScroll = (prevRouterProps, { location }) => {
|
||||||
|
// If the change is caused by opening a modal, do not scroll to top
|
||||||
|
return !(location.state?.mastodonModalKey && location.state?.mastodonModalKey !== prevRouterProps?.location?.state?.mastodonModalKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default
|
||||||
|
class ScrollContainer extends OriginalScrollContainer {
|
||||||
|
|
||||||
|
static defaultProps = {
|
||||||
|
shouldUpdateScroll: defaultShouldUpdateScroll,
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import type { MastodonLocation } from 'flavours/glitch/components/router';
|
|
||||||
|
|
||||||
export type ShouldUpdateScrollFn = (
|
|
||||||
prevLocationContext: MastodonLocation | null,
|
|
||||||
locationContext: MastodonLocation,
|
|
||||||
) => boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ScrollBehavior will automatically scroll to the top on navigations
|
|
||||||
* or restore saved scroll positions, but on some location changes we
|
|
||||||
* need to prevent this.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const defaultShouldUpdateScroll: ShouldUpdateScrollFn = (
|
|
||||||
prevLocation,
|
|
||||||
location,
|
|
||||||
) => {
|
|
||||||
// If the change is caused by opening a modal, do not scroll to top
|
|
||||||
const shouldUpdateScroll = !(
|
|
||||||
location.state?.mastodonModalKey &&
|
|
||||||
location.state.mastodonModalKey !== prevLocation?.state?.mastodonModalKey
|
|
||||||
);
|
|
||||||
|
|
||||||
return shouldUpdateScroll;
|
|
||||||
};
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import React, {
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useImperativeHandle,
|
|
||||||
useRef,
|
|
||||||
} from 'react';
|
|
||||||
|
|
||||||
import { defaultShouldUpdateScroll } from './default_should_update_scroll';
|
|
||||||
import type { ShouldUpdateScrollFn } from './default_should_update_scroll';
|
|
||||||
import { ScrollBehaviorContext } from './scroll_context';
|
|
||||||
|
|
||||||
interface ScrollContainerProps {
|
|
||||||
/**
|
|
||||||
* This key must be static for the element & not change
|
|
||||||
* while the component is mounted.
|
|
||||||
*/
|
|
||||||
scrollKey: string;
|
|
||||||
shouldUpdateScroll?: ShouldUpdateScrollFn;
|
|
||||||
childRef?: React.ForwardedRef<HTMLElement | undefined>;
|
|
||||||
children: React.ReactElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `ScrollContainer` is used to manage the scroll position of elements on the page
|
|
||||||
* that can be scrolled independently of the page body.
|
|
||||||
* This component is a port of the unmaintained https://github.com/ytase/react-router-scroll/
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const ScrollContainer: React.FC<ScrollContainerProps> = ({
|
|
||||||
children,
|
|
||||||
scrollKey,
|
|
||||||
childRef,
|
|
||||||
shouldUpdateScroll = defaultShouldUpdateScroll,
|
|
||||||
}) => {
|
|
||||||
const scrollBehaviorContext = useContext(ScrollBehaviorContext);
|
|
||||||
|
|
||||||
const containerRef = useRef<HTMLElement>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* If a childRef is passed, sync it with the containerRef. This
|
|
||||||
* is necessary because in this component's return statement,
|
|
||||||
* we're overwriting the immediate child component's ref prop.
|
|
||||||
*/
|
|
||||||
useImperativeHandle(childRef, () => containerRef.current, []);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register/unregister scrollable element with ScrollBehavior
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
|
||||||
if (!scrollBehaviorContext || !containerRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
scrollBehaviorContext.registerElement(
|
|
||||||
scrollKey,
|
|
||||||
containerRef.current,
|
|
||||||
(prevLocation, location) => {
|
|
||||||
// Hack to allow accessing scrollBehavior._stateStorage
|
|
||||||
return shouldUpdateScroll.call(
|
|
||||||
scrollBehaviorContext.scrollBehavior,
|
|
||||||
prevLocation,
|
|
||||||
location,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
scrollBehaviorContext.unregisterElement(scrollKey);
|
|
||||||
};
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return React.Children.only(
|
|
||||||
React.cloneElement(children, { ref: containerRef }),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
|
|
||||||
import { useLocation, useHistory } from 'react-router-dom';
|
|
||||||
|
|
||||||
import type { LocationBase } from 'scroll-behavior';
|
|
||||||
import ScrollBehavior from 'scroll-behavior';
|
|
||||||
|
|
||||||
import type {
|
|
||||||
LocationState,
|
|
||||||
MastodonLocation,
|
|
||||||
} from 'flavours/glitch/components/router';
|
|
||||||
import { usePrevious } from 'flavours/glitch/hooks/usePrevious';
|
|
||||||
|
|
||||||
import { defaultShouldUpdateScroll } from './default_should_update_scroll';
|
|
||||||
import type { ShouldUpdateScrollFn } from './default_should_update_scroll';
|
|
||||||
import { SessionStorage } from './state_storage';
|
|
||||||
|
|
||||||
type ScrollBehaviorInstance = InstanceType<
|
|
||||||
typeof ScrollBehavior<LocationBase, MastodonLocation>
|
|
||||||
>;
|
|
||||||
|
|
||||||
export interface ScrollBehaviorContextType {
|
|
||||||
registerElement: (
|
|
||||||
key: string,
|
|
||||||
element: HTMLElement,
|
|
||||||
shouldUpdateScroll: (
|
|
||||||
prevLocationContext: MastodonLocation | null,
|
|
||||||
locationContext: MastodonLocation,
|
|
||||||
) => boolean,
|
|
||||||
) => void;
|
|
||||||
unregisterElement: (key: string) => void;
|
|
||||||
scrollBehavior?: ScrollBehaviorInstance;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ScrollBehaviorContext =
|
|
||||||
React.createContext<ScrollBehaviorContextType | null>(null);
|
|
||||||
|
|
||||||
interface ScrollContextProps {
|
|
||||||
shouldUpdateScroll?: ShouldUpdateScrollFn;
|
|
||||||
children: React.ReactElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A top-level wrapper that provides the app with an instance of the
|
|
||||||
* ScrollBehavior object. scroll-behavior is a library for managing the
|
|
||||||
* scroll position of a single-page app in the same way the browser would
|
|
||||||
* normally do for a multi-page app. This means it'll scroll back to top
|
|
||||||
* when navigating to a new page, and will restore the scroll position
|
|
||||||
* when navigating e.g. using `history.back`.
|
|
||||||
* The library keeps a record of scroll positions in session storage.
|
|
||||||
*
|
|
||||||
* This component is a port of the unmaintained https://github.com/ytase/react-router-scroll/
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const ScrollContext: React.FC<ScrollContextProps> = ({
|
|
||||||
children,
|
|
||||||
shouldUpdateScroll = defaultShouldUpdateScroll,
|
|
||||||
}) => {
|
|
||||||
const location = useLocation<LocationState>();
|
|
||||||
const history = useHistory<LocationState>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Keep the current location in a mutable ref so that ScrollBehavior's
|
|
||||||
* `getCurrentLocation` can access it without having to recreate the
|
|
||||||
* whole ScrollBehavior object
|
|
||||||
*/
|
|
||||||
const currentLocationRef = useRef(location);
|
|
||||||
useEffect(() => {
|
|
||||||
currentLocationRef.current = location;
|
|
||||||
}, [location]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialise ScrollBehavior object once – using state rather
|
|
||||||
* than a ref to simplify the types and ensure it's defined immediately.
|
|
||||||
*/
|
|
||||||
const [scrollBehavior] = useState(
|
|
||||||
(): ScrollBehaviorInstance =>
|
|
||||||
new ScrollBehavior({
|
|
||||||
addNavigationListener: history.listen.bind(history),
|
|
||||||
stateStorage: new SessionStorage(),
|
|
||||||
getCurrentLocation: () =>
|
|
||||||
currentLocationRef.current as unknown as LocationBase,
|
|
||||||
shouldUpdateScroll: (
|
|
||||||
prevLocationContext: MastodonLocation | null,
|
|
||||||
locationContext: MastodonLocation,
|
|
||||||
) =>
|
|
||||||
// Hack to allow accessing scrollBehavior._stateStorage
|
|
||||||
shouldUpdateScroll.call(
|
|
||||||
scrollBehavior,
|
|
||||||
prevLocationContext,
|
|
||||||
locationContext,
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle scroll update when location changes
|
|
||||||
*/
|
|
||||||
const prevLocation = usePrevious(location) ?? null;
|
|
||||||
useEffect(() => {
|
|
||||||
scrollBehavior.updateScroll(prevLocation, location);
|
|
||||||
}, [location, prevLocation, scrollBehavior]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop Scrollbehavior on unmount
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
scrollBehavior.stop();
|
|
||||||
};
|
|
||||||
}, [scrollBehavior]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Provide the app with a way to register separately scrollable
|
|
||||||
* elements to also be tracked by ScrollBehavior. (By default
|
|
||||||
* ScrollBehavior only handles scrolling on the main document body.)
|
|
||||||
*/
|
|
||||||
const contextValue = useMemo<ScrollBehaviorContextType>(
|
|
||||||
() => ({
|
|
||||||
registerElement: (key, element, shouldUpdateScroll) => {
|
|
||||||
scrollBehavior.registerElement(
|
|
||||||
key,
|
|
||||||
element,
|
|
||||||
shouldUpdateScroll,
|
|
||||||
location,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
unregisterElement: (key) => {
|
|
||||||
scrollBehavior.unregisterElement(key);
|
|
||||||
},
|
|
||||||
scrollBehavior,
|
|
||||||
}),
|
|
||||||
[location, scrollBehavior],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollBehaviorContext.Provider value={contextValue}>
|
|
||||||
{React.Children.only(children)}
|
|
||||||
</ScrollBehaviorContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import type { LocationBase, ScrollPosition } from 'scroll-behavior';
|
|
||||||
|
|
||||||
const STATE_KEY_PREFIX = '@@scroll|';
|
|
||||||
|
|
||||||
interface LocationBaseWithKey extends LocationBase {
|
|
||||||
key?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This module is part of our port of https://github.com/ytase/react-router-scroll/
|
|
||||||
* and handles storing scroll positions in SessionStorage.
|
|
||||||
* Stored positions (`[x, y]`) are keyed by the location key and an optional
|
|
||||||
* `scrollKey` that's used for to track separately scrollable elements other
|
|
||||||
* than the document body.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export class SessionStorage {
|
|
||||||
read(
|
|
||||||
location: LocationBaseWithKey,
|
|
||||||
key: string | null,
|
|
||||||
): ScrollPosition | null {
|
|
||||||
const stateKey = this.getStateKey(location, key);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const value = sessionStorage.getItem(stateKey);
|
|
||||||
return value ? (JSON.parse(value) as ScrollPosition) : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
save(location: LocationBaseWithKey, key: string | null, value: unknown) {
|
|
||||||
const stateKey = this.getStateKey(location, key);
|
|
||||||
const storedValue = JSON.stringify(value);
|
|
||||||
|
|
||||||
try {
|
|
||||||
sessionStorage.setItem(stateKey, storedValue);
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
getStateKey(location: LocationBaseWithKey, key: string | null) {
|
|
||||||
const locationKey = location.key;
|
|
||||||
const stateKeyBase = `${STATE_KEY_PREFIX}${locationKey}`;
|
|
||||||
return key == null ? stateKeyBase : `${stateKeyBase}|${key}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
import Status from 'flavours/glitch/components/status';
|
import Status from 'flavours/glitch/components/status';
|
||||||
import { deleteModal } from 'flavours/glitch/initial_state';
|
import { deleteModal } from 'flavours/glitch/initial_state';
|
||||||
import { makeGetStatus, makeGetPictureInPicture } from 'flavours/glitch/selectors';
|
import { makeGetStatus, makeGetPictureInPicture } from 'flavours/glitch/selectors';
|
||||||
|
import { isFeatureEnabled } from 'flavours/glitch/utils/environment';
|
||||||
|
|
||||||
import { setStatusQuotePolicy } from '../actions/statuses_typed';
|
import { setStatusQuotePolicy } from '../actions/statuses_typed';
|
||||||
|
|
||||||
@@ -84,7 +85,9 @@ const mapDispatchToProps = (dispatch, { contextType }) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
onQuote (status) {
|
onQuote (status) {
|
||||||
|
if (isFeatureEnabled('outgoing_quotes')) {
|
||||||
dispatch(quoteComposeById(status.get('id')));
|
dispatch(quoteComposeById(status.get('id')));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
onReblog (status, e) {
|
onReblog (status, e) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
|
|
||||||
import Rails from '@rails/ujs';
|
import Rails from '@rails/ujs';
|
||||||
import { decode, ValidationError } from 'blurhash';
|
|
||||||
|
|
||||||
import ready from 'flavours/glitch/ready';
|
import ready from 'flavours/glitch/ready';
|
||||||
|
|
||||||
@@ -363,46 +362,6 @@ ready(() => {
|
|||||||
document.querySelectorAll('[data-admin-component]').forEach((element) => {
|
document.querySelectorAll('[data-admin-component]').forEach((element) => {
|
||||||
void mountReactComponent(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) => {
|
}).catch((reason: unknown) => {
|
||||||
throw reason;
|
throw reason;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,9 +7,8 @@ import { Helmet } from 'react-helmet';
|
|||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
|
|
||||||
import { AccountBio } from '@/flavours/glitch/components/account_bio';
|
import { AccountBio } from '@/flavours/glitch/components/account_bio';
|
||||||
import { AccountFields } from '@/flavours/glitch/components/account_fields';
|
|
||||||
import { DisplayName } from '@/flavours/glitch/components/display_name';
|
import { DisplayName } from '@/flavours/glitch/components/display_name';
|
||||||
import { AnimateEmojiProvider } from '@/flavours/glitch/components/emoji/context';
|
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||||
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
|
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
|
||||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||||
import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react';
|
import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react';
|
||||||
@@ -38,6 +37,7 @@ import {
|
|||||||
AutomatedBadge,
|
AutomatedBadge,
|
||||||
GroupBadge,
|
GroupBadge,
|
||||||
} from 'flavours/glitch/components/badge';
|
} from 'flavours/glitch/components/badge';
|
||||||
|
import { Button } from 'flavours/glitch/components/button';
|
||||||
import { CopyIconButton } from 'flavours/glitch/components/copy_icon_button';
|
import { CopyIconButton } from 'flavours/glitch/components/copy_icon_button';
|
||||||
import { Dropdown } from 'flavours/glitch/components/dropdown_menu';
|
import { Dropdown } from 'flavours/glitch/components/dropdown_menu';
|
||||||
import { FollowButton } from 'flavours/glitch/components/follow_button';
|
import { FollowButton } from 'flavours/glitch/components/follow_button';
|
||||||
@@ -47,6 +47,7 @@ import { IconButton } from 'flavours/glitch/components/icon_button';
|
|||||||
import { AccountNote } from 'flavours/glitch/features/account/components/account_note';
|
import { AccountNote } from 'flavours/glitch/features/account/components/account_note';
|
||||||
import { DomainPill } from 'flavours/glitch/features/account/components/domain_pill';
|
import { DomainPill } from 'flavours/glitch/features/account/components/domain_pill';
|
||||||
import FollowRequestNoteContainer from 'flavours/glitch/features/account/containers/follow_request_note_container';
|
import FollowRequestNoteContainer from 'flavours/glitch/features/account/containers/follow_request_note_container';
|
||||||
|
import { useLinks } from 'flavours/glitch/hooks/useLinks';
|
||||||
import { useIdentity } from 'flavours/glitch/identity_context';
|
import { useIdentity } from 'flavours/glitch/identity_context';
|
||||||
import {
|
import {
|
||||||
autoPlayGif,
|
autoPlayGif,
|
||||||
@@ -189,6 +190,14 @@ const titleFromAccount = (account: Account) => {
|
|||||||
return `${prefix} (@${acct})`;
|
return `${prefix} (@${acct})`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const dateFormatOptions: Intl.DateTimeFormatOptions = {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
};
|
||||||
|
|
||||||
export const AccountHeader: React.FC<{
|
export const AccountHeader: React.FC<{
|
||||||
accountId: string;
|
accountId: string;
|
||||||
hideTabs?: boolean;
|
hideTabs?: boolean;
|
||||||
@@ -201,6 +210,7 @@ export const AccountHeader: React.FC<{
|
|||||||
state.relationships.get(accountId),
|
state.relationships.get(accountId),
|
||||||
);
|
);
|
||||||
const hidden = useAppSelector((state) => getAccountHidden(state, accountId));
|
const hidden = useAppSelector((state) => getAccountHidden(state, accountId));
|
||||||
|
const handleLinkClick = useLinks();
|
||||||
|
|
||||||
const handleBlock = useCallback(() => {
|
const handleBlock = useCallback(() => {
|
||||||
if (!account) {
|
if (!account) {
|
||||||
@@ -373,11 +383,41 @@ export const AccountHeader: React.FC<{
|
|||||||
});
|
});
|
||||||
}, [account]);
|
}, [account]);
|
||||||
|
|
||||||
|
const handleMouseEnter = useCallback(
|
||||||
|
({ currentTarget }: React.MouseEvent) => {
|
||||||
|
if (autoPlayGif) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTarget
|
||||||
|
.querySelectorAll<HTMLImageElement>('.custom-emoji')
|
||||||
|
.forEach((emoji) => {
|
||||||
|
emoji.src = emoji.getAttribute('data-original') ?? '';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMouseLeave = useCallback(
|
||||||
|
({ currentTarget }: React.MouseEvent) => {
|
||||||
|
if (autoPlayGif) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTarget
|
||||||
|
.querySelectorAll<HTMLImageElement>('.custom-emoji')
|
||||||
|
.forEach((emoji) => {
|
||||||
|
emoji.src = emoji.getAttribute('data-static') ?? '';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const suspended = account?.suspended;
|
const suspended = account?.suspended;
|
||||||
const isRemote = account?.acct !== account?.username;
|
const isRemote = account?.acct !== account?.username;
|
||||||
const remoteDomain = isRemote ? account?.acct.split('@')[1] : null;
|
const remoteDomain = isRemote ? account?.acct.split('@')[1] : null;
|
||||||
|
|
||||||
const menuItems = useMemo(() => {
|
const menu = useMemo(() => {
|
||||||
const arr: MenuItem[] = [];
|
const arr: MenuItem[] = [];
|
||||||
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
@@ -599,15 +639,6 @@ export const AccountHeader: React.FC<{
|
|||||||
handleUnblockDomain,
|
handleUnblockDomain,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const menu = accountId !== me && (
|
|
||||||
<Dropdown
|
|
||||||
disabled={menuItems.length === 0}
|
|
||||||
items={menuItems}
|
|
||||||
icon='ellipsis-v'
|
|
||||||
iconComponent={MoreHorizIcon}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -721,16 +752,21 @@ export const AccountHeader: React.FC<{
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isMovedAndUnfollowedAccount = account.moved && !relationship?.following;
|
if (relationship?.blocking) {
|
||||||
|
|
||||||
if (!isMovedAndUnfollowedAccount) {
|
|
||||||
actionBtn = (
|
actionBtn = (
|
||||||
<FollowButton
|
<Button
|
||||||
accountId={accountId}
|
text={intl.formatMessage(messages.unblock, {
|
||||||
className='account__header__follow-button'
|
name: account.username,
|
||||||
labelLength='long'
|
})}
|
||||||
|
onClick={handleBlock}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
actionBtn = <FollowButton accountId={accountId} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (account.moved && !relationship?.following) {
|
||||||
|
actionBtn = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (account.locked) {
|
if (account.locked) {
|
||||||
@@ -775,10 +811,12 @@ export const AccountHeader: React.FC<{
|
|||||||
<MovedNote accountId={account.id} targetAccountId={account.moved} />
|
<MovedNote accountId={account.id} targetAccountId={account.moved} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<AnimateEmojiProvider
|
<div
|
||||||
className={classNames('account__header', {
|
className={classNames('account__header', {
|
||||||
inactive: !!account.moved,
|
inactive: !!account.moved,
|
||||||
})}
|
})}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
>
|
>
|
||||||
{!(suspended || hidden || account.moved) &&
|
{!(suspended || hidden || account.moved) &&
|
||||||
relationship?.requested_by && (
|
relationship?.requested_by && (
|
||||||
@@ -812,11 +850,18 @@ export const AccountHeader: React.FC<{
|
|||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div className='account__header__buttons account__header__buttons--desktop'>
|
<div className='account__header__tabs__buttons'>
|
||||||
{!hidden && actionBtn}
|
|
||||||
{!hidden && bellBtn}
|
{!hidden && bellBtn}
|
||||||
{!hidden && shareBtn}
|
{!hidden && shareBtn}
|
||||||
{menu}
|
{accountId !== me && (
|
||||||
|
<Dropdown
|
||||||
|
disabled={menu.length === 0}
|
||||||
|
items={menu}
|
||||||
|
icon='ellipsis-v'
|
||||||
|
iconComponent={MoreHorizIcon}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!hidden && actionBtn}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -846,15 +891,12 @@ export const AccountHeader: React.FC<{
|
|||||||
<FamiliarFollowers accountId={accountId} />
|
<FamiliarFollowers accountId={accountId} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className='account__header__buttons account__header__buttons--mobile'>
|
|
||||||
{!hidden && actionBtn}
|
|
||||||
{!hidden && bellBtn}
|
|
||||||
{menu}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!(suspended || hidden) && (
|
{!(suspended || hidden) && (
|
||||||
<div className='account__header__extra'>
|
<div className='account__header__extra'>
|
||||||
<div className='account__header__bio'>
|
<div
|
||||||
|
className='account__header__bio'
|
||||||
|
onClickCapture={handleLinkClick}
|
||||||
|
>
|
||||||
{account.id !== me && signedIn && (
|
{account.id !== me && signedIn && (
|
||||||
<AccountNote accountId={accountId} />
|
<AccountNote accountId={accountId} />
|
||||||
)}
|
)}
|
||||||
@@ -882,13 +924,52 @@ export const AccountHeader: React.FC<{
|
|||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<AccountFields fields={fields} emojis={account.emojis} />
|
{fields.map((pair, i) => (
|
||||||
|
<dl
|
||||||
|
key={i}
|
||||||
|
className={classNames({
|
||||||
|
verified: pair.verified_at,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<dt
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: pair.name_emojified,
|
||||||
|
}}
|
||||||
|
title={pair.name}
|
||||||
|
className='translate'
|
||||||
|
/>
|
||||||
|
|
||||||
|
<dd className='translate' title={pair.value_plain ?? ''}>
|
||||||
|
{pair.verified_at && (
|
||||||
|
<span
|
||||||
|
title={intl.formatMessage(messages.linkVerifiedOn, {
|
||||||
|
date: intl.formatDate(
|
||||||
|
pair.verified_at,
|
||||||
|
dateFormatOptions,
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
id='check'
|
||||||
|
icon={CheckIcon}
|
||||||
|
className='verified__mark'
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}{' '}
|
||||||
|
<span
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: pair.value_emojified,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</AnimateEmojiProvider>
|
</div>
|
||||||
|
|
||||||
<ActionBar account={account} />
|
<ActionBar account={account} />
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ import { connect } from 'react-redux';
|
|||||||
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
|
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
|
||||||
import { DismissableBanner } from 'flavours/glitch/components/dismissable_banner';
|
import { DismissableBanner } from 'flavours/glitch/components/dismissable_banner';
|
||||||
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
|
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
|
||||||
import { domain, localLiveFeedAccess } from 'flavours/glitch/initial_state';
|
import { domain } from 'flavours/glitch/initial_state';
|
||||||
import { canViewFeed } from 'flavours/glitch/permissions';
|
|
||||||
|
|
||||||
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
|
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
|
||||||
import { connectCommunityStream } from '../../actions/streaming';
|
import { connectCommunityStream } from '../../actions/streaming';
|
||||||
@@ -124,21 +123,8 @@ class CommunityTimeline extends PureComponent {
|
|||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { intl, hasUnread, columnId, multiColumn, onlyMedia } = this.props;
|
const { intl, hasUnread, columnId, multiColumn, onlyMedia } = this.props;
|
||||||
const { signedIn, permissions } = this.props.identity;
|
|
||||||
const pinned = !!columnId;
|
const pinned = !!columnId;
|
||||||
|
|
||||||
const emptyMessage = canViewFeed(signedIn, permissions, localLiveFeedAccess) ? (
|
|
||||||
<FormattedMessage
|
|
||||||
id='empty_column.community'
|
|
||||||
defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!'
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<FormattedMessage
|
|
||||||
id='empty_column.disabled_feed'
|
|
||||||
defaultMessage='This feed has been disabled by your server administrators.'
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
|
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
|
||||||
<ColumnHeader
|
<ColumnHeader
|
||||||
@@ -161,7 +147,7 @@ class CommunityTimeline extends PureComponent {
|
|||||||
scrollKey={`community_timeline-${columnId}`}
|
scrollKey={`community_timeline-${columnId}`}
|
||||||
timelineId={`community${onlyMedia ? ':media' : ''}`}
|
timelineId={`community${onlyMedia ? ':media' : ''}`}
|
||||||
onLoadMore={this.handleLoadMore}
|
onLoadMore={this.handleLoadMore}
|
||||||
emptyMessage={emptyMessage}
|
emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />}
|
||||||
bindToDocument={!multiColumn}
|
bindToDocument={!multiColumn}
|
||||||
regex={this.props.regex}
|
regex={this.props.regex}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -49,7 +49,9 @@ export const EditIndicator = () => {
|
|||||||
|
|
||||||
<EmbeddedStatusContent
|
<EmbeddedStatusContent
|
||||||
className='edit-indicator__content translate'
|
className='edit-indicator__content translate'
|
||||||
status={status}
|
content={status.get('contentHtml')}
|
||||||
|
language={status.get('language')}
|
||||||
|
mentions={status.get('mentions')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{(status.get('poll') || status.get('media_attachments').size > 0) && (
|
{(status.get('poll') || status.get('media_attachments').size > 0) && (
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import Overlay from 'react-overlays/Overlay';
|
|||||||
|
|
||||||
import MoodIcon from '@/material-icons/400-20px/mood.svg?react';
|
import MoodIcon from '@/material-icons/400-20px/mood.svg?react';
|
||||||
import { IconButton } from 'flavours/glitch/components/icon_button';
|
import { IconButton } from 'flavours/glitch/components/icon_button';
|
||||||
|
import { useSystemEmojiFont } from 'flavours/glitch/initial_state';
|
||||||
|
|
||||||
import { buildCustomEmojis, categoriesFromEmojis } from '../../emoji/emoji';
|
import { buildCustomEmojis, categoriesFromEmojis } from '../../emoji/emoji';
|
||||||
import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components';
|
import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components';
|
||||||
@@ -98,12 +99,12 @@ class ModifierPickerMenu extends PureComponent {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='emoji-picker-dropdown__modifiers__menu' style={{ display: active ? 'block' : 'none' }} ref={this.setRef}>
|
<div className='emoji-picker-dropdown__modifiers__menu' style={{ display: active ? 'block' : 'none' }} ref={this.setRef}>
|
||||||
<button type='button' onClick={this.handleClick} data-index={1}><Emoji emoji='fist' size={22} skin={1} /></button>
|
<button type='button' onClick={this.handleClick} data-index={1}><Emoji emoji='fist' size={22} skin={1} native={useSystemEmojiFont} /></button>
|
||||||
<button type='button' onClick={this.handleClick} data-index={2}><Emoji emoji='fist' size={22} skin={2} /></button>
|
<button type='button' onClick={this.handleClick} data-index={2}><Emoji emoji='fist' size={22} skin={2} native={useSystemEmojiFont} /></button>
|
||||||
<button type='button' onClick={this.handleClick} data-index={3}><Emoji emoji='fist' size={22} skin={3} /></button>
|
<button type='button' onClick={this.handleClick} data-index={3}><Emoji emoji='fist' size={22} skin={3} native={useSystemEmojiFont} /></button>
|
||||||
<button type='button' onClick={this.handleClick} data-index={4}><Emoji emoji='fist' size={22} skin={4} /></button>
|
<button type='button' onClick={this.handleClick} data-index={4}><Emoji emoji='fist' size={22} skin={4} native={useSystemEmojiFont} /></button>
|
||||||
<button type='button' onClick={this.handleClick} data-index={5}><Emoji emoji='fist' size={22} skin={5} /></button>
|
<button type='button' onClick={this.handleClick} data-index={5}><Emoji emoji='fist' size={22} skin={5} native={useSystemEmojiFont} /></button>
|
||||||
<button type='button' onClick={this.handleClick} data-index={6}><Emoji emoji='fist' size={22} skin={6} /></button>
|
<button type='button' onClick={this.handleClick} data-index={6}><Emoji emoji='fist' size={22} skin={6} native={useSystemEmojiFont} /></button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -138,7 +139,7 @@ class ModifierPicker extends PureComponent {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='emoji-picker-dropdown__modifiers'>
|
<div className='emoji-picker-dropdown__modifiers'>
|
||||||
<Emoji emoji='fist' size={22} skin={modifier} onClick={this.handleClick} />
|
<Emoji emoji='fist' size={22} skin={modifier} onClick={this.handleClick} native={useSystemEmojiFont} />
|
||||||
<ModifierPickerMenu active={active} onSelect={this.handleSelect} onClose={this.props.onClose} />
|
<ModifierPickerMenu active={active} onSelect={this.handleSelect} onClose={this.props.onClose} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -290,6 +291,7 @@ class EmojiPickerMenuImpl extends PureComponent {
|
|||||||
notFound={notFoundFn}
|
notFound={notFoundFn}
|
||||||
autoFocus={this.state.readyToFocus}
|
autoFocus={this.state.readyToFocus}
|
||||||
emojiTooltip
|
emojiTooltip
|
||||||
|
native={useSystemEmojiFont}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ModifierPicker
|
<ModifierPicker
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ export const ReplyIndicator = () => {
|
|||||||
|
|
||||||
<EmbeddedStatusContent
|
<EmbeddedStatusContent
|
||||||
className='reply-indicator__content translate'
|
className='reply-indicator__content translate'
|
||||||
status={status}
|
content={status.get('contentHtml')}
|
||||||
|
language={status.get('language')}
|
||||||
|
mentions={status.get('mentions')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{(status.get('poll') || status.get('media_attachments').size > 0) && (
|
{(status.get('poll') || status.get('media_attachments').size > 0) && (
|
||||||
|
|||||||
@@ -12,12 +12,14 @@ import type { ApiQuotePolicy } from '@/flavours/glitch/api_types/quotes';
|
|||||||
import type { StatusVisibility } from '@/flavours/glitch/api_types/statuses';
|
import type { StatusVisibility } from '@/flavours/glitch/api_types/statuses';
|
||||||
import { Icon } from '@/flavours/glitch/components/icon';
|
import { Icon } from '@/flavours/glitch/components/icon';
|
||||||
import { useAppSelector, useAppDispatch } from '@/flavours/glitch/store';
|
import { useAppSelector, useAppDispatch } from '@/flavours/glitch/store';
|
||||||
|
import { isFeatureEnabled } from '@/flavours/glitch/utils/environment';
|
||||||
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
||||||
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
|
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
|
||||||
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
|
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
|
||||||
import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react';
|
import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react';
|
||||||
|
|
||||||
import type { VisibilityModalCallback } from '../../ui/components/visibility_modal';
|
import type { VisibilityModalCallback } from '../../ui/components/visibility_modal';
|
||||||
|
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
|
||||||
|
|
||||||
import { messages as privacyMessages } from './privacy_dropdown';
|
import { messages as privacyMessages } from './privacy_dropdown';
|
||||||
|
|
||||||
@@ -41,6 +43,9 @@ interface PrivacyDropdownProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const VisibilityButton: FC<PrivacyDropdownProps> = (props) => {
|
export const VisibilityButton: FC<PrivacyDropdownProps> = (props) => {
|
||||||
|
if (!isFeatureEnabled('outgoing_quotes')) {
|
||||||
|
return <PrivacyDropdownContainer {...props} />;
|
||||||
|
}
|
||||||
return <PrivacyModalButton {...props} />;
|
return <PrivacyModalButton {...props} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,14 +10,11 @@ import {
|
|||||||
insertEmojiCompose,
|
insertEmojiCompose,
|
||||||
uploadCompose,
|
uploadCompose,
|
||||||
} from 'flavours/glitch/actions/compose';
|
} from 'flavours/glitch/actions/compose';
|
||||||
import { pasteLinkCompose } from 'flavours/glitch/actions/compose_typed';
|
|
||||||
import { openModal } from 'flavours/glitch/actions/modal';
|
import { openModal } from 'flavours/glitch/actions/modal';
|
||||||
import { privacyPreference } from 'flavours/glitch/utils/privacy_preference';
|
import { privacyPreference } from 'flavours/glitch/utils/privacy_preference';
|
||||||
|
|
||||||
import ComposeForm from '../components/compose_form';
|
import ComposeForm from '../components/compose_form';
|
||||||
|
|
||||||
const urlLikeRegex = /^https?:\/\/[^\s]+\/[^\s]+$/i;
|
|
||||||
|
|
||||||
const sideArmPrivacy = state => {
|
const sideArmPrivacy = state => {
|
||||||
const inReplyTo = state.getIn(['compose', 'in_reply_to']);
|
const inReplyTo = state.getIn(['compose', 'in_reply_to']);
|
||||||
const replyPrivacy = inReplyTo ? state.getIn(['statuses', inReplyTo, 'visibility']) : null;
|
const replyPrivacy = inReplyTo ? state.getIn(['statuses', inReplyTo, 'visibility']) : null;
|
||||||
@@ -96,21 +93,8 @@ const mapDispatchToProps = (dispatch, props) => ({
|
|||||||
dispatch(changeComposeSpoilerText(checked));
|
dispatch(changeComposeSpoilerText(checked));
|
||||||
},
|
},
|
||||||
|
|
||||||
onPaste (e) {
|
onPaste (files) {
|
||||||
if (e.clipboardData && e.clipboardData.files.length === 1) {
|
dispatch(uploadCompose(files));
|
||||||
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) {
|
onPickEmoji (position, data, needsSpace) {
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ import { IconButton } from 'flavours/glitch/components/icon_button';
|
|||||||
import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp';
|
import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp';
|
||||||
import StatusContent from 'flavours/glitch/components/status_content';
|
import StatusContent from 'flavours/glitch/components/status_content';
|
||||||
import { Dropdown } from 'flavours/glitch/components/dropdown_menu';
|
import { Dropdown } from 'flavours/glitch/components/dropdown_menu';
|
||||||
|
import { autoPlayGif } from 'flavours/glitch/initial_state';
|
||||||
import { makeGetStatus } from 'flavours/glitch/selectors';
|
import { makeGetStatus } from 'flavours/glitch/selectors';
|
||||||
import { LinkedDisplayName } from '@/flavours/glitch/components/display_name';
|
import { LinkedDisplayName } from '@/flavours/glitch/components/display_name';
|
||||||
import { AnimateEmojiProvider } from '@/flavours/glitch/components/emoji/context';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
more: { id: 'status.more', defaultMessage: 'More' },
|
more: { id: 'status.more', defaultMessage: 'More' },
|
||||||
@@ -61,6 +61,32 @@ export const Conversation = ({ conversation, scrollKey }) => {
|
|||||||
const sharedCWState = useSelector(state => state.getIn(['state', 'content_warnings', 'shared_state']));
|
const sharedCWState = useSelector(state => state.getIn(['state', 'content_warnings', 'shared_state']));
|
||||||
const [expanded, setExpanded] = useState(undefined);
|
const [expanded, setExpanded] = useState(undefined);
|
||||||
|
|
||||||
|
const handleMouseEnter = useCallback(({ currentTarget }) => {
|
||||||
|
if (autoPlayGif) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emojis = currentTarget.querySelectorAll('.custom-emoji');
|
||||||
|
|
||||||
|
for (var i = 0; i < emojis.length; i++) {
|
||||||
|
let emoji = emojis[i];
|
||||||
|
emoji.src = emoji.getAttribute('data-original');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleMouseLeave = useCallback(({ currentTarget }) => {
|
||||||
|
if (autoPlayGif) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emojis = currentTarget.querySelectorAll('.custom-emoji');
|
||||||
|
|
||||||
|
for (var i = 0; i < emojis.length; i++) {
|
||||||
|
let emoji = emojis[i];
|
||||||
|
emoji.src = emoji.getAttribute('data-static');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
const handleClick = useCallback(() => {
|
||||||
if (unread) {
|
if (unread) {
|
||||||
dispatch(markConversationRead(id));
|
dispatch(markConversationRead(id));
|
||||||
@@ -145,9 +171,9 @@ export const Conversation = ({ conversation, scrollKey }) => {
|
|||||||
{unread && <span className='conversation__unread' />} <RelativeTimestamp timestamp={lastStatus.get('created_at')} />
|
{unread && <span className='conversation__unread' />} <RelativeTimestamp timestamp={lastStatus.get('created_at')} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AnimateEmojiProvider className='conversation__content__names'>
|
<div className='conversation__content__names' onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
|
||||||
<FormattedMessage id='conversation.with' defaultMessage='With {names}' values={{ names: <span>{names}</span> }} />
|
<FormattedMessage id='conversation.with' defaultMessage='With {names}' values={{ names: <span>{names}</span> }} />
|
||||||
</AnimateEmojiProvider>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<StatusContent
|
<StatusContent
|
||||||
|
|||||||
@@ -1,23 +1,168 @@
|
|||||||
import { FormattedMessage } from 'react-intl';
|
import type { MouseEventHandler } from 'react';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
import { EmojiHTML } from '@/flavours/glitch/components/emoji/html';
|
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import {
|
||||||
|
followAccount,
|
||||||
|
unblockAccount,
|
||||||
|
unmuteAccount,
|
||||||
|
} from 'flavours/glitch/actions/accounts';
|
||||||
|
import { openModal } from 'flavours/glitch/actions/modal';
|
||||||
import { Avatar } from 'flavours/glitch/components/avatar';
|
import { Avatar } from 'flavours/glitch/components/avatar';
|
||||||
|
import { Button } from 'flavours/glitch/components/button';
|
||||||
import { DisplayName } from 'flavours/glitch/components/display_name';
|
import { DisplayName } from 'flavours/glitch/components/display_name';
|
||||||
import { FollowButton } from 'flavours/glitch/components/follow_button';
|
|
||||||
import { Permalink } from 'flavours/glitch/components/permalink';
|
import { Permalink } from 'flavours/glitch/components/permalink';
|
||||||
import { ShortNumber } from 'flavours/glitch/components/short_number';
|
import { ShortNumber } from 'flavours/glitch/components/short_number';
|
||||||
import { autoPlayGif } from 'flavours/glitch/initial_state';
|
import { autoPlayGif, me } from 'flavours/glitch/initial_state';
|
||||||
import type { Account } from 'flavours/glitch/models/account';
|
import type { Account } from 'flavours/glitch/models/account';
|
||||||
import { makeGetAccount } from 'flavours/glitch/selectors';
|
import { makeGetAccount } from 'flavours/glitch/selectors';
|
||||||
import { useAppSelector } from 'flavours/glitch/store';
|
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
||||||
|
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
||||||
|
cancel_follow_request: {
|
||||||
|
id: 'account.cancel_follow_request',
|
||||||
|
defaultMessage: 'Withdraw follow request',
|
||||||
|
},
|
||||||
|
requested: {
|
||||||
|
id: 'account.requested',
|
||||||
|
defaultMessage: 'Awaiting approval. Click to cancel follow request',
|
||||||
|
},
|
||||||
|
unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' },
|
||||||
|
unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' },
|
||||||
|
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
|
||||||
|
});
|
||||||
|
|
||||||
const getAccount = makeGetAccount();
|
const getAccount = makeGetAccount();
|
||||||
|
|
||||||
export const AccountCard: React.FC<{ accountId: string }> = ({ accountId }) => {
|
export const AccountCard: React.FC<{ accountId: string }> = ({ accountId }) => {
|
||||||
|
const intl = useIntl();
|
||||||
const account = useAppSelector((s) => getAccount(s, accountId));
|
const account = useAppSelector((s) => getAccount(s, accountId));
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const handleMouseEnter = useCallback<MouseEventHandler>(
|
||||||
|
({ currentTarget }) => {
|
||||||
|
if (autoPlayGif) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const emojis =
|
||||||
|
currentTarget.querySelectorAll<HTMLImageElement>('.custom-emoji');
|
||||||
|
|
||||||
|
emojis.forEach((emoji) => {
|
||||||
|
const original = emoji.getAttribute('data-original');
|
||||||
|
if (original) emoji.src = original;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMouseLeave = useCallback<MouseEventHandler>(
|
||||||
|
({ currentTarget }) => {
|
||||||
|
if (autoPlayGif) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emojis =
|
||||||
|
currentTarget.querySelectorAll<HTMLImageElement>('.custom-emoji');
|
||||||
|
|
||||||
|
emojis.forEach((emoji) => {
|
||||||
|
const staticUrl = emoji.getAttribute('data-static');
|
||||||
|
if (staticUrl) emoji.src = staticUrl;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFollow = useCallback(() => {
|
||||||
|
if (!account) return;
|
||||||
|
|
||||||
|
if (
|
||||||
|
account.getIn(['relationship', 'following']) ||
|
||||||
|
account.getIn(['relationship', 'requested'])
|
||||||
|
) {
|
||||||
|
dispatch(
|
||||||
|
openModal({ modalType: 'CONFIRM_UNFOLLOW', modalProps: { account } }),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
dispatch(followAccount(account.get('id')));
|
||||||
|
}
|
||||||
|
}, [account, dispatch]);
|
||||||
|
|
||||||
|
const handleBlock = useCallback(() => {
|
||||||
|
if (account?.relationship?.blocking) {
|
||||||
|
dispatch(unblockAccount(account.get('id')));
|
||||||
|
}
|
||||||
|
}, [account, dispatch]);
|
||||||
|
|
||||||
|
const handleMute = useCallback(() => {
|
||||||
|
if (account?.relationship?.muting) {
|
||||||
|
dispatch(unmuteAccount(account.get('id')));
|
||||||
|
}
|
||||||
|
}, [account, dispatch]);
|
||||||
|
|
||||||
|
const handleEditProfile = useCallback(() => {
|
||||||
|
window.open('/settings/profile', '_blank');
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (!account) return null;
|
if (!account) return null;
|
||||||
|
|
||||||
|
let actionBtn;
|
||||||
|
|
||||||
|
if (me !== account.get('id')) {
|
||||||
|
if (!account.get('relationship')) {
|
||||||
|
// Wait until the relationship is loaded
|
||||||
|
actionBtn = '';
|
||||||
|
} else if (account.getIn(['relationship', 'requested'])) {
|
||||||
|
actionBtn = (
|
||||||
|
<Button
|
||||||
|
text={intl.formatMessage(messages.cancel_follow_request)}
|
||||||
|
title={intl.formatMessage(messages.requested)}
|
||||||
|
onClick={handleFollow}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (account.getIn(['relationship', 'muting'])) {
|
||||||
|
actionBtn = (
|
||||||
|
<Button
|
||||||
|
text={intl.formatMessage(messages.unmute)}
|
||||||
|
onClick={handleMute}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (!account.getIn(['relationship', 'blocking'])) {
|
||||||
|
actionBtn = (
|
||||||
|
<Button
|
||||||
|
disabled={account.relationship?.blocked_by}
|
||||||
|
className={classNames({
|
||||||
|
'button--destructive': account.getIn(['relationship', 'following']),
|
||||||
|
})}
|
||||||
|
text={intl.formatMessage(
|
||||||
|
account.getIn(['relationship', 'following'])
|
||||||
|
? messages.unfollow
|
||||||
|
: messages.follow,
|
||||||
|
)}
|
||||||
|
onClick={handleFollow}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (account.getIn(['relationship', 'blocking'])) {
|
||||||
|
actionBtn = (
|
||||||
|
<Button
|
||||||
|
text={intl.formatMessage(messages.unblock)}
|
||||||
|
onClick={handleBlock}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
actionBtn = (
|
||||||
|
<Button
|
||||||
|
text={intl.formatMessage(messages.edit_profile)}
|
||||||
|
onClick={handleEditProfile}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='account-card'>
|
<div className='account-card'>
|
||||||
<Permalink
|
<Permalink
|
||||||
@@ -43,10 +188,11 @@ export const AccountCard: React.FC<{ accountId: string }> = ({ accountId }) => {
|
|||||||
</Permalink>
|
</Permalink>
|
||||||
|
|
||||||
{account.get('note').length > 0 && (
|
{account.get('note').length > 0 && (
|
||||||
<EmojiHTML
|
<div
|
||||||
className='account-card__bio translate'
|
className='account-card__bio translate'
|
||||||
htmlString={account.get('note_emojified')}
|
onMouseEnter={handleMouseEnter}
|
||||||
extraEmojis={account.get('emojis')}
|
onMouseLeave={handleMouseLeave}
|
||||||
|
dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -80,9 +226,7 @@ export const AccountCard: React.FC<{ accountId: string }> = ({ accountId }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='account-card__actions__button'>
|
<div className='account-card__actions__button'>{actionBtn}</div>
|
||||||
<FollowButton accountId={account.get('id')} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { ColumnHeader } from 'flavours/glitch/components/column_header';
|
|||||||
import { LoadMore } from 'flavours/glitch/components/load_more';
|
import { LoadMore } from 'flavours/glitch/components/load_more';
|
||||||
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
||||||
import { RadioButton } from 'flavours/glitch/components/radio_button';
|
import { RadioButton } from 'flavours/glitch/components/radio_button';
|
||||||
import { ScrollContainer } from 'flavours/glitch/containers/scroll_container';
|
import ScrollContainer from 'flavours/glitch/containers/scroll_container';
|
||||||
import { useSearchParam } from 'flavours/glitch/hooks/useSearchParam';
|
import { useSearchParam } from 'flavours/glitch/hooks/useSearchParam';
|
||||||
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
||||||
|
|
||||||
@@ -209,6 +209,7 @@ export const Directory: React.FC<{
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{multiColumn && !pinned ? (
|
{multiColumn && !pinned ? (
|
||||||
|
// @ts-expect-error ScrollContainer is not properly typed yet
|
||||||
<ScrollContainer scrollKey='directory'>
|
<ScrollContainer scrollKey='directory'>
|
||||||
{scrollableArea}
|
{scrollableArea}
|
||||||
</ScrollContainer>
|
</ScrollContainer>
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export const EMOJI_MODE_TWEMOJI = 'twemoji';
|
|||||||
export const EMOJI_TYPE_UNICODE = 'unicode';
|
export const EMOJI_TYPE_UNICODE = 'unicode';
|
||||||
export const EMOJI_TYPE_CUSTOM = 'custom';
|
export const EMOJI_TYPE_CUSTOM = 'custom';
|
||||||
|
|
||||||
|
export const EMOJI_STATE_MISSING = 'missing';
|
||||||
|
|
||||||
export const EMOJIS_WITH_DARK_BORDER = [
|
export const EMOJIS_WITH_DARK_BORDER = [
|
||||||
'🎱', // 1F3B1
|
'🎱', // 1F3B1
|
||||||
'🐜', // 1F41C
|
'🐜', // 1F41C
|
||||||
|
|||||||
@@ -197,18 +197,11 @@ function toLoadedLocale(localeString: string) {
|
|||||||
log(`Locale ${locale} is different from provided ${localeString}`);
|
log(`Locale ${locale} is different from provided ${localeString}`);
|
||||||
}
|
}
|
||||||
if (!loadedLocales.has(locale)) {
|
if (!loadedLocales.has(locale)) {
|
||||||
throw new LocaleNotLoadedError(locale);
|
throw new Error(`Locale ${locale} is not loaded in emoji database`);
|
||||||
}
|
}
|
||||||
return locale;
|
return locale;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class LocaleNotLoadedError extends Error {
|
|
||||||
constructor(locale: Locale) {
|
|
||||||
super(`Locale ${locale} is not loaded in emoji database`);
|
|
||||||
this.name = 'LocaleNotLoadedError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function hasLocale(locale: Locale, db: Database): Promise<boolean> {
|
async function hasLocale(locale: Locale, db: Database): Promise<boolean> {
|
||||||
if (loadedLocales.has(locale)) {
|
if (loadedLocales.has(locale)) {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Trie from 'substring-trie';
|
|||||||
|
|
||||||
import { assetHost } from 'flavours/glitch/utils/config';
|
import { assetHost } from 'flavours/glitch/utils/config';
|
||||||
|
|
||||||
import { autoPlayGif } from '../../initial_state';
|
import { autoPlayGif, useSystemEmojiFont } from '../../initial_state';
|
||||||
|
|
||||||
import { unicodeMapping } from './emoji_unicode_mapping_light';
|
import { unicodeMapping } from './emoji_unicode_mapping_light';
|
||||||
|
|
||||||
@@ -39,13 +39,13 @@ const emojifyTextNode = (node, customEmojis) => {
|
|||||||
for (;;) {
|
for (;;) {
|
||||||
let unicode_emoji;
|
let unicode_emoji;
|
||||||
|
|
||||||
// Skip to the next potential emoji to replace (either custom emoji or custom emoji :shortcode:
|
// Skip to the next potential emoji to replace (either custom emoji or custom emoji :shortcode:)
|
||||||
if (customEmojis === null) {
|
if (customEmojis === null) {
|
||||||
while (i < str.length && !(unicode_emoji = trie.search(str.slice(i)))) {
|
while (i < str.length && (useSystemEmojiFont || !(unicode_emoji = trie.search(str.slice(i))))) {
|
||||||
i += str.codePointAt(i) < 65536 ? 1 : 2;
|
i += str.codePointAt(i) < 65536 ? 1 : 2;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
while (i < str.length && str[i] !== ':' && !(unicode_emoji = trie.search(str.slice(i)))) {
|
while (i < str.length && str[i] !== ':' && (useSystemEmojiFont || !(unicode_emoji = trie.search(str.slice(i))))) {
|
||||||
i += str.codePointAt(i) < 65536 ? 1 : 2;
|
i += str.codePointAt(i) < 65536 ? 1 : 2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,12 +148,6 @@ const emojifyNode = (node, customEmojis) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Legacy emoji processing function.
|
|
||||||
* @param {string} str
|
|
||||||
* @param {object} customEmojis
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
const emojify = (str, customEmojis = {}) => {
|
const emojify = (str, customEmojis = {}) => {
|
||||||
const wrapper = document.createElement('div');
|
const wrapper = document.createElement('div');
|
||||||
wrapper.innerHTML = str;
|
wrapper.innerHTML = str;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user