mirror of
https://github.com/glitch-soc/mastodon.git
synced 2026-03-29 03:00:33 +02:00
Compare commits
199 Commits
8b418b84d0
...
v4.5.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23f04c2623 | ||
|
|
ada1d32394 | ||
|
|
db943c43c8 | ||
|
|
1a74b74a40 | ||
|
|
9a25b12f0c | ||
|
|
6f9b32b137 | ||
|
|
1b3ef035b9 | ||
|
|
6698901d57 | ||
|
|
ba0609bbaf | ||
|
|
d545e55b86 | ||
|
|
25d572e9b9 | ||
|
|
3479b453e5 | ||
|
|
c96eebde37 | ||
|
|
723b2601b8 | ||
|
|
66c06a0655 | ||
|
|
ded7f50f2c | ||
|
|
85eda5b46f | ||
|
|
f1c9c89c39 | ||
|
|
57e0c6562f | ||
|
|
f7b6e57151 | ||
|
|
57f658dc5c | ||
|
|
0cda068918 | ||
|
|
deeaf50472 | ||
|
|
adea0b7b31 | ||
|
|
1eb8d1b967 | ||
|
|
f354bbe8aa | ||
|
|
53437c4653 | ||
|
|
617926742c | ||
|
|
5799d5d306 | ||
|
|
b5d868018d | ||
|
|
55a7b1ea58 | ||
|
|
c1fb6893c5 | ||
|
|
71ae4cf2cf | ||
|
|
2ffe03457d | ||
|
|
c1f5a9db23 | ||
|
|
7c0701d906 | ||
|
|
b134c6a8ef | ||
|
|
a846ed17ff | ||
|
|
3013039720 | ||
|
|
ad4ba5aa00 | ||
|
|
1c5461fffe | ||
|
|
725c1a159d | ||
|
|
b52efea5cb | ||
|
|
a0bdfc46c7 | ||
|
|
afcdc19730 | ||
|
|
80aa3bc8ad | ||
|
|
92955f7e6e | ||
|
|
b868e598bc | ||
|
|
3de59a9344 | ||
|
|
32c3376d84 | ||
|
|
962ae88caf | ||
|
|
7d9d3de972 | ||
|
|
546a95349e | ||
|
|
df1ab0ab90 | ||
|
|
8d1ea4c531 | ||
|
|
8233295e3b | ||
|
|
4eb0a506d3 | ||
|
|
75739a5a9b | ||
|
|
54e08a54e9 | ||
|
|
12ec21a95f | ||
|
|
fe9a71975c | ||
|
|
e1f145973b | ||
|
|
66f25c6709 | ||
|
|
e4e4ffb08d | ||
|
|
e016e2a31e | ||
|
|
6c1e892dd7 | ||
|
|
86cff1abca | ||
|
|
e6d2fc869b | ||
|
|
a9f8268a75 | ||
|
|
dfe269439a | ||
|
|
9bc9ebc59e | ||
|
|
a6d31c0ccf | ||
|
|
1e2cf6c964 | ||
|
|
c42c71c90a | ||
|
|
782e410719 | ||
|
|
b0c141e658 | ||
|
|
1ef4bbd88d | ||
|
|
240d38b7c0 | ||
|
|
770d1212bb | ||
|
|
86e463c0e8 | ||
|
|
a04a210e14 | ||
|
|
300d62f1c4 | ||
|
|
27d33d1233 | ||
|
|
b35c691ab2 | ||
|
|
19588756ef | ||
|
|
e398ff40b2 | ||
|
|
96eb687524 | ||
|
|
05c624cfa7 | ||
|
|
dba811952a | ||
|
|
8836c4fc84 | ||
|
|
1da82862f1 | ||
|
|
8c725777ed | ||
|
|
126823986a | ||
|
|
f04b06a44f | ||
|
|
c7481cb2ca | ||
|
|
7141917943 | ||
|
|
6c7a9b8311 | ||
|
|
8ae06fdbcd | ||
|
|
553bb4673e | ||
|
|
fe6d3690fd | ||
|
|
9746c5eb6b | ||
|
|
b5ae2f07a1 | ||
|
|
9b91852f93 | ||
|
|
0bb7711225 | ||
|
|
86445f45fc | ||
|
|
5fe316b2e9 | ||
|
|
1dbf10198d | ||
|
|
c6ccacdf7b | ||
|
|
6ccd9c2f1f | ||
|
|
261d9b33fe | ||
|
|
4ee21c2e29 | ||
|
|
c08cd6d62a | ||
|
|
44d45e5705 | ||
|
|
27c67f1750 | ||
|
|
bb28552859 | ||
|
|
6486c092f6 | ||
|
|
a7b45682a6 | ||
|
|
5a57c0844a | ||
|
|
1d081250f4 | ||
|
|
e6c8958d07 | ||
|
|
b2506cc110 | ||
|
|
d18491b7a7 | ||
|
|
13330030cd | ||
|
|
f9012a774c | ||
|
|
375af385e7 | ||
|
|
7e5224a3c0 | ||
|
|
140d782cba | ||
|
|
c7a7ce8ce7 | ||
|
|
afbe0a4860 | ||
|
|
febde69d0b | ||
|
|
85b9a5944d | ||
|
|
585827c14f | ||
|
|
bb6093c315 | ||
|
|
058f704c21 | ||
|
|
6baa8f2466 | ||
|
|
e742eff044 | ||
|
|
55b9d21537 | ||
|
|
59f0134578 | ||
|
|
28b9e9087a | ||
|
|
fa2cc409ce | ||
|
|
8a100d84c5 | ||
|
|
9ae0464e8f | ||
|
|
9eea4479e1 | ||
|
|
30103fd2c8 | ||
|
|
a9a7ad62f1 | ||
|
|
ea663cf7c7 | ||
|
|
fbe05d42fb | ||
|
|
29ae9c9c4b | ||
|
|
4684d5e69b | ||
|
|
3ca92c4ae2 | ||
|
|
81b1a34d96 | ||
|
|
26c78392f8 | ||
|
|
caecc88247 | ||
|
|
bed4ca26e2 | ||
|
|
5d108e95d7 | ||
|
|
8a2d38a47b | ||
|
|
048430f4e8 | ||
|
|
d45b4db1d7 | ||
|
|
ef3a95affc | ||
|
|
3e6a9371b0 | ||
|
|
7ab0cfd637 | ||
|
|
b33bcc8be6 | ||
|
|
cdad6ee0c9 | ||
|
|
2d958cb909 | ||
|
|
949f15e200 | ||
|
|
105a2d64a7 | ||
|
|
1c17990413 | ||
|
|
0a8f96d3be | ||
|
|
1ec8e42dbb | ||
|
|
7ea3c6a039 | ||
|
|
e91c764590 | ||
|
|
cfdd9396c0 | ||
|
|
ba498ae779 | ||
|
|
5bae08d1ff | ||
|
|
5253527ec4 | ||
|
|
0b50789c5b | ||
|
|
a978e37f4c | ||
|
|
dd708298a8 | ||
|
|
449eb03f11 | ||
|
|
1baede0a7c | ||
|
|
a7ecfc1ca5 | ||
|
|
e62baacfc1 | ||
|
|
b5a6feb3bf | ||
|
|
05964f571b | ||
|
|
16a54f7158 | ||
|
|
6d53ca63d6 | ||
|
|
93acfdd7d3 | ||
|
|
a209b8e544 | ||
|
|
ca0c5e7a79 | ||
|
|
10a81a2f43 | ||
|
|
9f8fce3c47 | ||
|
|
5f5e6ca031 | ||
|
|
af4c372ab2 | ||
|
|
aa579ce286 | ||
|
|
adfabf8c80 | ||
|
|
ea710df180 | ||
|
|
e1b6e28829 | ||
|
|
214d59bd37 | ||
|
|
e4291e9b05 |
42
.github/workflows/build-releases.yml
vendored
42
.github/workflows/build-releases.yml
vendored
@@ -9,7 +9,44 @@ permissions:
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
check-latest-stable:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
latest: ${{ steps.check.outputs.is_latest_stable }}
|
||||
steps:
|
||||
# Repository needs to be cloned to list branches
|
||||
- name: Clone repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check latest stable
|
||||
shell: bash
|
||||
id: check
|
||||
run: |
|
||||
ref="${GITHUB_REF#refs/tags/}"
|
||||
|
||||
if [[ "$ref" =~ ^v([0-9]+)\.([0-9]+)(\.[0-9]+)?$ ]]; then
|
||||
current="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
|
||||
else
|
||||
echo "tag $ref is not semver"
|
||||
echo "is_latest_stable=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
latest=$(git for-each-ref --format='%(refname:short)' "refs/remotes/origin/stable-*.*" \
|
||||
| sed -E 's#^origin/stable-##' \
|
||||
| sort -Vr \
|
||||
| head -n1)
|
||||
|
||||
if [[ "$current" == "$latest" ]]; then
|
||||
echo "is_latest_stable=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "is_latest_stable=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
build-image:
|
||||
needs: check-latest-stable
|
||||
uses: ./.github/workflows/build-container-image.yml
|
||||
with:
|
||||
file_to_build: Dockerfile
|
||||
@@ -20,13 +57,14 @@ jobs:
|
||||
# Only tag with latest when ran against the latest stable branch
|
||||
# This needs to be updated after each minor version release
|
||||
flavor: |
|
||||
latest=${{ startsWith(github.ref, 'refs/tags/v4.3.') }}
|
||||
latest=${{ needs.check-latest-stable.outputs.latest }}
|
||||
tags: |
|
||||
type=pep440,pattern={{raw}}
|
||||
type=pep440,pattern=v{{major}}.{{minor}}
|
||||
secrets: inherit
|
||||
|
||||
build-image-streaming:
|
||||
needs: check-latest-stable
|
||||
uses: ./.github/workflows/build-container-image.yml
|
||||
with:
|
||||
file_to_build: streaming/Dockerfile
|
||||
@@ -37,7 +75,7 @@ jobs:
|
||||
# Only tag with latest when ran against the latest stable branch
|
||||
# This needs to be updated after each minor version release
|
||||
flavor: |
|
||||
latest=${{ startsWith(github.ref, 'refs/tags/v4.3.') }}
|
||||
latest=${{ needs.check-latest-stable.outputs.latest }}
|
||||
tags: |
|
||||
type=pep440,pattern={{raw}}
|
||||
type=pep440,pattern=v{{major}}.{{minor}}
|
||||
|
||||
131
CHANGELOG.md
131
CHANGELOG.md
@@ -2,17 +2,126 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [4.5.0] - UNRELEASED
|
||||
## [4.5.5] - 2026-01-20
|
||||
|
||||
### Security
|
||||
|
||||
- Fix missing limits on various federated properties [GHSA-gg8q-rcg7-p79g](https://github.com/mastodon/mastodon/security/advisories/GHSA-gg8q-rcg7-p79g)
|
||||
- Fix remote user suspension bypass [GHSA-5h2f-wg8j-xqwp](https://github.com/mastodon/mastodon/security/advisories/GHSA-5h2f-wg8j-xqwp)
|
||||
- Fix missing length limits on some user-provided fields [GHSA-6x3w-9g92-gvf3](https://github.com/mastodon/mastodon/security/advisories/GHSA-6x3w-9g92-gvf3)
|
||||
- Fix missing access check for push notification settings update [GHSA-f3q8-7vw3-69v4](https://github.com/mastodon/mastodon/security/advisories/GHSA-f3q8-7vw3-69v4)
|
||||
|
||||
### Changed
|
||||
|
||||
- Skip tombstone creation on deleting from 404 (#37533 by @ClearlyClaire)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix potential duplicate handling of quote accept/reject/delete (#37537 by @ClearlyClaire)
|
||||
- Fix `FeedManager#filter_from_home` error when handling a reblog of a deleted status (#37486 by @ClearlyClaire)
|
||||
- Fix needlessly complicated SQL query in status batch removal (#37469 by @ClearlyClaire)
|
||||
- Fix `quote_approval_policy` being reset to user defaults when omitted in status update (#37436 and #37474 by @mjankowski and @shleeable)
|
||||
- Fix `Vary` parsing in cache control enforcement (#37426 by @MegaManSec)
|
||||
- Fix missing URI scheme test in `QuoteRequest` handling (#37425 by @MegaManSec)
|
||||
- Fix thread-unsafe ActivityPub activity dispatch (#37423 by @MegaManSec)
|
||||
- Fix URI generation for reblogs by accounts with numerical ActivityPub identifiers (#37415 by @oneiros)
|
||||
- Fix SignatureParser accepting duplicate parameters in HTTP Signature header (#37375 by @shleeable)
|
||||
- Fix emoji with variant selector not being rendered properly (#37320 by @ChaosExAnima)
|
||||
- Fix mobile admin sidebar displaying under batch table toolbar (#37307 by @diondiondion)
|
||||
|
||||
## [4.5.4] - 2026-01-07
|
||||
|
||||
### Security
|
||||
|
||||
- Fix SSRF protection bypass ([GHSA](https://github.com/mastodon/mastodon/security/advisories/GHSA-xfrj-c749-jxxq))
|
||||
- Fix missing ownership check in severed relationships controller ([GHSA](https://github.com/mastodon/mastodon/security/advisories/GHSA-ww85-x9cp-5v24))
|
||||
|
||||
### Changed
|
||||
|
||||
- Change HTTP Signature verification status from 401 to 503 on temporary failure to get remote actor (#37221 by @ClearlyClaire)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix custom emojis not being rendered in profile fields (#37365 by @ClearlyClaire)
|
||||
- Fix serialization of context pages (#37376 by @ClearlyClaire)
|
||||
- Fix quotes with CWs but no text not having fallback link (#37361 by @ClearlyClaire)
|
||||
- Fix outdated link target for “locked” warning (#37366 by @ClearlyClaire)
|
||||
- Fix local custom emojis sometimes being rendered in remote posts (#37284 by @ChaosExAnima)
|
||||
- Fix some assets not being loaded from configured CDN (#37310 by @ChaosExAnima)
|
||||
- Fix notifications page error in Tor browser (#37285 by @diondiondion)
|
||||
- Fix custom emojis not being displayed in CWs and fav/boost notifications (#37272 and #37306 by @ChaosExAnima and @ClearlyClaire)
|
||||
- Fix default `Admin` role not including `view_feeds` permission (#37301 by @ClearlyClaire)
|
||||
- Fix hashtag autocomplete replacing suggestion's first characters with input (#37281 by @ClearlyClaire)
|
||||
- Fix mentions of domain-blocked users being processed (#37257 by @ClearlyClaire)
|
||||
|
||||
## [4.5.3] - 2025-12-08
|
||||
|
||||
### Security
|
||||
|
||||
- Fix inconsistent error handling leaking information on existence of private posts ([GHSA-gwhw-gcjx-72v8](https://github.com/mastodon/mastodon/security/advisories/GHSA-gwhw-gcjx-72v8))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix “Delete and Redraft” on a non-quote being treated as a quote post in some cases (#37140 by @ClearlyClaire)
|
||||
- Fix YouTube embeds by sending referer (#37126 by @ChaosExAnima)
|
||||
- Fix streamed quoted polls not being hydrated correctly (#37118 by @ClearlyClaire)
|
||||
- Fix creation of duplicate conversations (#37108 by @oneiros)
|
||||
- Fix extraneous `noreferrer` in external links (#37107 by @ChaosExAnima)
|
||||
- Fix edge case error handling in some database migrations (#37079 by @ClearlyClaire)
|
||||
- Fix error handling when re-fetching already-known statuses (#37077 by @ClearlyClaire)
|
||||
- Fix post navigation in single-column mode when Advanced UI is enabled (#37044 by @diondiondion)
|
||||
- Fix `tootctl status remove` removing quoted posts and remote quotes of local posts (#37009 by @ClearlyClaire)
|
||||
- Fix known expensive S3 batch delete operation failing because of short timeouts (#37004 by @ClearlyClaire)
|
||||
- Fix compose autosuggest always lowercasing input token (#36995 by @ClearlyClaire)
|
||||
|
||||
## [4.5.2] - 2025-11-20
|
||||
|
||||
### Changed
|
||||
|
||||
- Change private quote education modal to not show up on self-quotes (#36926 by @ClearlyClaire)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix missing fallback link in CW-only quote posts (#36963 by @ClearlyClaire)
|
||||
- Fix statuses without text being hidden while loading (#36962 by @ClearlyClaire)
|
||||
- Fix `g` + `h` keyboard shortcut not working when a post is focused (#36935 by @diondiondion)
|
||||
- Fix quoting overwriting current content warning (#36934 by @ClearlyClaire)
|
||||
- Fix scroll-to-status in threaded view being unreliable (#36927 by @ClearlyClaire)
|
||||
- Fix path resolution for emoji worker (#36897 by @ChaosExAnima)
|
||||
- Fix `tootctl upgrade storage-schema` failing with `ArgumentError` (#36914 by @shugo)
|
||||
- Fix cross-origin handling of CSS modules (#36890 by @ClearlyClaire)
|
||||
- Fix error with remote tags including percent signs (#36886 and #36925 by @ChaosExAnima and @ClearlyClaire)
|
||||
- Fix bogus quote approval policy not always being replaced correctly (#36885 by @ClearlyClaire)
|
||||
- Fix hashtag completion not being inserted correctly (#36884 by @ClearlyClaire)
|
||||
- Fix Cmd/Ctrl + Enter in the composer triggering confirmation dialog action (#36870 by @diondiondion)
|
||||
|
||||
## [4.5.1] - 2025-11-13
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix Cmd/Ctrl + Enter not submitting Alt text modal on some browsers (#36866 by @diondiondion)
|
||||
- Fix posts coming from public/hashtag streaming being marked as unquotable (#36860 and #36869 by @ClearlyClaire)
|
||||
- Fix old previously-undiscovered posts being treated as new when receiving an `Update` (#36848 by @ClearlyClaire)
|
||||
- Fix blank screen in browsers that don't support `Intl.DisplayNames` (#36847 by @diondiondion)
|
||||
- Fix filters not being applied to quotes in detailed view (#36843 by @ClearlyClaire)
|
||||
- Fix scroll shift caused by fetch-all-replies alerts (#36807 by @diondiondion)
|
||||
- Fix dropdown menu not focusing first item when opened via keyboard (#36804 by @diondiondion)
|
||||
- Fix assets build issue on arch64 (#36781 by @ClearlyClaire)
|
||||
- Fix `/api/v1/statuses/:id/context` sometimes returing `Mastodon-Async-Refresh` without `result_count` (#36779 by @ClearlyClaire)
|
||||
- Fix prepared quote not being discarded with contents when replying (#36778 by @ClearlyClaire)
|
||||
|
||||
## [4.5.0] - 2025-11-06
|
||||
|
||||
### 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)\
|
||||
- **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, #36559, #36693, #36704, #36690, #36689, #36696, #36721, #36695 and #36736 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`.\
|
||||
- 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, #36607 and #36703 by @ClearlyClaire)\
|
||||
This splits the `timeline_preview` setting into four more granular settings controlling live feeds and topic (hashtag, trending link) feeds.\
|
||||
The setting for local topic feeds has 2 values: `public` and `authenticated`. Every other setting has 3 values: `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)
|
||||
@@ -20,21 +129,22 @@ All notable changes to this project will be documented in this file.
|
||||
- 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 default visualizer for audio upload without poster (#36734 by @ChaosExAnima)
|
||||
- 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 “Posting defaults” setting page, moving existing settings from “Other” (#35896, #36033, #35966, #35969, and #36084 by @ClearlyClaire and @diondiondion)
|
||||
- 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, #36638 and #36750 by @ChaosExAnima, @ClearlyClaire and @braddunbar)\
|
||||
This also completely reworks the processing and rendering of emojis and server-rendered HTML in statuses and other places.
|
||||
- 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
|
||||
|
||||
@@ -43,6 +153,9 @@ All notable changes to this project will be documented in this file.
|
||||
- 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 submitting an empty post to show an error rather than failing silently (#36650 by @diondiondion)
|
||||
- Change "Privacy and reach" settings from "Public profile" to their own top-level category (#27294 by @ChaelCodes)
|
||||
- Change number of times quote verification is retried to better deal with temporary failures (#36698 by @ClearlyClaire)
|
||||
- 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)
|
||||
@@ -70,9 +183,11 @@ All notable changes to this project will be documented in this file.
|
||||
- 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 initially selected language in Rules panel, hide selector when no alternative translations exist (#36672 by @diondiondion)
|
||||
- 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 Skeleton placeholders being animated when setting to reduce animations is enabled (#36716 by @ClearlyClaire)
|
||||
- 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)
|
||||
|
||||
@@ -183,7 +183,7 @@ FROM build AS libvips
|
||||
|
||||
# libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"]
|
||||
# renovate: datasource=github-releases depName=libvips packageName=libvips/libvips
|
||||
ARG VIPS_VERSION=8.17.2
|
||||
ARG VIPS_VERSION=8.17.3
|
||||
# libvips download URL, change with [--build-arg VIPS_URL="https://github.com/libvips/libvips/releases/download"]
|
||||
ARG VIPS_URL=https://github.com/libvips/libvips/releases/download
|
||||
|
||||
|
||||
@@ -48,3 +48,22 @@ Mastodon requires all `POST` requests to be signed, and MAY require `GET` reques
|
||||
### Additional documentation
|
||||
|
||||
- [Mastodon documentation](https://docs.joinmastodon.org/)
|
||||
|
||||
## Size limits
|
||||
|
||||
Mastodon imposes a few hard limits on federated content.
|
||||
These limits are intended to be very generous and way above what the Mastodon user experience is optimized for, so as to accomodate future changes and unusual or unforeseen usage patterns, while still providing some limits for performance reasons.
|
||||
The following table attempts to summary those limits.
|
||||
|
||||
| Limited property | Size limit | Consequence of exceeding the limit |
|
||||
| ------------------------------------------------------------- | ---------- | ---------------------------------- |
|
||||
| Serialized JSON-LD | 1MB | **Activity is rejected/dropped** |
|
||||
| Profile fields (actor `PropertyValue` attachments) name/value | 2047 | Field name/value is truncated |
|
||||
| Number of profile fields (actor `PropertyValue` attachments) | 50 | Fields list is truncated |
|
||||
| Poll options (number of `anyOf`/`oneOf` in a `Question`) | 500 | Items list is truncated |
|
||||
| Account username (actor `preferredUsername`) length | 2048 | **Actor will be rejected** |
|
||||
| Account display name (actor `name`) length | 2048 | Display name will be truncated |
|
||||
| Account note (actor `summary`) length | 20kB | Account note will be truncated |
|
||||
| Account `attributionDomains` | 256 | List will be truncated |
|
||||
| Account aliases (actor `alsoKnownAs`) | 256 | List will be truncated |
|
||||
| Custom emoji shortcode (`Emoji` `name`) | 2048 | Emoji will be rejected |
|
||||
|
||||
14
Gemfile.lock
14
Gemfile.lock
@@ -128,7 +128,7 @@ GEM
|
||||
blurhash (0.1.8)
|
||||
bootsnap (1.18.6)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (7.0.2)
|
||||
brakeman (7.1.1)
|
||||
racc
|
||||
browser (6.2.0)
|
||||
builder (3.3.0)
|
||||
@@ -224,7 +224,7 @@ GEM
|
||||
mail (~> 2.7)
|
||||
email_validator (2.2.4)
|
||||
activemodel
|
||||
erb (5.1.1)
|
||||
erb (5.1.3)
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.4.0)
|
||||
tzinfo
|
||||
@@ -337,7 +337,7 @@ GEM
|
||||
activesupport (>= 3.0)
|
||||
nokogiri (>= 1.6)
|
||||
io-console (0.8.1)
|
||||
irb (1.15.2)
|
||||
irb (1.15.3)
|
||||
pp (>= 0.6.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
@@ -621,7 +621,7 @@ GEM
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.2.3)
|
||||
rack (3.2.4)
|
||||
rack-attack (6.8.0)
|
||||
rack (>= 1.0, < 4)
|
||||
rack-cors (3.0.0)
|
||||
@@ -691,7 +691,7 @@ GEM
|
||||
readline (~> 0.0)
|
||||
rdf-normalize (0.7.0)
|
||||
rdf (~> 3.3)
|
||||
rdoc (6.15.0)
|
||||
rdoc (6.15.1)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
tsort
|
||||
@@ -791,7 +791,7 @@ GEM
|
||||
ruby-vips (2.2.5)
|
||||
ffi (~> 1.12)
|
||||
logger
|
||||
rubyzip (3.2.1)
|
||||
rubyzip (3.2.2)
|
||||
rufus-scheduler (3.9.2)
|
||||
fugit (~> 1.1, >= 1.11.1)
|
||||
safety_net_attestation (0.5.0)
|
||||
@@ -805,7 +805,7 @@ GEM
|
||||
securerandom (0.4.1)
|
||||
shoulda-matchers (6.5.0)
|
||||
activesupport (>= 5.2.0)
|
||||
sidekiq (8.0.8)
|
||||
sidekiq (8.0.9)
|
||||
connection_pool (>= 2.5.0)
|
||||
json (>= 2.9.0)
|
||||
logger (>= 1.6.2)
|
||||
|
||||
@@ -15,7 +15,7 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ---------------- |
|
||||
| 4.5.x | Yes |
|
||||
| 4.4.x | Yes |
|
||||
| 4.3.x | Yes |
|
||||
| 4.2.x | Until 2026-01-08 |
|
||||
| < 4.2 | No |
|
||||
| 4.3.x | Until 2026-05-06 |
|
||||
| < 4.3 | No |
|
||||
|
||||
@@ -36,9 +36,8 @@ class ActivityPub::ContextsController < ActivityPub::BaseController
|
||||
|
||||
def context_presenter
|
||||
first_page = ActivityPub::CollectionPresenter.new(
|
||||
id: items_context_url(@conversation, page_params),
|
||||
type: :unordered,
|
||||
part_of: items_context_url(@conversation),
|
||||
part_of: context_url(@conversation),
|
||||
next: next_page,
|
||||
items: @items.map { |status| status.local? ? ActivityPub::TagManager.instance.uri_for(status) : status.uri }
|
||||
)
|
||||
@@ -52,7 +51,7 @@ class ActivityPub::ContextsController < ActivityPub::BaseController
|
||||
page = ActivityPub::CollectionPresenter.new(
|
||||
id: items_context_url(@conversation, page_params),
|
||||
type: :unordered,
|
||||
part_of: items_context_url(@conversation),
|
||||
part_of: context_url(@conversation),
|
||||
next: next_page,
|
||||
items: @items.map { |status| status.local? ? ActivityPub::TagManager.instance.uri_for(status) : status.uri }
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
class ActivityPub::InboxesController < ActivityPub::BaseController
|
||||
include JsonLdHelper
|
||||
|
||||
before_action :skip_large_payload
|
||||
before_action :skip_unknown_actor_activity
|
||||
before_action :require_actor_signature!
|
||||
skip_before_action :authenticate_user!
|
||||
@@ -16,6 +17,10 @@ class ActivityPub::InboxesController < ActivityPub::BaseController
|
||||
|
||||
private
|
||||
|
||||
def skip_large_payload
|
||||
head 413 if request.content_length > ActivityPub::Activity::MAX_JSON_SIZE
|
||||
end
|
||||
|
||||
def skip_unknown_actor_activity
|
||||
head 202 if unknown_affected_account?
|
||||
end
|
||||
|
||||
@@ -22,7 +22,7 @@ class ActivityPub::LikesController < ActivityPub::BaseController
|
||||
def set_status
|
||||
@status = @account.statuses.find(params[:status_id])
|
||||
authorize @status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ class ActivityPub::QuoteAuthorizationsController < ActivityPub::BaseController
|
||||
before_action :set_quote_authorization
|
||||
|
||||
def show
|
||||
expires_in 30.seconds, public: true if @quote.status.distributable? && public_fetch_mode?
|
||||
expires_in 30.seconds, public: true if @quote.quoted_status.distributable? && public_fetch_mode?
|
||||
render json: @quote, serializer: ActivityPub::QuoteAuthorizationSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
|
||||
end
|
||||
|
||||
@@ -23,8 +23,8 @@ class ActivityPub::QuoteAuthorizationsController < ActivityPub::BaseController
|
||||
@quote = Quote.accepted.where(quoted_account: @account).find(params[:id])
|
||||
return not_found unless @quote.status.present? && @quote.quoted_status.present?
|
||||
|
||||
authorize @quote.status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
authorize @quote.quoted_status, :show?
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
end
|
||||
|
||||
@@ -25,7 +25,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController
|
||||
def set_status
|
||||
@status = @account.statuses.find(params[:status_id])
|
||||
authorize @status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ class ActivityPub::SharesController < ActivityPub::BaseController
|
||||
def set_status
|
||||
@status = @account.statuses.find(params[:status_id])
|
||||
authorize @status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ class Api::V1::Polls::VotesController < Api::BaseController
|
||||
def set_poll
|
||||
@poll = Poll.find(params[:poll_id])
|
||||
authorize @poll.status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ class Api::V1::PollsController < Api::BaseController
|
||||
def set_poll
|
||||
@poll = Poll.find(params[:id])
|
||||
authorize @poll.status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ class Api::V1::Statuses::BaseController < Api::BaseController
|
||||
def set_status
|
||||
@status = Status.find(params[:status_id])
|
||||
authorize @status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
end
|
||||
|
||||
@@ -23,7 +23,7 @@ class Api::V1::Statuses::BookmarksController < Api::V1::Statuses::BaseController
|
||||
bookmark&.destroy!
|
||||
|
||||
render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_account.id, bookmarks_map: { @status.id => false })
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
end
|
||||
|
||||
@@ -25,7 +25,7 @@ class Api::V1::Statuses::FavouritesController < Api::V1::Statuses::BaseControlle
|
||||
|
||||
relationships = StatusRelationshipsPresenter.new([@status], current_account.id, favourites_map: { @status.id => false }, attributes_map: { @status.id => { favourites_count: count } })
|
||||
render json: @status, serializer: REST::StatusSerializer, relationships: relationships
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
end
|
||||
|
||||
@@ -36,7 +36,7 @@ class Api::V1::Statuses::ReblogsController < Api::V1::Statuses::BaseController
|
||||
|
||||
relationships = StatusRelationshipsPresenter.new([@status], current_account.id, reblogs_map: { @reblog.id => false }, attributes_map: { @reblog.id => { reblogs_count: count } })
|
||||
render json: @reblog, serializer: REST::StatusSerializer, relationships: relationships
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
@@ -45,7 +45,7 @@ class Api::V1::Statuses::ReblogsController < Api::V1::Statuses::BaseController
|
||||
def set_reblog
|
||||
@reblog = Status.find(params[:status_id])
|
||||
authorize @reblog, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ class Api::V1::StatusesController < Api::BaseController
|
||||
if async_refresh.running?
|
||||
add_async_refresh_header(async_refresh)
|
||||
elsif !current_account.nil? && @status.should_fetch_replies?
|
||||
add_async_refresh_header(AsyncRefresh.create(refresh_key))
|
||||
add_async_refresh_header(AsyncRefresh.create(refresh_key, count_results: true))
|
||||
|
||||
WorkerBatch.new.within do |batch|
|
||||
batch.connect(refresh_key, threshold: 1.0)
|
||||
@@ -107,9 +107,7 @@ class Api::V1::StatusesController < Api::BaseController
|
||||
@status = Status.where(account: current_account).find(params[:id])
|
||||
authorize @status, :update?
|
||||
|
||||
UpdateStatusService.new.call(
|
||||
@status,
|
||||
current_account.id,
|
||||
update_options = {
|
||||
text: status_params[:status],
|
||||
media_ids: status_params[:media_ids],
|
||||
media_attributes: status_params[:media_attributes],
|
||||
@@ -117,9 +115,12 @@ class Api::V1::StatusesController < Api::BaseController
|
||||
language: status_params[:language],
|
||||
spoiler_text: status_params[:spoiler_text],
|
||||
poll: status_params[:poll],
|
||||
quote_approval_policy: quote_approval_policy,
|
||||
content_type: status_params[:content_type]
|
||||
)
|
||||
content_type: status_params[:content_type],
|
||||
}
|
||||
|
||||
update_options[:quote_approval_policy] = quote_approval_policy if status_params[:quote_approval_policy].present?
|
||||
|
||||
UpdateStatusService.new.call(@status, current_account.id, update_options)
|
||||
|
||||
render json: @status, serializer: REST::StatusSerializer
|
||||
end
|
||||
@@ -147,7 +148,7 @@ class Api::V1::StatusesController < Api::BaseController
|
||||
def set_status
|
||||
@status = Status.find(params[:id])
|
||||
authorize @status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ class Api::Web::EmbedsController < Api::Web::BaseController
|
||||
def set_status
|
||||
@status = Status.find(params[:id])
|
||||
authorize @status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
end
|
||||
|
||||
@@ -62,7 +62,7 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
|
||||
end
|
||||
|
||||
def set_push_subscription
|
||||
@push_subscription = ::Web::PushSubscription.find(params[:id])
|
||||
@push_subscription = ::Web::PushSubscription.where(user_id: active_session.user_id).find(params[:id])
|
||||
end
|
||||
|
||||
def subscription_params
|
||||
|
||||
@@ -21,7 +21,7 @@ class AuthorizeInteractionsController < ApplicationController
|
||||
def set_resource
|
||||
@resource = located_resource
|
||||
authorize(@resource, :show?) if @resource.is_a?(Status)
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ module CacheConcern
|
||||
# from being used as cache keys, while allowing to `Vary` on them (to not serve
|
||||
# anonymous cached data to authenticated requests when authentication matters)
|
||||
def enforce_cache_control!
|
||||
vary = response.headers['Vary']&.split&.map { |x| x.strip.downcase }
|
||||
vary = response.headers['Vary'].to_s.split(',').map { |x| x.strip.downcase }.reject(&:empty?)
|
||||
return unless vary.present? && %w(cookie authorization signature).any? { |header| vary.include?(header) && request.headers[header].present? }
|
||||
|
||||
response.cache_control.replace(private: true, no_store: true)
|
||||
|
||||
@@ -72,10 +72,13 @@ module SignatureVerification
|
||||
rescue Mastodon::SignatureVerificationError => e
|
||||
fail_with! e.message
|
||||
rescue *Mastodon::HTTP_CONNECTION_ERRORS => e
|
||||
@signature_verification_failure_code ||= 503
|
||||
fail_with! "Failed to fetch remote data: #{e.message}"
|
||||
rescue Mastodon::UnexpectedResponseError
|
||||
@signature_verification_failure_code ||= 503
|
||||
fail_with! 'Failed to fetch remote data (got unexpected reply from server)'
|
||||
rescue Stoplight::Error::RedLight
|
||||
@signature_verification_failure_code ||= 503
|
||||
fail_with! 'Fetching attempt skipped because of recent connection failure'
|
||||
end
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ class MediaController < ApplicationController
|
||||
|
||||
def verify_permitted_status!
|
||||
authorize @media_attachment.status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ class SeveredRelationshipsController < ApplicationController
|
||||
private
|
||||
|
||||
def set_event
|
||||
@event = AccountRelationshipSeveranceEvent.find(params[:id])
|
||||
@event = AccountRelationshipSeveranceEvent.where(account: current_account).find(params[:id])
|
||||
end
|
||||
|
||||
def following_data
|
||||
|
||||
@@ -62,7 +62,7 @@ class StatusesController < ApplicationController
|
||||
def set_status
|
||||
@status = @account.statuses.find(params[:id])
|
||||
authorize @status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { throttle } from 'lodash';
|
||||
|
||||
import api from 'flavours/glitch/api';
|
||||
import { browserHistory } from 'flavours/glitch/components/router';
|
||||
import { countableText } from 'flavours/glitch/features/compose/util/counter';
|
||||
import { search as emojiSearch } from 'flavours/glitch/features/emoji/emoji_mart_search_light';
|
||||
import { tagHistory } from 'flavours/glitch/settings';
|
||||
import { recoverHashtags } from 'flavours/glitch/utils/hashtag';
|
||||
@@ -57,7 +58,6 @@ export const COMPOSE_ADVANCED_OPTIONS_CHANGE = 'COMPOSE_ADVANCED_OPTIONS_CHANGE'
|
||||
export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE';
|
||||
export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
|
||||
export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE';
|
||||
export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE';
|
||||
export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE';
|
||||
export const COMPOSE_CONTENT_TYPE_CHANGE = 'COMPOSE_CONTENT_TYPE_CHANGE';
|
||||
export const COMPOSE_LANGUAGE_CHANGE = 'COMPOSE_LANGUAGE_CHANGE';
|
||||
@@ -93,6 +93,7 @@ const messages = defineMessages({
|
||||
open: { id: 'compose.published.open', defaultMessage: 'Open' },
|
||||
published: { id: 'compose.published.body', defaultMessage: 'Post published.' },
|
||||
saved: { id: 'compose.saved.body', defaultMessage: 'Post saved.' },
|
||||
blankPostError: { id: 'compose.error.blank_post', defaultMessage: 'Post can\'t be blank.' },
|
||||
});
|
||||
|
||||
export const ensureComposeIsVisible = (getState) => {
|
||||
@@ -215,7 +216,15 @@ export function submitCompose(overridePrivacy = null, successCallback = undefine
|
||||
const spoilers = getState().getIn(['compose', 'spoiler']) || getState().getIn(['local_settings', 'always_show_spoilers_field']);
|
||||
const spoiler_text = spoilers ? getState().getIn(['compose', 'spoiler_text'], '') : '';
|
||||
|
||||
if (!(status?.length || media.size !== 0 || (hasQuote && spoiler_text?.length))) {
|
||||
const fulltext = `${spoiler_text ?? ''}${countableText(status ?? '')}`;
|
||||
const hasText = fulltext.trim().length > 0;
|
||||
|
||||
if (!(hasText || media.size !== 0 || (hasQuote && spoiler_text?.length))) {
|
||||
dispatch(showAlert({
|
||||
message: messages.blankPostError,
|
||||
}));
|
||||
dispatch(focusCompose());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -700,8 +709,17 @@ export function selectComposeSuggestion(position, token, suggestion, path) {
|
||||
|
||||
dispatch(useEmoji(suggestion));
|
||||
} else if (suggestion.type === 'hashtag') {
|
||||
completion = suggestion.name.slice(token.length - 1);
|
||||
startPosition = position + token.length;
|
||||
// TODO: it could make sense to keep the “most capitalized” of the two
|
||||
const tokenName = token.slice(1); // strip leading '#'
|
||||
const suggestionPrefix = suggestion.name.slice(0, tokenName.length);
|
||||
const prefixMatchesSuggestion = suggestionPrefix.localeCompare(tokenName, undefined, { sensitivity: 'accent' }) === 0;
|
||||
if (prefixMatchesSuggestion) {
|
||||
completion = token + suggestion.name.slice(tokenName.length);
|
||||
} else {
|
||||
completion = `${token.slice(0, 1)}${suggestion.name}`;
|
||||
}
|
||||
|
||||
startPosition = position - 1;
|
||||
} else if (suggestion.type === 'account') {
|
||||
completion = `@${getState().getIn(['accounts', suggestion.id, 'acct'])}`;
|
||||
startPosition = position - 1;
|
||||
@@ -815,13 +833,6 @@ export function changeComposeSpoilerText(text) {
|
||||
};
|
||||
}
|
||||
|
||||
export function changeComposeVisibility(value) {
|
||||
return {
|
||||
type: COMPOSE_VISIBILITY_CHANGE,
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
export function insertEmojiCompose(position, emoji, needsSpace) {
|
||||
return {
|
||||
type: COMPOSE_EMOJI_INSERT,
|
||||
|
||||
@@ -13,10 +13,11 @@ import {
|
||||
} from 'flavours/glitch/store/typed_functions';
|
||||
|
||||
import type { ApiQuotePolicy } from '../api_types/quotes';
|
||||
import type { Status } from '../models/status';
|
||||
import type { Status, StatusVisibility } from '../models/status';
|
||||
import type { RootState } from '../store';
|
||||
|
||||
import { showAlert } from './alerts';
|
||||
import { focusCompose } from './compose';
|
||||
import { changeCompose, focusCompose } from './compose';
|
||||
import { importFetchedStatuses } from './importer';
|
||||
import { openModal } from './modal';
|
||||
|
||||
@@ -41,6 +42,10 @@ const messages = defineMessages({
|
||||
id: 'quote_error.unauthorized',
|
||||
defaultMessage: 'You are not authorized to quote this post.',
|
||||
},
|
||||
quoteErrorPrivateMention: {
|
||||
id: 'quote_error.private_mentions',
|
||||
defaultMessage: 'Quoting is not allowed with direct mentions.',
|
||||
},
|
||||
});
|
||||
|
||||
type SimulatedMediaAttachmentJSON = ApiMediaAttachmentJSON & {
|
||||
@@ -67,6 +72,39 @@ const simulateModifiedApiResponse = (
|
||||
return data;
|
||||
};
|
||||
|
||||
export const changeComposeVisibility = createAppThunk(
|
||||
'compose/visibility_change',
|
||||
(visibility: StatusVisibility, { dispatch, getState }) => {
|
||||
if (visibility !== 'direct') {
|
||||
return visibility;
|
||||
}
|
||||
|
||||
const state = getState();
|
||||
const quotedStatusId = state.compose.get('quoted_status_id') as
|
||||
| string
|
||||
| null;
|
||||
if (!quotedStatusId) {
|
||||
return visibility;
|
||||
}
|
||||
|
||||
// Remove the quoted status
|
||||
dispatch(quoteComposeCancel());
|
||||
const quotedStatus = state.statuses.get(quotedStatusId) as Status | null;
|
||||
if (!quotedStatus) {
|
||||
return visibility;
|
||||
}
|
||||
|
||||
// Append the quoted status URL to the compose text
|
||||
const url = quotedStatus.get('url') as string;
|
||||
const text = state.compose.get('text') as string;
|
||||
if (!text.includes(url)) {
|
||||
const newText = text.trim() ? `${text}\n\n${url}` : url;
|
||||
dispatch(changeCompose(newText));
|
||||
}
|
||||
return visibility;
|
||||
},
|
||||
);
|
||||
|
||||
export const changeUploadCompose = createDataLoadingThunk(
|
||||
'compose/changeUpload',
|
||||
async (
|
||||
@@ -130,6 +168,8 @@ export const quoteComposeByStatus = createAppThunk(
|
||||
|
||||
if (composeState.get('id')) {
|
||||
dispatch(showAlert({ message: messages.quoteErrorEdit }));
|
||||
} else if (composeState.get('privacy') === 'direct') {
|
||||
dispatch(showAlert({ message: messages.quoteErrorPrivateMention }));
|
||||
} else if (composeState.get('poll')) {
|
||||
dispatch(showAlert({ message: messages.quoteErrorPoll }));
|
||||
} else if (
|
||||
@@ -173,6 +213,17 @@ export const quoteComposeById = createAppThunk(
|
||||
},
|
||||
);
|
||||
|
||||
const composeStateForbidsLink = (composeState: RootState['compose']) => {
|
||||
return (
|
||||
composeState.get('quoted_status_id') ||
|
||||
composeState.get('is_submitting') ||
|
||||
composeState.get('poll') ||
|
||||
composeState.get('is_uploading') ||
|
||||
composeState.get('id') ||
|
||||
composeState.get('privacy') === 'direct'
|
||||
);
|
||||
};
|
||||
|
||||
export const pasteLinkCompose = createDataLoadingThunk(
|
||||
'compose/pasteLink',
|
||||
async ({ url }: { url: string }) => {
|
||||
@@ -183,15 +234,12 @@ export const pasteLinkCompose = createDataLoadingThunk(
|
||||
limit: 2,
|
||||
});
|
||||
},
|
||||
(data, { dispatch, getState }) => {
|
||||
(data, { dispatch, getState, requestId }) => {
|
||||
const composeState = getState().compose;
|
||||
|
||||
if (
|
||||
composeState.get('quoted_status_id') ||
|
||||
composeState.get('is_submitting') ||
|
||||
composeState.get('poll') ||
|
||||
composeState.get('is_uploading') ||
|
||||
composeState.get('id')
|
||||
composeStateForbidsLink(composeState) ||
|
||||
composeState.get('fetching_link') !== requestId // Request has been cancelled
|
||||
)
|
||||
return;
|
||||
|
||||
@@ -207,6 +255,17 @@ export const pasteLinkCompose = createDataLoadingThunk(
|
||||
dispatch(quoteComposeById(data.statuses[0].id));
|
||||
}
|
||||
},
|
||||
{
|
||||
useLoadingBar: false,
|
||||
condition: (_, { getState }) =>
|
||||
!getState().compose.get('fetching_link') &&
|
||||
!composeStateForbidsLink(getState().compose),
|
||||
},
|
||||
);
|
||||
|
||||
// Ideally this would cancel the action and the HTTP request, but this is good enough
|
||||
export const cancelPasteLinkCompose = createAction(
|
||||
'compose/cancelPasteLinkCompose',
|
||||
);
|
||||
|
||||
export const quoteComposeCancel = createAction('compose/quoteComposeCancel');
|
||||
|
||||
@@ -46,11 +46,11 @@ export function importFetchedAccounts(accounts) {
|
||||
return importAccounts({ accounts: normalAccounts });
|
||||
}
|
||||
|
||||
export function importFetchedStatus(status) {
|
||||
return importFetchedStatuses([status]);
|
||||
export function importFetchedStatus(status, options = {}) {
|
||||
return importFetchedStatuses([status], options);
|
||||
}
|
||||
|
||||
export function importFetchedStatuses(statuses) {
|
||||
export function importFetchedStatuses(statuses, options = {}) {
|
||||
return (dispatch, getState) => {
|
||||
const accounts = [];
|
||||
const normalStatuses = [];
|
||||
@@ -58,7 +58,7 @@ export function importFetchedStatuses(statuses) {
|
||||
const filters = [];
|
||||
|
||||
function processStatus(status) {
|
||||
pushUnique(normalStatuses, normalizeStatus(status, getState().getIn(['statuses', status.id]), getState().get('local_settings')));
|
||||
pushUnique(normalStatuses, normalizeStatus(status, getState().getIn(['statuses', status.id]), { ...options, settings: getState().get('local_settings') }));
|
||||
pushUnique(accounts, status.account);
|
||||
|
||||
if (status.filtered) {
|
||||
|
||||
@@ -27,9 +27,12 @@ function stripQuoteFallback(text) {
|
||||
return wrapper.innerHTML;
|
||||
}
|
||||
|
||||
export function normalizeStatus(status, normalOldStatus, settings) {
|
||||
export function normalizeStatus(status, normalOldStatus, { settings, bogusQuotePolicy = false }) {
|
||||
const normalStatus = { ...status };
|
||||
|
||||
if (bogusQuotePolicy)
|
||||
normalStatus.quote_approval = null;
|
||||
|
||||
normalStatus.account = status.account.id;
|
||||
|
||||
if (status.reblog && status.reblog.id) {
|
||||
@@ -101,6 +104,8 @@ export function normalizeStatus(status, normalOldStatus, settings) {
|
||||
}
|
||||
|
||||
if (normalOldStatus) {
|
||||
normalStatus.quote_approval ||= normalOldStatus.get('quote_approval');
|
||||
|
||||
const list = normalOldStatus.get('media_attachments');
|
||||
if (normalStatus.media_attachments && list) {
|
||||
normalStatus.media_attachments.forEach(item => {
|
||||
|
||||
@@ -85,6 +85,8 @@ export function fetchStatus(id, {
|
||||
dispatch(fetchStatusSuccess(skipLoading));
|
||||
}).catch(error => {
|
||||
dispatch(fetchStatusFail(id, error, skipLoading, parentQuotePostId));
|
||||
if (error.status === 404)
|
||||
dispatch(deleteFromTimelines(id));
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -204,8 +206,8 @@ export function deleteStatusFail(id, error) {
|
||||
};
|
||||
}
|
||||
|
||||
export const updateStatus = status => dispatch =>
|
||||
dispatch(importFetchedStatus(status));
|
||||
export const updateStatus = (status, { bogusQuotePolicy }) => dispatch =>
|
||||
dispatch(importFetchedStatus(status, { bogusQuotePolicy }));
|
||||
|
||||
export function muteStatus(id) {
|
||||
return (dispatch) => {
|
||||
|
||||
@@ -52,6 +52,9 @@ const randomUpTo = max =>
|
||||
export const connectTimelineStream = (timelineId, channelName, params = {}, options = {}) => {
|
||||
const { messages } = getLocale();
|
||||
|
||||
// Public streams are currently not returning personalized quote policies
|
||||
const bogusQuotePolicy = channelName.startsWith('public') || channelName.startsWith('hashtag');
|
||||
|
||||
return connectStream(channelName, params, (dispatch, getState) => {
|
||||
// @ts-ignore
|
||||
const locale = getState().getIn(['meta', 'locale']);
|
||||
@@ -97,11 +100,11 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
|
||||
switch (data.event) {
|
||||
case 'update':
|
||||
// @ts-expect-error
|
||||
dispatch(updateTimeline(timelineId, JSON.parse(data.payload), options.accept));
|
||||
dispatch(updateTimeline(timelineId, JSON.parse(data.payload), { accept: options.accept, bogusQuotePolicy }));
|
||||
break;
|
||||
case 'status.update':
|
||||
// @ts-expect-error
|
||||
dispatch(updateStatus(JSON.parse(data.payload)));
|
||||
dispatch(updateStatus(JSON.parse(data.payload), { bogusQuotePolicy }));
|
||||
break;
|
||||
case 'delete':
|
||||
dispatch(deleteFromTimelines(data.payload));
|
||||
|
||||
@@ -33,7 +33,7 @@ export const loadPending = timeline => ({
|
||||
timeline,
|
||||
});
|
||||
|
||||
export function updateTimeline(timeline, status, accept) {
|
||||
export function updateTimeline(timeline, status, { accept = undefined, bogusQuotePolicy = false } = {}) {
|
||||
return (dispatch, getState) => {
|
||||
if (typeof accept === 'function' && !accept(status)) {
|
||||
return;
|
||||
@@ -55,7 +55,7 @@ export function updateTimeline(timeline, status, accept) {
|
||||
filtered = filters.length > 0;
|
||||
}
|
||||
|
||||
dispatch(importFetchedStatus(status));
|
||||
dispatch(importFetchedStatus(status, { bogusQuotePolicy }));
|
||||
|
||||
dispatch({
|
||||
type: TIMELINE_UPDATE,
|
||||
|
||||
@@ -6,7 +6,6 @@ import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import type { Account } from 'flavours/glitch/models/account';
|
||||
|
||||
import { CustomEmojiProvider } from './emoji/context';
|
||||
import { EmojiHTML } from './emoji/html';
|
||||
import { useElementHandledLink } from './status/handled_link';
|
||||
|
||||
@@ -22,12 +21,13 @@ export const AccountFields: React.FC<Pick<Account, 'fields' | 'emojis'>> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<CustomEmojiProvider emojis={emojis}>
|
||||
<>
|
||||
{fields.map((pair, i) => (
|
||||
<dl key={i} className={classNames({ verified: pair.verified_at })}>
|
||||
<EmojiHTML
|
||||
as='dt'
|
||||
htmlString={pair.name_emojified}
|
||||
extraEmojis={emojis}
|
||||
className='translate'
|
||||
{...htmlHandlers}
|
||||
/>
|
||||
@@ -52,12 +52,13 @@ export const AccountFields: React.FC<Pick<Account, 'fields' | 'emojis'>> = ({
|
||||
<EmojiHTML
|
||||
as='span'
|
||||
htmlString={pair.value_emojified}
|
||||
extraEmojis={emojis}
|
||||
{...htmlHandlers}
|
||||
/>
|
||||
</dd>
|
||||
</dl>
|
||||
))}
|
||||
</CustomEmojiProvider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
word = word.trim().toLowerCase();
|
||||
word = word.trim();
|
||||
|
||||
if (word.length > 0) {
|
||||
return [left + 1, word];
|
||||
|
||||
@@ -29,7 +29,7 @@ const textAtCursorMatchesToken = (str, caretPosition) => {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
word = word.trim().toLowerCase();
|
||||
word = word.trim();
|
||||
|
||||
if (word.length > 0) {
|
||||
return [left + 1, word];
|
||||
|
||||
@@ -41,7 +41,7 @@ export const ContentWarning: React.FC<{
|
||||
<EmojiHTML
|
||||
as='span'
|
||||
htmlString={text}
|
||||
extraEmojis={status.get('emoji') as List<CustomEmoji>}
|
||||
extraEmojis={status.get('emojis') as List<CustomEmoji>}
|
||||
/>
|
||||
</StatusBanner>
|
||||
);
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
closeDropdownMenu,
|
||||
} from 'flavours/glitch/actions/dropdown_menu';
|
||||
import { openModal, closeModal } from 'flavours/glitch/actions/modal';
|
||||
import { fetchStatus } from 'flavours/glitch/actions/statuses';
|
||||
import { CircularProgress } from 'flavours/glitch/components/circular_progress';
|
||||
import { isUserTouching } from 'flavours/glitch/is_mobile';
|
||||
import {
|
||||
@@ -42,16 +43,10 @@ import { IconButton } from './icon_button';
|
||||
|
||||
let id = 0;
|
||||
|
||||
export interface RenderItemFnHandlers {
|
||||
onClick: React.MouseEventHandler;
|
||||
onKeyUp: React.KeyboardEventHandler;
|
||||
}
|
||||
|
||||
export type RenderItemFn<Item = MenuItem> = (
|
||||
item: Item,
|
||||
index: number,
|
||||
handlers: RenderItemFnHandlers,
|
||||
focusRefCallback?: (c: HTMLAnchorElement | HTMLButtonElement | null) => void,
|
||||
onClick: React.MouseEventHandler,
|
||||
) => React.ReactNode;
|
||||
|
||||
type ItemClickFn<Item = MenuItem> = (item: Item, index: number) => void;
|
||||
@@ -101,7 +96,6 @@ export const DropdownMenu = <Item = MenuItem,>({
|
||||
onItemClick,
|
||||
}: DropdownMenuProps<Item>) => {
|
||||
const nodeRef = useRef<HTMLDivElement>(null);
|
||||
const focusedItemRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleDocumentClick = (e: MouseEvent) => {
|
||||
@@ -163,8 +157,11 @@ export const DropdownMenu = <Item = MenuItem,>({
|
||||
document.addEventListener('click', handleDocumentClick, { capture: true });
|
||||
document.addEventListener('keydown', handleKeyDown, { capture: true });
|
||||
|
||||
if (focusedItemRef.current && openedViaKeyboard) {
|
||||
focusedItemRef.current.focus({ preventScroll: true });
|
||||
if (openedViaKeyboard) {
|
||||
const firstMenuItem = nodeRef.current?.querySelector<
|
||||
HTMLAnchorElement | HTMLButtonElement
|
||||
>('li:first-child > :is(a, button)');
|
||||
firstMenuItem?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
return () => {
|
||||
@@ -175,13 +172,6 @@ export const DropdownMenu = <Item = MenuItem,>({
|
||||
};
|
||||
}, [onClose, openedViaKeyboard]);
|
||||
|
||||
const handleFocusedItemRef = useCallback(
|
||||
(c: HTMLAnchorElement | HTMLButtonElement | null) => {
|
||||
focusedItemRef.current = c as HTMLElement;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleItemClick = useCallback(
|
||||
(e: React.MouseEvent | React.KeyboardEvent) => {
|
||||
const i = Number(e.currentTarget.getAttribute('data-index'));
|
||||
@@ -207,15 +197,6 @@ export const DropdownMenu = <Item = MenuItem,>({
|
||||
[onClose, onItemClick, items],
|
||||
);
|
||||
|
||||
const handleItemKeyUp = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
handleItemClick(e);
|
||||
}
|
||||
},
|
||||
[handleItemClick],
|
||||
);
|
||||
|
||||
const nativeRenderItem = (option: Item, i: number) => {
|
||||
if (!isMenuItem(option)) {
|
||||
return null;
|
||||
@@ -232,9 +213,7 @@ export const DropdownMenu = <Item = MenuItem,>({
|
||||
if (isActionItem(option)) {
|
||||
element = (
|
||||
<button
|
||||
ref={i === 0 ? handleFocusedItemRef : undefined}
|
||||
onClick={handleItemClick}
|
||||
onKeyUp={handleItemKeyUp}
|
||||
data-index={i}
|
||||
aria-disabled={disabled}
|
||||
>
|
||||
@@ -248,9 +227,7 @@ export const DropdownMenu = <Item = MenuItem,>({
|
||||
target={option.target ?? '_target'}
|
||||
data-method={option.method}
|
||||
rel='noopener'
|
||||
ref={i === 0 ? handleFocusedItemRef : undefined}
|
||||
onClick={handleItemClick}
|
||||
onKeyUp={handleItemKeyUp}
|
||||
data-index={i}
|
||||
>
|
||||
<DropdownMenuItemContent item={option} />
|
||||
@@ -258,13 +235,7 @@ export const DropdownMenu = <Item = MenuItem,>({
|
||||
);
|
||||
} else {
|
||||
element = (
|
||||
<Link
|
||||
to={option.to}
|
||||
ref={i === 0 ? handleFocusedItemRef : undefined}
|
||||
onClick={handleItemClick}
|
||||
onKeyUp={handleItemKeyUp}
|
||||
data-index={i}
|
||||
>
|
||||
<Link to={option.to} onClick={handleItemClick} data-index={i}>
|
||||
<DropdownMenuItemContent item={option} />
|
||||
</Link>
|
||||
);
|
||||
@@ -307,15 +278,7 @@ export const DropdownMenu = <Item = MenuItem,>({
|
||||
})}
|
||||
>
|
||||
{items.map((option, i) =>
|
||||
renderItemMethod(
|
||||
option,
|
||||
i,
|
||||
{
|
||||
onClick: handleItemClick,
|
||||
onKeyUp: handleItemKeyUp,
|
||||
},
|
||||
i === 0 ? handleFocusedItemRef : undefined,
|
||||
),
|
||||
renderItemMethod(option, i, handleItemClick),
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
@@ -340,6 +303,7 @@ interface DropdownProps<Item extends object | null = MenuItem> {
|
||||
*/
|
||||
scrollKey?: string;
|
||||
status?: ImmutableMap<string, unknown>;
|
||||
needsStatusRefresh?: boolean;
|
||||
forceDropdown?: boolean;
|
||||
renderItem?: RenderItemFn<Item>;
|
||||
renderHeader?: RenderHeaderFn<Item>;
|
||||
@@ -363,6 +327,7 @@ export const Dropdown = <Item extends object | null = MenuItem>({
|
||||
placement = 'bottom',
|
||||
offset = [5, 5],
|
||||
status,
|
||||
needsStatusRefresh,
|
||||
forceDropdown = false,
|
||||
renderItem,
|
||||
renderHeader,
|
||||
@@ -382,6 +347,7 @@ export const Dropdown = <Item extends object | null = MenuItem>({
|
||||
const prefetchAccountId = status
|
||||
? status.getIn(['account', 'id'])
|
||||
: undefined;
|
||||
const statusId = status?.get('id') as string | undefined;
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (buttonRef.current) {
|
||||
@@ -399,7 +365,7 @@ export const Dropdown = <Item extends object | null = MenuItem>({
|
||||
}, [dispatch, currentId]);
|
||||
|
||||
const handleItemClick = useCallback(
|
||||
(e: React.MouseEvent | React.KeyboardEvent) => {
|
||||
(e: React.MouseEvent) => {
|
||||
const i = Number(e.currentTarget.getAttribute('data-index'));
|
||||
const item = items?.[i];
|
||||
|
||||
@@ -420,10 +386,20 @@ export const Dropdown = <Item extends object | null = MenuItem>({
|
||||
[handleClose, onItemClick, items],
|
||||
);
|
||||
|
||||
const toggleDropdown = useCallback(
|
||||
(e: React.MouseEvent | React.KeyboardEvent) => {
|
||||
const { type } = e;
|
||||
const isKeypressRef = useRef(false);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === ' ' || e.key === 'Enter') {
|
||||
isKeypressRef.current = true;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const unsetIsKeypress = useCallback(() => {
|
||||
isKeypressRef.current = false;
|
||||
}, []);
|
||||
|
||||
const toggleDropdown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (open) {
|
||||
handleClose();
|
||||
} else {
|
||||
@@ -436,6 +412,15 @@ export const Dropdown = <Item extends object | null = MenuItem>({
|
||||
dispatch(fetchRelationships([prefetchAccountId]));
|
||||
}
|
||||
|
||||
if (needsStatusRefresh && statusId) {
|
||||
dispatch(
|
||||
fetchStatus(statusId, {
|
||||
forceFetch: true,
|
||||
alsoFetchContext: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (isUserTouching() && !forceDropdown) {
|
||||
dispatch(
|
||||
openModal({
|
||||
@@ -450,10 +435,11 @@ export const Dropdown = <Item extends object | null = MenuItem>({
|
||||
dispatch(
|
||||
openDropdownMenu({
|
||||
id: currentId,
|
||||
keyboard: type !== 'click',
|
||||
keyboard: isKeypressRef.current,
|
||||
scrollKey,
|
||||
}),
|
||||
);
|
||||
isKeypressRef.current = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -468,6 +454,8 @@ export const Dropdown = <Item extends object | null = MenuItem>({
|
||||
items,
|
||||
forceDropdown,
|
||||
handleClose,
|
||||
statusId,
|
||||
needsStatusRefresh,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -484,6 +472,9 @@ export const Dropdown = <Item extends object | null = MenuItem>({
|
||||
const buttonProps = {
|
||||
disabled,
|
||||
onClick: toggleDropdown,
|
||||
onKeyDown: handleKeyDown,
|
||||
onKeyUp: unsetIsKeypress,
|
||||
onBlur: unsetIsKeypress,
|
||||
'aria-expanded': open,
|
||||
'aria-controls': menuId,
|
||||
ref: buttonRef,
|
||||
|
||||
@@ -58,17 +58,7 @@ export const EditedTimestamp: React.FC<{
|
||||
}, []);
|
||||
|
||||
const renderItem = useCallback(
|
||||
(
|
||||
item: HistoryItem,
|
||||
index: number,
|
||||
{
|
||||
onClick,
|
||||
onKeyUp,
|
||||
}: {
|
||||
onClick: React.MouseEventHandler;
|
||||
onKeyUp: React.KeyboardEventHandler;
|
||||
},
|
||||
) => {
|
||||
(item: HistoryItem, index: number, onClick: React.MouseEventHandler) => {
|
||||
const formattedDate = (
|
||||
<RelativeTimestamp
|
||||
timestamp={item.get('created_at') as string}
|
||||
@@ -98,7 +88,7 @@ export const EditedTimestamp: React.FC<{
|
||||
className='dropdown-menu__item edited-timestamp__history__item'
|
||||
key={item.get('created_at') as string}
|
||||
>
|
||||
<button data-index={index} onClick={onClick} onKeyUp={onKeyUp}>
|
||||
<button data-index={index} onClick={onClick} type='button'>
|
||||
{label}
|
||||
</button>
|
||||
</li>
|
||||
|
||||
@@ -105,6 +105,7 @@ const hotkeyMatcherMap = {
|
||||
reply: just('r'),
|
||||
favourite: just('f'),
|
||||
boost: just('b'),
|
||||
bookmark: just('d'),
|
||||
quote: just('q'),
|
||||
mention: just('m'),
|
||||
open: any('enter', 'o'),
|
||||
@@ -180,25 +181,24 @@ export function useHotkeys<T extends HTMLElement>(handlers: HandlerMap) {
|
||||
|
||||
if (shouldHandleEvent) {
|
||||
const matchCandidates: {
|
||||
handler: (event: KeyboardEvent) => void;
|
||||
// A candidate will be have an undefined handler if it's matched,
|
||||
// but handled in a parent component rather than this one.
|
||||
handler: ((event: KeyboardEvent) => void) | undefined;
|
||||
priority: number;
|
||||
}[] = [];
|
||||
|
||||
(Object.keys(hotkeyMatcherMap) as HotkeyName[]).forEach(
|
||||
(handlerName) => {
|
||||
const handler = handlersRef.current[handlerName];
|
||||
const hotkeyMatcher = hotkeyMatcherMap[handlerName];
|
||||
|
||||
if (handler) {
|
||||
const hotkeyMatcher = hotkeyMatcherMap[handlerName];
|
||||
const { isMatch, priority } = hotkeyMatcher(
|
||||
event,
|
||||
bufferedKeys.current,
|
||||
);
|
||||
|
||||
const { isMatch, priority } = hotkeyMatcher(
|
||||
event,
|
||||
bufferedKeys.current,
|
||||
);
|
||||
|
||||
if (isMatch) {
|
||||
matchCandidates.push({ handler, priority });
|
||||
}
|
||||
if (isMatch) {
|
||||
matchCandidates.push({ handler, priority });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -8,13 +8,14 @@ import classNames from 'classnames';
|
||||
import { quoteComposeById } from '@/flavours/glitch/actions/compose_typed';
|
||||
import { toggleReblog } from '@/flavours/glitch/actions/interactions';
|
||||
import { openModal } from '@/flavours/glitch/actions/modal';
|
||||
import { fetchStatus } from '@/flavours/glitch/actions/statuses';
|
||||
import { quickBoosting } from '@/flavours/glitch/initial_state';
|
||||
import type { ActionMenuItem } from '@/flavours/glitch/models/dropdown_menu';
|
||||
import type { Status } from '@/flavours/glitch/models/status';
|
||||
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
|
||||
import type { SomeRequired } from '@/flavours/glitch/utils/types';
|
||||
|
||||
import type { RenderItemFn, RenderItemFnHandlers } from '../dropdown_menu';
|
||||
import type { RenderItemFn } from '../dropdown_menu';
|
||||
import { Dropdown, DropdownMenuItemContent } from '../dropdown_menu';
|
||||
import { IconButton } from '../icon_button';
|
||||
|
||||
@@ -74,18 +75,12 @@ const StandaloneBoostButton: FC<ReblogButtonProps> = ({ status, counters }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const renderMenuItem: RenderItemFn<ActionMenuItem> = (
|
||||
item,
|
||||
index,
|
||||
handlers,
|
||||
focusRefCallback,
|
||||
) => (
|
||||
const renderMenuItem: RenderItemFn<ActionMenuItem> = (item, index, onClick) => (
|
||||
<ReblogMenuItem
|
||||
index={index}
|
||||
item={item}
|
||||
handlers={handlers}
|
||||
onClick={onClick}
|
||||
key={`${item.text}-${index}`}
|
||||
focusRefCallback={focusRefCallback}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -117,6 +112,7 @@ const BoostOrQuoteMenu: FC<ReblogButtonProps> = ({ status, counters }) => {
|
||||
|
||||
const statusId = status.get('id') as string;
|
||||
const wasBoosted = !!status.get('reblogged');
|
||||
const quoteApproval = status.get('quote_approval');
|
||||
|
||||
const showLoginPrompt = useCallback(() => {
|
||||
dispatch(
|
||||
@@ -173,9 +169,16 @@ const BoostOrQuoteMenu: FC<ReblogButtonProps> = ({ status, counters }) => {
|
||||
dispatch(toggleReblog(status.get('id'), true));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (quoteApproval === null) {
|
||||
dispatch(
|
||||
fetchStatus(statusId, { forceFetch: true, alsoFetchContext: false }),
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
[dispatch, isLoggedIn, showLoginPrompt, status],
|
||||
[dispatch, isLoggedIn, showLoginPrompt, status, quoteApproval, statusId],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -208,16 +211,10 @@ const BoostOrQuoteMenu: FC<ReblogButtonProps> = ({ status, counters }) => {
|
||||
interface ReblogMenuItemProps {
|
||||
item: ActionMenuItem;
|
||||
index: number;
|
||||
handlers: RenderItemFnHandlers;
|
||||
focusRefCallback?: (c: HTMLAnchorElement | HTMLButtonElement | null) => void;
|
||||
onClick: React.MouseEventHandler;
|
||||
}
|
||||
|
||||
const ReblogMenuItem: FC<ReblogMenuItemProps> = ({
|
||||
index,
|
||||
item,
|
||||
handlers,
|
||||
focusRefCallback,
|
||||
}) => {
|
||||
const ReblogMenuItem: FC<ReblogMenuItemProps> = ({ index, item, onClick }) => {
|
||||
const { text, highlighted, disabled } = item;
|
||||
|
||||
return (
|
||||
@@ -227,12 +224,7 @@ const ReblogMenuItem: FC<ReblogMenuItemProps> = ({
|
||||
})}
|
||||
key={`${text}-${index}`}
|
||||
>
|
||||
<button
|
||||
{...handlers}
|
||||
ref={focusRefCallback}
|
||||
aria-disabled={disabled}
|
||||
data-index={index}
|
||||
>
|
||||
<button onClick={onClick} aria-disabled={disabled} data-index={index}>
|
||||
<DropdownMenuItemContent item={item} />
|
||||
</button>
|
||||
</li>
|
||||
|
||||
@@ -133,16 +133,18 @@ export const HandledLink: FC<HandledLinkProps & ComponentProps<'a'>> = ({
|
||||
|
||||
// Handle hashtags
|
||||
if (
|
||||
text.startsWith('#') ||
|
||||
prevText?.endsWith('#') ||
|
||||
text.startsWith('#') ||
|
||||
prevText?.endsWith('#')
|
||||
(text.startsWith('#') ||
|
||||
prevText?.endsWith('#') ||
|
||||
text.startsWith('#') ||
|
||||
prevText?.endsWith('#')) &&
|
||||
!text.includes('%')
|
||||
) {
|
||||
const hashtag = text.slice(1).trim();
|
||||
|
||||
return (
|
||||
<Link
|
||||
className={classNames('mention hashtag', className)}
|
||||
to={`/tags/${hashtag}`}
|
||||
to={`/tags/${encodeURIComponent(hashtag)}`}
|
||||
rel='tag'
|
||||
data-menu-hashtag={hashtagAccountId}
|
||||
>
|
||||
@@ -196,7 +198,7 @@ export const HandledLink: FC<HandledLinkProps & ComponentProps<'a'>> = ({
|
||||
title={href}
|
||||
className={classNames('unhandled-link', className)}
|
||||
target='_blank'
|
||||
rel='noreferrer noopener'
|
||||
rel='noopener'
|
||||
translate='no'
|
||||
ref={linkRef}
|
||||
>
|
||||
|
||||
@@ -374,6 +374,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||
<Dropdown
|
||||
scrollKey={scrollKey}
|
||||
status={status}
|
||||
needsStatusRefresh={quickBoosting && status.get('quote_approval') === null}
|
||||
items={menu}
|
||||
icon='ellipsis-h'
|
||||
size={18}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.inlineIcon {
|
||||
vertical-align: middle;
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import { Button } from '../button';
|
||||
import { useDismissableBannerState } from '../dismissable_banner';
|
||||
import { Icon } from '../icon';
|
||||
|
||||
import classes from './remove_quote_hint.module.css';
|
||||
|
||||
const DISMISSABLE_BANNER_ID = 'notifications/remove_quote_hint';
|
||||
|
||||
/**
|
||||
@@ -93,7 +95,7 @@ export const RemoveQuoteHint: React.FC<{
|
||||
id: 'status.more',
|
||||
defaultMessage: 'More',
|
||||
})}
|
||||
style={{ verticalAlign: 'middle' }}
|
||||
className={classes.inlineIcon}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
|
||||
@@ -49,6 +49,7 @@ export const StatusBanner: React.FC<{
|
||||
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type='button'
|
||||
className='link-button'
|
||||
onClick={onClick}
|
||||
aria-describedby={descriptionId}
|
||||
|
||||
@@ -16,7 +16,7 @@ import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import { me } from 'flavours/glitch/initial_state';
|
||||
|
||||
import { Permalink } from './permalink';
|
||||
import { LinkedDisplayName } from './display_name';
|
||||
|
||||
export default class StatusPrepend extends PureComponent {
|
||||
|
||||
@@ -30,20 +30,12 @@ export default class StatusPrepend extends PureComponent {
|
||||
Message = () => {
|
||||
const { type, account } = this.props;
|
||||
let link = (
|
||||
<Permalink
|
||||
to={`/@${account.get('acct')}`}
|
||||
href={account.get('url')}
|
||||
className='status__display-name'
|
||||
data-hover-card-account={account.get('id')}
|
||||
>
|
||||
<bdi>
|
||||
<strong
|
||||
dangerouslySetInnerHTML={{
|
||||
__html : account.get('display_name_html') || account.get('username'),
|
||||
}}
|
||||
/>
|
||||
</bdi>
|
||||
</Permalink>
|
||||
<LinkedDisplayName
|
||||
displayProps={{
|
||||
account: account,
|
||||
variant: 'simple'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
switch (type) {
|
||||
case 'reblogged_by':
|
||||
|
||||
@@ -32,16 +32,38 @@ interface Rule extends BaseRule {
|
||||
translations?: Record<string, BaseRule>;
|
||||
}
|
||||
|
||||
function getDefaultSelectedLocale(
|
||||
currentUiLocale: string,
|
||||
localeOptions: SelectItem[],
|
||||
) {
|
||||
const preciseMatch = localeOptions.find(
|
||||
(option) => option.value === currentUiLocale,
|
||||
);
|
||||
if (preciseMatch) {
|
||||
return preciseMatch.value;
|
||||
}
|
||||
|
||||
const partialLocale = currentUiLocale.split('-')[0];
|
||||
const partialMatch = localeOptions.find(
|
||||
(option) => option.value.split('-')[0] === partialLocale,
|
||||
);
|
||||
|
||||
return partialMatch?.value ?? 'default';
|
||||
}
|
||||
|
||||
export const RulesSection: FC<RulesSectionProps> = ({ isLoading = false }) => {
|
||||
const intl = useIntl();
|
||||
const [locale, setLocale] = useState(intl.locale);
|
||||
const rules = useAppSelector((state) => rulesSelector(state, locale));
|
||||
const localeOptions = useAppSelector((state) =>
|
||||
localeOptionsSelector(state, intl),
|
||||
);
|
||||
const [selectedLocale, setSelectedLocale] = useState(() =>
|
||||
getDefaultSelectedLocale(intl.locale, localeOptions),
|
||||
);
|
||||
const rules = useAppSelector((state) => rulesSelector(state, selectedLocale));
|
||||
|
||||
const handleLocaleChange: ChangeEventHandler<HTMLSelectElement> = useCallback(
|
||||
(e) => {
|
||||
setLocale(e.currentTarget.value);
|
||||
setSelectedLocale(e.currentTarget.value);
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -74,25 +96,27 @@ export const RulesSection: FC<RulesSectionProps> = ({ isLoading = false }) => {
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<div className='rules-languages'>
|
||||
<label htmlFor='language-select'>
|
||||
<FormattedMessage
|
||||
id='about.language_label'
|
||||
defaultMessage='Language'
|
||||
/>
|
||||
</label>
|
||||
<select onChange={handleLocaleChange} id='language-select'>
|
||||
{localeOptions.map((option) => (
|
||||
<option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
selected={option.value === locale}
|
||||
>
|
||||
{option.text}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{localeOptions.length > 1 && (
|
||||
<div className='rules-languages'>
|
||||
<label htmlFor='language-select'>
|
||||
<FormattedMessage
|
||||
id='about.language_label'
|
||||
defaultMessage='Language'
|
||||
/>
|
||||
</label>
|
||||
<select onChange={handleLocaleChange} id='language-select'>
|
||||
{localeOptions.map((option) => (
|
||||
<option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
selected={option.value === selectedLocale}
|
||||
>
|
||||
{option.text}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
@@ -145,9 +169,13 @@ const localeOptionsSelector = createSelector(
|
||||
},
|
||||
};
|
||||
// Use the default locale as a target to translate language names.
|
||||
const intlLocale = new Intl.DisplayNames(intl.locale, {
|
||||
type: 'language',
|
||||
});
|
||||
const intlLocale =
|
||||
// Intl.DisplayNames can be undefined in old browsers
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
Intl.DisplayNames &&
|
||||
(new Intl.DisplayNames(intl.locale, {
|
||||
type: 'language',
|
||||
}) as Intl.DisplayNames | undefined);
|
||||
for (const { translations } of rules) {
|
||||
for (const locale in translations) {
|
||||
if (langs[locale]) {
|
||||
@@ -155,7 +183,7 @@ const localeOptionsSelector = createSelector(
|
||||
}
|
||||
langs[locale] = {
|
||||
value: locale,
|
||||
text: intlLocale.of(locale) ?? locale,
|
||||
text: intlLocale?.of(locale) ?? locale,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,7 +330,7 @@ export const AltTextModal = forwardRef<ModalRef, Props & Partial<RestoreProps>>(
|
||||
});
|
||||
}, [dispatch, setIsSaving, mediaId, onClose, position, description]);
|
||||
|
||||
const handleKeyUp = useCallback(
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
@@ -457,7 +457,7 @@ export const AltTextModal = forwardRef<ModalRef, Props & Partial<RestoreProps>>(
|
||||
id='description'
|
||||
value={isDetecting ? ' ' : description}
|
||||
onChange={handleDescriptionChange}
|
||||
onKeyUp={handleKeyUp}
|
||||
onKeyDown={handleKeyDown}
|
||||
lang={lang}
|
||||
placeholder={intl.formatMessage(
|
||||
type === 'audio'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useCallback, useState, useId } from 'react';
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
@@ -22,6 +22,8 @@ import { useAudioVisualizer } from 'flavours/glitch/hooks/useAudioVisualizer';
|
||||
import { displayMedia, useBlurhash } from 'flavours/glitch/initial_state';
|
||||
import { playerSettings } from 'flavours/glitch/settings';
|
||||
|
||||
import { AudioVisualizer } from './visualizer';
|
||||
|
||||
const messages = defineMessages({
|
||||
play: { id: 'video.play', defaultMessage: 'Play' },
|
||||
pause: { id: 'video.pause', defaultMessage: 'Pause' },
|
||||
@@ -116,7 +118,6 @@ export const Audio: React.FC<{
|
||||
const seekRef = useRef<HTMLDivElement>(null);
|
||||
const volumeRef = useRef<HTMLDivElement>(null);
|
||||
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>();
|
||||
const accessibilityId = useId();
|
||||
|
||||
const { audioContextRef, sourceRef, gainNodeRef, playAudio, pauseAudio } =
|
||||
useAudioContext({ audioElementRef: audioRef });
|
||||
@@ -538,19 +539,6 @@ export const Audio: React.FC<{
|
||||
[togglePlay, toggleMute],
|
||||
);
|
||||
|
||||
const springForBand0 = useSpring({
|
||||
to: { r: 50 + (frequencyBands[0] ?? 0) * 10 },
|
||||
config: config.wobbly,
|
||||
});
|
||||
const springForBand1 = useSpring({
|
||||
to: { r: 50 + (frequencyBands[1] ?? 0) * 10 },
|
||||
config: config.wobbly,
|
||||
});
|
||||
const springForBand2 = useSpring({
|
||||
to: { r: 50 + (frequencyBands[2] ?? 0) * 10 },
|
||||
config: config.wobbly,
|
||||
});
|
||||
|
||||
const progress = Math.min((currentTime / loadedDuration) * 100, 100);
|
||||
const effectivelyMuted = muted || volume === 0;
|
||||
|
||||
@@ -641,81 +629,7 @@ export const Audio: React.FC<{
|
||||
</div>
|
||||
|
||||
<div className='audio-player__controls__play'>
|
||||
<svg
|
||||
className='audio-player__visualizer'
|
||||
viewBox='0 0 124 124'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<animated.circle
|
||||
opacity={0.5}
|
||||
cx={57}
|
||||
cy={62.5}
|
||||
r={springForBand0.r}
|
||||
fill='var(--player-accent-color)'
|
||||
/>
|
||||
<animated.circle
|
||||
opacity={0.5}
|
||||
cx={65}
|
||||
cy={57.5}
|
||||
r={springForBand1.r}
|
||||
fill='var(--player-accent-color)'
|
||||
/>
|
||||
<animated.circle
|
||||
opacity={0.5}
|
||||
cx={63}
|
||||
cy={66.5}
|
||||
r={springForBand2.r}
|
||||
fill='var(--player-accent-color)'
|
||||
/>
|
||||
|
||||
<g clipPath={`url(#${accessibilityId}-clip)`}>
|
||||
<rect
|
||||
x={14}
|
||||
y={14}
|
||||
width={96}
|
||||
height={96}
|
||||
fill={`url(#${accessibilityId}-pattern)`}
|
||||
/>
|
||||
<rect
|
||||
x={14}
|
||||
y={14}
|
||||
width={96}
|
||||
height={96}
|
||||
fill='var(--player-background-color'
|
||||
opacity={0.45}
|
||||
/>
|
||||
</g>
|
||||
|
||||
<defs>
|
||||
<pattern
|
||||
id={`${accessibilityId}-pattern`}
|
||||
patternContentUnits='objectBoundingBox'
|
||||
width='1'
|
||||
height='1'
|
||||
>
|
||||
<use href={`#${accessibilityId}-image`} />
|
||||
</pattern>
|
||||
|
||||
<clipPath id={`${accessibilityId}-clip`}>
|
||||
<rect
|
||||
x={14}
|
||||
y={14}
|
||||
width={96}
|
||||
height={96}
|
||||
rx={48}
|
||||
fill='white'
|
||||
/>
|
||||
</clipPath>
|
||||
|
||||
<image
|
||||
id={`${accessibilityId}-image`}
|
||||
href={poster}
|
||||
width={1}
|
||||
height={1}
|
||||
preserveAspectRatio='none'
|
||||
/>
|
||||
</defs>
|
||||
</svg>
|
||||
<AudioVisualizer frequencyBands={frequencyBands} poster={poster} />
|
||||
|
||||
<button
|
||||
type='button'
|
||||
|
||||
100
app/javascript/flavours/glitch/features/audio/visualizer.tsx
Normal file
100
app/javascript/flavours/glitch/features/audio/visualizer.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useId } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { animated, config, useSpring } from '@react-spring/web';
|
||||
|
||||
interface AudioVisualizerProps {
|
||||
frequencyBands?: number[];
|
||||
poster?: string;
|
||||
}
|
||||
|
||||
export const AudioVisualizer: FC<AudioVisualizerProps> = ({
|
||||
frequencyBands = [],
|
||||
poster,
|
||||
}) => {
|
||||
const accessibilityId = useId();
|
||||
|
||||
const springForBand0 = useSpring({
|
||||
to: { r: 50 + (frequencyBands[0] ?? 0) * 10 },
|
||||
config: config.wobbly,
|
||||
});
|
||||
const springForBand1 = useSpring({
|
||||
to: { r: 50 + (frequencyBands[1] ?? 0) * 10 },
|
||||
config: config.wobbly,
|
||||
});
|
||||
const springForBand2 = useSpring({
|
||||
to: { r: 50 + (frequencyBands[2] ?? 0) * 10 },
|
||||
config: config.wobbly,
|
||||
});
|
||||
|
||||
return (
|
||||
<svg
|
||||
className='audio-player__visualizer'
|
||||
viewBox='0 0 124 124'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<animated.circle
|
||||
opacity={0.5}
|
||||
cx={57}
|
||||
cy={62.5}
|
||||
r={springForBand0.r}
|
||||
fill='var(--player-accent-color)'
|
||||
/>
|
||||
<animated.circle
|
||||
opacity={0.5}
|
||||
cx={65}
|
||||
cy={57.5}
|
||||
r={springForBand1.r}
|
||||
fill='var(--player-accent-color)'
|
||||
/>
|
||||
<animated.circle
|
||||
opacity={0.5}
|
||||
cx={63}
|
||||
cy={66.5}
|
||||
r={springForBand2.r}
|
||||
fill='var(--player-accent-color)'
|
||||
/>
|
||||
|
||||
<g clipPath={`url(#${accessibilityId}-clip)`}>
|
||||
<rect
|
||||
x={14}
|
||||
y={14}
|
||||
width={96}
|
||||
height={96}
|
||||
fill={`url(#${accessibilityId}-pattern)`}
|
||||
/>
|
||||
<rect
|
||||
x={14}
|
||||
y={14}
|
||||
width={96}
|
||||
height={96}
|
||||
fill='var(--player-background-color'
|
||||
opacity={0.45}
|
||||
/>
|
||||
</g>
|
||||
|
||||
<defs>
|
||||
<pattern
|
||||
id={`${accessibilityId}-pattern`}
|
||||
patternContentUnits='objectBoundingBox'
|
||||
width='1'
|
||||
height='1'
|
||||
>
|
||||
<use href={`#${accessibilityId}-image`} />
|
||||
</pattern>
|
||||
|
||||
<clipPath id={`${accessibilityId}-clip`}>
|
||||
<rect x={14} y={14} width={96} height={96} rx={48} fill='white' />
|
||||
</clipPath>
|
||||
|
||||
<image
|
||||
id={`${accessibilityId}-image`}
|
||||
href={poster}
|
||||
width={1}
|
||||
height={1}
|
||||
preserveAspectRatio='none'
|
||||
/>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -110,10 +110,12 @@ class ComposeForm extends ImmutablePureComponent {
|
||||
handleKeyDownPost = (e) => {
|
||||
if (e.key.toLowerCase() === 'enter' && (e.ctrlKey || e.metaKey)) {
|
||||
this.handleSubmit(e);
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
if (e.key.toLowerCase() === 'enter' && e.altKey) {
|
||||
this.handleSecondarySubmit(e);
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
this.blurOnEscape(e);
|
||||
@@ -129,20 +131,19 @@ class ComposeForm extends ImmutablePureComponent {
|
||||
e.preventDefault();
|
||||
this.textareaRef.current?.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
this.blurOnEscape(e);
|
||||
}
|
||||
};
|
||||
|
||||
getFulltextForCharacterCounting = () => {
|
||||
return [this.props.spoiler? this.props.spoilerText: '', countableText(this.props.text)].join('');
|
||||
};
|
||||
|
||||
canSubmit = () => {
|
||||
const { isSubmitting, isChangingUpload, isUploading, anyMedia, maxChars } = this.props;
|
||||
const { isSubmitting, isChangingUpload, isUploading, maxChars } = this.props;
|
||||
const fulltext = this.getFulltextForCharacterCounting();
|
||||
const isOnlyWhitespace = fulltext.length !== 0 && fulltext.trim().length === 0;
|
||||
|
||||
return !(isSubmitting || isUploading || isChangingUpload || length(fulltext) > maxChars || (isOnlyWhitespace && !anyMedia));
|
||||
return !(isSubmitting || isUploading || isChangingUpload || length(fulltext) > maxChars);
|
||||
};
|
||||
|
||||
handleSubmit = (e, overridePrivacy = null) => {
|
||||
@@ -156,7 +157,11 @@ class ComposeForm extends ImmutablePureComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.onSubmit(missingAltTextModal && this.props.missingAltText && this.props.privacy !== 'direct', overridePrivacy);
|
||||
this.props.onSubmit({
|
||||
missingAltText: missingAltTextModal && this.props.missingAltText && this.props.privacy !== 'direct',
|
||||
quoteToPrivate: this.props.quoteToPrivate,
|
||||
overridePrivacy,
|
||||
});
|
||||
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useCallback } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { cancelPasteLinkCompose } from '@/flavours/glitch/actions/compose_typed';
|
||||
import { useAppDispatch } from '@/flavours/glitch/store';
|
||||
import CancelFillIcon from '@/material-icons/400-24px/cancel-fill.svg?react';
|
||||
import { DisplayName } from 'flavours/glitch/components/display_name';
|
||||
import { IconButton } from 'flavours/glitch/components/icon_button';
|
||||
import { Skeleton } from 'flavours/glitch/components/skeleton';
|
||||
|
||||
const messages = defineMessages({
|
||||
quote_cancel: { id: 'status.quote.cancel', defaultMessage: 'Cancel quote' },
|
||||
});
|
||||
|
||||
export const QuotePlaceholder: FC = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const handleQuoteCancel = useCallback(() => {
|
||||
dispatch(cancelPasteLinkCompose());
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<div className='status__quote'>
|
||||
<div className='status'>
|
||||
<div className='status__info'>
|
||||
<div className='status__avatar'>
|
||||
<Skeleton width='32px' height='32px' />
|
||||
</div>
|
||||
<div className='status__display-name'>
|
||||
<DisplayName />
|
||||
</div>
|
||||
<IconButton
|
||||
onClick={handleQuoteCancel}
|
||||
className='status__quote-cancel'
|
||||
title={intl.formatMessage(messages.quote_cancel)}
|
||||
icon='cancel-fill'
|
||||
iconComponent={CancelFillIcon}
|
||||
/>
|
||||
</div>
|
||||
<div className='status__content'>
|
||||
<Skeleton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -7,11 +7,17 @@ import { quoteComposeCancel } from '@/flavours/glitch/actions/compose_typed';
|
||||
import { QuotedStatus } from '@/flavours/glitch/components/status_quoted';
|
||||
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
|
||||
|
||||
import { QuotePlaceholder } from './quote_placeholder';
|
||||
|
||||
export const ComposeQuotedStatus: FC = () => {
|
||||
const quotedStatusId = useAppSelector(
|
||||
(state) => state.compose.get('quoted_status_id') as string | null,
|
||||
);
|
||||
|
||||
const isFetchingLink = useAppSelector(
|
||||
(state) => !!state.compose.get('fetching_link'),
|
||||
);
|
||||
|
||||
const isEditing = useAppSelector((state) => !!state.compose.get('id'));
|
||||
|
||||
const quote = useMemo(
|
||||
@@ -30,7 +36,9 @@ export const ComposeQuotedStatus: FC = () => {
|
||||
dispatch(quoteComposeCancel());
|
||||
}, [dispatch]);
|
||||
|
||||
if (!quote) {
|
||||
if (isFetchingLink && !quote) {
|
||||
return <QuotePlaceholder />;
|
||||
} else if (!quote) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
import CloseIcon from '@/material-icons/400-20px/close.svg?react';
|
||||
import SoundIcon from '@/material-icons/400-24px/audio.svg?react';
|
||||
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
|
||||
import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
|
||||
import { undoUploadCompose } from 'flavours/glitch/actions/compose';
|
||||
@@ -17,7 +18,18 @@ import { openModal } from 'flavours/glitch/actions/modal';
|
||||
import { Blurhash } from 'flavours/glitch/components/blurhash';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import type { MediaAttachment } from 'flavours/glitch/models/media_attachment';
|
||||
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
||||
import {
|
||||
createAppSelector,
|
||||
useAppDispatch,
|
||||
useAppSelector,
|
||||
} from 'flavours/glitch/store';
|
||||
|
||||
import { AudioVisualizer } from '../../audio/visualizer';
|
||||
|
||||
const selectUserAvatar = createAppSelector(
|
||||
[(state) => state.accounts, (state) => state.meta.get('me') as string],
|
||||
(accounts, myId) => accounts.get(myId)?.avatar_static,
|
||||
);
|
||||
|
||||
export const Upload: React.FC<{
|
||||
id: string;
|
||||
@@ -38,6 +50,7 @@ export const Upload: React.FC<{
|
||||
const sensitive = useAppSelector(
|
||||
(state) => state.compose.get('sensitive') as boolean,
|
||||
);
|
||||
const userAvatar = useAppSelector(selectUserAvatar);
|
||||
|
||||
const handleUndoClick = useCallback(() => {
|
||||
dispatch(undoUploadCompose(id));
|
||||
@@ -67,6 +80,8 @@ export const Upload: React.FC<{
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
const preview_url = media.get('preview_url') as string | null;
|
||||
const blurhash = media.get('blurhash') as string | null;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -85,17 +100,19 @@ export const Upload: React.FC<{
|
||||
<div
|
||||
className='compose-form__upload__thumbnail'
|
||||
style={{
|
||||
backgroundImage: !sensitive
|
||||
? `url(${media.get('preview_url') as string})`
|
||||
: undefined,
|
||||
backgroundImage:
|
||||
!sensitive && preview_url ? `url(${preview_url})` : undefined,
|
||||
backgroundPosition: `${x}% ${y}%`,
|
||||
}}
|
||||
>
|
||||
{sensitive && (
|
||||
<Blurhash
|
||||
hash={media.get('blurhash') as string}
|
||||
className='compose-form__upload__preview'
|
||||
/>
|
||||
{sensitive && blurhash && (
|
||||
<Blurhash hash={blurhash} className='compose-form__upload__preview' />
|
||||
)}
|
||||
{!sensitive && !preview_url && (
|
||||
<div className='compose-form__upload__visualizer'>
|
||||
<AudioVisualizer poster={userAvatar} />
|
||||
<Icon id='sound' icon={SoundIcon} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='compose-form__upload__actions'>
|
||||
|
||||
@@ -5,8 +5,10 @@ import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { changeComposeVisibility } from '@/flavours/glitch/actions/compose';
|
||||
import { setComposeQuotePolicy } from '@/flavours/glitch/actions/compose_typed';
|
||||
import {
|
||||
changeComposeVisibility,
|
||||
setComposeQuotePolicy,
|
||||
} from '@/flavours/glitch/actions/compose_typed';
|
||||
import { openModal } from '@/flavours/glitch/actions/modal';
|
||||
import type { ApiQuotePolicy } from '@/flavours/glitch/api_types/quotes';
|
||||
import type { StatusVisibility } from '@/flavours/glitch/api_types/statuses';
|
||||
|
||||
@@ -31,7 +31,7 @@ export const Warning = () => {
|
||||
defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.'
|
||||
values={{
|
||||
locked: (
|
||||
<a href='/settings/profile'>
|
||||
<a href='/settings/privacy#account_unlocked'>
|
||||
<FormattedMessage
|
||||
id='compose_form.lock_disclaimer.lock'
|
||||
defaultMessage='locked'
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
} from 'flavours/glitch/actions/compose';
|
||||
import { pasteLinkCompose } from 'flavours/glitch/actions/compose_typed';
|
||||
import { openModal } from 'flavours/glitch/actions/modal';
|
||||
import { PRIVATE_QUOTE_MODAL_ID } from 'flavours/glitch/features/ui/components/confirmation_modals/private_quote_notify';
|
||||
import { me } from 'flavours/glitch/initial_state';
|
||||
import { privacyPreference } from 'flavours/glitch/utils/privacy_preference';
|
||||
|
||||
import ComposeForm from '../components/compose_form';
|
||||
@@ -52,6 +54,11 @@ const mapStateToProps = state => ({
|
||||
isUploading: state.getIn(['compose', 'is_uploading']),
|
||||
anyMedia: state.getIn(['compose', 'media_attachments']).size > 0,
|
||||
missingAltText: state.getIn(['compose', 'media_attachments']).some(media => ['image', 'gifv'].includes(media.get('type')) && (media.get('description') ?? '').length === 0),
|
||||
quoteToPrivate:
|
||||
!!state.getIn(['compose', 'quoted_status_id'])
|
||||
&& state.getIn(['compose', 'privacy']) === 'private'
|
||||
&& state.getIn(['statuses', state.getIn(['compose', 'quoted_status_id']), 'account']) !== me
|
||||
&& !state.getIn(['settings', 'dismissed_banners', PRIVATE_QUOTE_MODAL_ID]),
|
||||
isInReply: state.getIn(['compose', 'in_reply_to']) !== null,
|
||||
lang: state.getIn(['compose', 'language']),
|
||||
sideArm: sideArmPrivacy(state),
|
||||
@@ -65,12 +72,17 @@ const mapDispatchToProps = (dispatch, props) => ({
|
||||
dispatch(changeCompose(text));
|
||||
},
|
||||
|
||||
onSubmit (missingAltText, overridePrivacy = null) {
|
||||
onSubmit ({ missingAltText, quoteToPrivate, overridePrivacy = null }) {
|
||||
if (missingAltText) {
|
||||
dispatch(openModal({
|
||||
modalType: 'CONFIRM_MISSING_ALT_TEXT',
|
||||
modalProps: { overridePrivacy },
|
||||
}));
|
||||
} else if (quoteToPrivate) {
|
||||
dispatch(openModal({
|
||||
modalType: 'CONFIRM_PRIVATE_QUOTE_NOTIFY',
|
||||
modalProps: {},
|
||||
}));
|
||||
} else {
|
||||
dispatch(submitCompose(overridePrivacy, (status) => {
|
||||
if (props.redirectOnSuccess) {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { changeComposeVisibility } from '../../../actions/compose';
|
||||
import { openModal, closeModal } from '../../../actions/modal';
|
||||
import { isUserTouching } from '../../../is_mobile';
|
||||
import { changeComposeVisibility } from '@/flavours/glitch/actions/compose_typed';
|
||||
|
||||
import PrivacyDropdown from '../components/privacy_dropdown';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { initialState } from '@/flavours/glitch/initial_state';
|
||||
|
||||
import { toSupportedLocale } from './locale';
|
||||
import type { LocaleOrCustom } from './types';
|
||||
import { emojiLogger } from './utils';
|
||||
// eslint-disable-next-line import/default -- Importing via worker loader.
|
||||
import EmojiWorker from './worker?worker&inline';
|
||||
@@ -24,19 +25,17 @@ export function initializeEmoji() {
|
||||
}
|
||||
|
||||
if (worker) {
|
||||
// Assign worker to const to make TS happy inside the event listener.
|
||||
const thisWorker = worker;
|
||||
const timeoutId = setTimeout(() => {
|
||||
log('worker is not ready after timeout');
|
||||
worker = null;
|
||||
void fallbackLoad();
|
||||
}, WORKER_TIMEOUT);
|
||||
thisWorker.addEventListener('message', (event: MessageEvent<string>) => {
|
||||
worker.addEventListener('message', (event: MessageEvent<string>) => {
|
||||
const { data: message } = event;
|
||||
if (message === 'ready') {
|
||||
log('worker ready, loading data');
|
||||
clearTimeout(timeoutId);
|
||||
thisWorker.postMessage('custom');
|
||||
messageWorker('custom');
|
||||
void loadEmojiLocale(userLocale);
|
||||
// Load English locale as well, because people are still used to
|
||||
// using it from before we supported other locales.
|
||||
@@ -55,20 +54,35 @@ export function initializeEmoji() {
|
||||
async function fallbackLoad() {
|
||||
log('falling back to main thread for loading');
|
||||
const { importCustomEmojiData } = await import('./loader');
|
||||
await importCustomEmojiData();
|
||||
const emojis = await importCustomEmojiData();
|
||||
if (emojis) {
|
||||
log('loaded %d custom emojis', emojis.length);
|
||||
}
|
||||
await loadEmojiLocale(userLocale);
|
||||
if (userLocale !== 'en') {
|
||||
await loadEmojiLocale('en');
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadEmojiLocale(localeString: string) {
|
||||
async function loadEmojiLocale(localeString: string) {
|
||||
const locale = toSupportedLocale(localeString);
|
||||
const { importEmojiData, localeToPath } = await import('./loader');
|
||||
|
||||
if (worker) {
|
||||
worker.postMessage(locale);
|
||||
const path = await localeToPath(locale);
|
||||
log('asking worker to load locale %s from %s', locale, path);
|
||||
messageWorker(locale, path);
|
||||
} else {
|
||||
const { importEmojiData } = await import('./loader');
|
||||
await importEmojiData(locale);
|
||||
const emojis = await importEmojiData(locale);
|
||||
if (emojis) {
|
||||
log('loaded %d emojis to locale %s', emojis.length, locale);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function messageWorker(locale: LocaleOrCustom, path?: string) {
|
||||
if (!worker) {
|
||||
return;
|
||||
}
|
||||
worker.postMessage({ locale, path });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { flattenEmojiData } from 'emojibase';
|
||||
import type { CompactEmoji, FlatCompactEmoji } from 'emojibase';
|
||||
import type { CompactEmoji, FlatCompactEmoji, Locale } from 'emojibase';
|
||||
|
||||
import {
|
||||
putEmojiData,
|
||||
@@ -8,45 +8,64 @@ import {
|
||||
putLatestEtag,
|
||||
} from './database';
|
||||
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale';
|
||||
import type { CustomEmojiData, LocaleOrCustom } from './types';
|
||||
import { emojiLogger } from './utils';
|
||||
import type { CustomEmojiData } from './types';
|
||||
|
||||
const log = emojiLogger('loader');
|
||||
|
||||
export async function importEmojiData(localeString: string) {
|
||||
export async function importEmojiData(localeString: string, path?: string) {
|
||||
const locale = toSupportedLocale(localeString);
|
||||
const emojis = await fetchAndCheckEtag<CompactEmoji[]>(locale);
|
||||
|
||||
// Validate the provided path.
|
||||
if (path && !/^[/a-z]*\/packs\/assets\/compact-\w+\.json$/.test(path)) {
|
||||
throw new Error('Invalid path for emoji data');
|
||||
} else {
|
||||
// Otherwise get the path if not provided.
|
||||
path ??= await localeToPath(locale);
|
||||
}
|
||||
|
||||
const emojis = await fetchAndCheckEtag<CompactEmoji[]>(locale, path);
|
||||
if (!emojis) {
|
||||
return;
|
||||
}
|
||||
const flattenedEmojis: FlatCompactEmoji[] = flattenEmojiData(emojis);
|
||||
log('loaded %d for %s locale', flattenedEmojis.length, locale);
|
||||
await putEmojiData(flattenedEmojis, locale);
|
||||
return flattenedEmojis;
|
||||
}
|
||||
|
||||
export async function importCustomEmojiData() {
|
||||
const emojis = await fetchAndCheckEtag<CustomEmojiData[]>('custom');
|
||||
const emojis = await fetchAndCheckEtag<CustomEmojiData[]>(
|
||||
'custom',
|
||||
'/api/v1/custom_emojis',
|
||||
);
|
||||
if (!emojis) {
|
||||
return;
|
||||
}
|
||||
log('loaded %d custom emojis', emojis.length);
|
||||
await putCustomEmojiData(emojis);
|
||||
return emojis;
|
||||
}
|
||||
|
||||
async function fetchAndCheckEtag<ResultType extends object[]>(
|
||||
localeOrCustom: LocaleOrCustom,
|
||||
const modules = import.meta.glob<string>(
|
||||
'../../../../../../node_modules/emojibase-data/**/compact.json',
|
||||
{
|
||||
query: '?url',
|
||||
import: 'default',
|
||||
},
|
||||
);
|
||||
|
||||
export function localeToPath(locale: Locale) {
|
||||
const key = `../../../../../../node_modules/emojibase-data/${locale}/compact.json`;
|
||||
if (!modules[key] || typeof modules[key] !== 'function') {
|
||||
throw new Error(`Unsupported locale: ${locale}`);
|
||||
}
|
||||
return modules[key]();
|
||||
}
|
||||
|
||||
export async function fetchAndCheckEtag<ResultType extends object[]>(
|
||||
localeString: string,
|
||||
path: string,
|
||||
): Promise<ResultType | null> {
|
||||
const locale = toSupportedLocaleOrCustom(localeOrCustom);
|
||||
const locale = toSupportedLocaleOrCustom(localeString);
|
||||
|
||||
// Use location.origin as this script may be loaded from a CDN domain.
|
||||
const url = new URL(location.origin);
|
||||
if (locale === 'custom') {
|
||||
url.pathname = '/api/v1/custom_emojis';
|
||||
} else {
|
||||
// This doesn't use isDevelopment() as that module loads initial state
|
||||
// which breaks workers, as they cannot access the DOM.
|
||||
url.pathname = `/packs${import.meta.env.DEV ? '-dev' : ''}/emoji/${locale}.json`;
|
||||
}
|
||||
const url = new URL(path, location.origin);
|
||||
|
||||
const oldEtag = await loadLatestEtag(locale);
|
||||
const response = await fetch(url, {
|
||||
@@ -61,21 +80,19 @@ async function fetchAndCheckEtag<ResultType extends object[]>(
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch emoji data for ${localeOrCustom}: ${response.statusText}`,
|
||||
`Failed to fetch emoji data for ${locale}: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as ResultType;
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error(
|
||||
`Unexpected data format for ${localeOrCustom}: expected an array`,
|
||||
);
|
||||
throw new Error(`Unexpected data format for ${locale}: expected an array`);
|
||||
}
|
||||
|
||||
// Store the ETag for future requests
|
||||
const etag = response.headers.get('ETag');
|
||||
if (etag) {
|
||||
await putLatestEtag(etag, localeOrCustom);
|
||||
await putLatestEtag(etag, localeString);
|
||||
}
|
||||
|
||||
return data;
|
||||
|
||||
@@ -33,6 +33,7 @@ describe('emojiToUnicodeHex', () => {
|
||||
['⚫', '26AB'],
|
||||
['🖤', '1F5A4'],
|
||||
['💀', '1F480'],
|
||||
['❤️', '2764'], // Checks for trailing variation selector removal.
|
||||
['💂♂️', '1F482-200D-2642-FE0F'],
|
||||
] as const)(
|
||||
'emojiToUnicodeHex converts %s to %s',
|
||||
|
||||
@@ -30,6 +30,12 @@ export function emojiToUnicodeHex(emoji: string): string {
|
||||
codes.push(code);
|
||||
}
|
||||
}
|
||||
|
||||
// Handles how Emojibase removes the variation selector for single code emojis.
|
||||
// See: https://emojibase.dev/docs/spec/#merged-variation-selectors
|
||||
if (codes.at(1) === VARIATION_SELECTOR_CODE && codes.length === 2) {
|
||||
codes.pop();
|
||||
}
|
||||
return hexNumbersToString(codes);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
stringToEmojiState,
|
||||
tokenizeText,
|
||||
} from './render';
|
||||
import type { EmojiStateCustom } from './types';
|
||||
|
||||
describe('tokenizeText', () => {
|
||||
test('returns an array of text to be a single token', () => {
|
||||
@@ -82,12 +83,8 @@ describe('stringToEmojiState', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('returns custom emoji state for valid custom emoji', () => {
|
||||
expect(stringToEmojiState(':smile:')).toEqual({
|
||||
type: 'custom',
|
||||
code: 'smile',
|
||||
data: undefined,
|
||||
});
|
||||
test('returns null for custom emoji without data', () => {
|
||||
expect(stringToEmojiState(':smile:')).toBeNull();
|
||||
});
|
||||
|
||||
test('returns custom emoji state with data when provided', () => {
|
||||
@@ -107,7 +104,6 @@ describe('stringToEmojiState', () => {
|
||||
|
||||
test('returns null for invalid emoji strings', () => {
|
||||
expect(stringToEmojiState('notanemoji')).toBeNull();
|
||||
expect(stringToEmojiState(':invalid-emoji:')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -130,18 +126,13 @@ describe('loadEmojiDataToState', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('loads custom emoji data into state', async () => {
|
||||
const dbCall = vi
|
||||
.spyOn(db, 'loadCustomEmojiByShortcode')
|
||||
.mockResolvedValueOnce(customEmojiFactory());
|
||||
const customState = { type: 'custom', code: 'smile' } as const;
|
||||
const result = await loadEmojiDataToState(customState, 'en');
|
||||
expect(dbCall).toHaveBeenCalledWith('smile');
|
||||
expect(result).toEqual({
|
||||
test('returns null for custom emoji without data', async () => {
|
||||
const customState = {
|
||||
type: 'custom',
|
||||
code: 'smile',
|
||||
data: customEmojiFactory(),
|
||||
});
|
||||
} as const satisfies EmojiStateCustom;
|
||||
const result = await loadEmojiDataToState(customState, 'en');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null if unicode emoji not found in database', async () => {
|
||||
@@ -151,18 +142,11 @@ describe('loadEmojiDataToState', () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null if custom emoji not found in database', async () => {
|
||||
vi.spyOn(db, 'loadCustomEmojiByShortcode').mockResolvedValueOnce(undefined);
|
||||
const customState = { type: 'custom', code: 'smile' } as const;
|
||||
const result = await loadEmojiDataToState(customState, 'en');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('retries loading emoji data once if initial load fails', async () => {
|
||||
const dbCall = vi
|
||||
.spyOn(db, 'loadEmojiByHexcode')
|
||||
.mockRejectedValue(new db.LocaleNotLoadedError('en'));
|
||||
vi.spyOn(loader, 'importEmojiData').mockResolvedValueOnce();
|
||||
vi.spyOn(loader, 'importEmojiData').mockResolvedValueOnce(undefined);
|
||||
const consoleCall = vi
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementationOnce(() => null);
|
||||
|
||||
@@ -4,11 +4,7 @@ import {
|
||||
EMOJI_TYPE_UNICODE,
|
||||
EMOJI_TYPE_CUSTOM,
|
||||
} from './constants';
|
||||
import {
|
||||
loadCustomEmojiByShortcode,
|
||||
loadEmojiByHexcode,
|
||||
LocaleNotLoadedError,
|
||||
} from './database';
|
||||
import { loadEmojiByHexcode, LocaleNotLoadedError } from './database';
|
||||
import { importEmojiData } from './loader';
|
||||
import { emojiToUnicodeHex } from './normalize';
|
||||
import type {
|
||||
@@ -79,7 +75,7 @@ export function tokenizeText(text: string): TokenizedText {
|
||||
export function stringToEmojiState(
|
||||
code: string,
|
||||
customEmoji: ExtraCustomEmojiMap = {},
|
||||
): EmojiState | null {
|
||||
): EmojiStateUnicode | Required<EmojiStateCustom> | null {
|
||||
if (isUnicodeEmoji(code)) {
|
||||
return {
|
||||
type: EMOJI_TYPE_UNICODE,
|
||||
@@ -89,11 +85,13 @@ export function stringToEmojiState(
|
||||
|
||||
if (isCustomEmoji(code)) {
|
||||
const shortCode = code.slice(1, -1);
|
||||
return {
|
||||
type: EMOJI_TYPE_CUSTOM,
|
||||
code: shortCode,
|
||||
data: customEmoji[shortCode],
|
||||
};
|
||||
if (customEmoji[shortCode]) {
|
||||
return {
|
||||
type: EMOJI_TYPE_CUSTOM,
|
||||
code: shortCode,
|
||||
data: customEmoji[shortCode],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -114,26 +112,23 @@ export async function loadEmojiDataToState(
|
||||
return state;
|
||||
}
|
||||
|
||||
// Don't try to load data for custom emoji.
|
||||
if (state.type === EMOJI_TYPE_CUSTOM) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// First, try to load the data from IndexedDB.
|
||||
try {
|
||||
// This is duplicative, but that's because TS can't distinguish the state type easily.
|
||||
if (state.type === EMOJI_TYPE_UNICODE) {
|
||||
const data = await loadEmojiByHexcode(state.code, locale);
|
||||
if (data) {
|
||||
return {
|
||||
...state,
|
||||
data,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const data = await loadCustomEmojiByShortcode(state.code);
|
||||
if (data) {
|
||||
return {
|
||||
...state,
|
||||
data,
|
||||
};
|
||||
}
|
||||
const data = await loadEmojiByHexcode(state.code, locale);
|
||||
if (data) {
|
||||
return {
|
||||
...state,
|
||||
type: EMOJI_TYPE_UNICODE,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
// If not found, assume it's not an emoji and return null.
|
||||
log(
|
||||
'Could not find emoji %s of type %s for locale %s',
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import { importEmojiData, importCustomEmojiData } from './loader';
|
||||
import { importCustomEmojiData, importEmojiData } from './loader';
|
||||
|
||||
addEventListener('message', handleMessage);
|
||||
self.postMessage('ready'); // After the worker is ready, notify the main thread
|
||||
|
||||
function handleMessage(event: MessageEvent<string>) {
|
||||
const { data: locale } = event;
|
||||
void loadData(locale);
|
||||
function handleMessage(event: MessageEvent<{ locale: string; path?: string }>) {
|
||||
const {
|
||||
data: { locale, path },
|
||||
} = event;
|
||||
void loadData(locale, path);
|
||||
}
|
||||
|
||||
async function loadData(locale: string) {
|
||||
if (locale !== 'custom') {
|
||||
await importEmojiData(locale);
|
||||
async function loadData(locale: string, path?: string) {
|
||||
let importCount: number | undefined;
|
||||
if (locale === 'custom') {
|
||||
importCount = (await importCustomEmojiData())?.length;
|
||||
} else if (path) {
|
||||
importCount = (await importEmojiData(locale, path))?.length;
|
||||
} else {
|
||||
await importCustomEmojiData();
|
||||
throw new Error('Path is required for loading locale emoji data');
|
||||
}
|
||||
if (importCount) {
|
||||
self.postMessage(`loaded ${importCount} emojis into ${locale}`);
|
||||
}
|
||||
self.postMessage(`loaded ${locale}`);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { FormattedDate, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { dismissAnnouncement } from '@/flavours/glitch/actions/announcements';
|
||||
import type { ApiAnnouncementJSON } from '@/flavours/glitch/api_types/announcements';
|
||||
import { AnimateEmojiProvider } from '@/flavours/glitch/components/emoji/context';
|
||||
import { EmojiHTML } from '@/flavours/glitch/components/emoji/html';
|
||||
import { useAppDispatch } from '@/flavours/glitch/store';
|
||||
|
||||
import { ReactionsBar } from './reactions';
|
||||
|
||||
@@ -22,13 +24,23 @@ export const Announcement: FC<AnnouncementProps> = ({
|
||||
announcement,
|
||||
selected,
|
||||
}) => {
|
||||
const [unread, setUnread] = useState(!announcement.read);
|
||||
const { read, id } = announcement;
|
||||
|
||||
// Dismiss announcement when it becomes active.
|
||||
const dispatch = useAppDispatch();
|
||||
useEffect(() => {
|
||||
// Only update `unread` marker once the announcement is out of view
|
||||
if (!selected && unread !== !announcement.read) {
|
||||
setUnread(!announcement.read);
|
||||
if (selected && !read) {
|
||||
dispatch(dismissAnnouncement(id));
|
||||
}
|
||||
}, [announcement.read, selected, unread]);
|
||||
}, [selected, id, dispatch, read]);
|
||||
|
||||
// But visually show the announcement as read only when it goes out of view.
|
||||
const [unread, setUnread] = useState(!read);
|
||||
useEffect(() => {
|
||||
if (!selected && unread !== !read) {
|
||||
setUnread(!read);
|
||||
}
|
||||
}, [selected, unread, read]);
|
||||
|
||||
return (
|
||||
<AnimateEmojiProvider className='announcements__item'>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { List } from 'immutable';
|
||||
|
||||
import { EmojiHTML } from '@/flavours/glitch/components/emoji/html';
|
||||
import { useElementHandledLink } from '@/flavours/glitch/components/status/handled_link';
|
||||
import type { CustomEmoji } from '@/flavours/glitch/models/custom_emoji';
|
||||
import type { Status } from '@/flavours/glitch/models/status';
|
||||
|
||||
import type { Mention } from './embedded_status';
|
||||
@@ -33,6 +34,7 @@ export const EmbeddedStatusContent: React.FC<{
|
||||
className={className}
|
||||
lang={status.get('language') as string}
|
||||
htmlString={status.get('contentHtml') as string}
|
||||
extraEmojis={status.get('emojis') as List<CustomEmoji>}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -37,7 +37,10 @@ const handleIframeUrl = (html, url, providerName) => {
|
||||
iframeUrl.searchParams.set('autoplay', 1)
|
||||
iframeUrl.searchParams.set('auto_play', 1)
|
||||
|
||||
if (startTime && providerName === "YouTube") iframeUrl.searchParams.set('start', startTime)
|
||||
if (providerName === 'YouTube') {
|
||||
iframeUrl.searchParams.set('start', startTime || '');
|
||||
iframe.referrerPolicy = 'strict-origin-when-cross-origin';
|
||||
}
|
||||
|
||||
iframe.src = iframeUrl.href
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
@typescript-eslint/no-unsafe-assignment */
|
||||
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
@@ -57,6 +57,8 @@ export const DetailedStatus: React.FC<{
|
||||
pictureInPicture: any;
|
||||
onToggleHidden?: (status: any) => void;
|
||||
onToggleMediaVisibility?: () => void;
|
||||
ancestors?: number;
|
||||
multiColumn?: boolean;
|
||||
expanded: boolean;
|
||||
}> = ({
|
||||
status,
|
||||
@@ -72,6 +74,8 @@ export const DetailedStatus: React.FC<{
|
||||
pictureInPicture,
|
||||
onToggleMediaVisibility,
|
||||
onToggleHidden,
|
||||
ancestors = 0,
|
||||
multiColumn = false,
|
||||
expanded,
|
||||
}) => {
|
||||
const properStatus = status?.get('reblog') ?? status;
|
||||
@@ -136,6 +140,30 @@ export const DetailedStatus: React.FC<{
|
||||
if (onTranslate) onTranslate(status);
|
||||
}, [onTranslate, status]);
|
||||
|
||||
// The component is managed and will change if the status changes
|
||||
// Ancestors can increase when loading a thread, in which case we want to scroll,
|
||||
// or decrease if a post is deleted, in which case we don't want to mess with it
|
||||
const previousAncestors = useRef(-1);
|
||||
useEffect(() => {
|
||||
if (nodeRef.current && previousAncestors.current < ancestors) {
|
||||
nodeRef.current.scrollIntoView(true);
|
||||
|
||||
// In the single-column interface, `scrollIntoView` will put the post behind the header, so compensate for that.
|
||||
if (!multiColumn) {
|
||||
const offset = document
|
||||
.querySelector('.column-header__wrapper')
|
||||
?.getBoundingClientRect().bottom;
|
||||
|
||||
if (offset) {
|
||||
const scrollingElement = document.scrollingElement ?? document.body;
|
||||
scrollingElement.scrollBy(0, -offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
previousAncestors.current = ancestors;
|
||||
}, [ancestors, multiColumn]);
|
||||
|
||||
if (!properStatus) {
|
||||
return null;
|
||||
}
|
||||
@@ -445,6 +473,7 @@ export const DetailedStatus: React.FC<{
|
||||
<QuotedStatus
|
||||
quote={status.get('quote')}
|
||||
parentQuotePostId={status.get('id')}
|
||||
contextType='thread'
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -295,7 +295,7 @@ export const RefreshController: React.FC<{
|
||||
if (loadingState === 'loading') {
|
||||
return (
|
||||
<div
|
||||
className='load-more load-gap'
|
||||
className='load-more load-more--large'
|
||||
aria-busy
|
||||
aria-live='polite'
|
||||
aria-label={intl.formatMessage(messages.loadingInitial)}
|
||||
|
||||
@@ -161,8 +161,7 @@ class Status extends ImmutablePureComponent {
|
||||
|
||||
componentDidMount () {
|
||||
attachFullscreenListener(this.onFullScreenChange);
|
||||
this.props.dispatch(fetchStatus(this.props.params.statusId));
|
||||
this._scrollStatusIntoView();
|
||||
this.props.dispatch(fetchStatus(this.props.params.statusId, { forceFetch: true }));
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(props, state) {
|
||||
@@ -170,7 +169,7 @@ class Status extends ImmutablePureComponent {
|
||||
let updated = false;
|
||||
|
||||
if (props.params.statusId && state.statusId !== props.params.statusId) {
|
||||
props.dispatch(fetchStatus(props.params.statusId));
|
||||
props.dispatch(fetchStatus(props.params.statusId, { forceFetch: true }));
|
||||
update.threadExpanded = undefined;
|
||||
update.statusId = props.params.statusId;
|
||||
updated = true;
|
||||
@@ -329,6 +328,12 @@ class Status extends ImmutablePureComponent {
|
||||
dispatch(openModal({ modalType: 'COMPOSE_PRIVACY', modalProps: { statusId, onChange: handleChange } }));
|
||||
};
|
||||
|
||||
handleQuote = (status) => {
|
||||
const { dispatch } = this.props;
|
||||
|
||||
dispatch(quoteComposeById(status.get('id')));
|
||||
};
|
||||
|
||||
handleEditClick = (status) => {
|
||||
const { dispatch, askReplyConfirmation } = this.props;
|
||||
|
||||
@@ -506,35 +511,11 @@ class Status extends ImmutablePureComponent {
|
||||
this.statusNode = c;
|
||||
};
|
||||
|
||||
_scrollStatusIntoView () {
|
||||
const { status, multiColumn } = this.props;
|
||||
|
||||
if (status) {
|
||||
requestIdleCallback(() => {
|
||||
this.statusNode?.scrollIntoView(true);
|
||||
|
||||
// In the single-column interface, `scrollIntoView` will put the post behind the header,
|
||||
// so compensate for that.
|
||||
if (!multiColumn) {
|
||||
const offset = document.querySelector('.column-header__wrapper')?.getBoundingClientRect()?.bottom;
|
||||
if (offset) {
|
||||
const scrollingElement = document.scrollingElement || document.body;
|
||||
scrollingElement.scrollBy(0, -offset);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate (prevProps) {
|
||||
const { status, ancestorsIds, descendantsIds } = this.props;
|
||||
const { status, descendantsIds } = this.props;
|
||||
|
||||
const isSameStatus = status && (prevProps.status?.get('id') === status.get('id'));
|
||||
|
||||
if (status && (ancestorsIds.length > prevProps.ancestorsIds.length || !isSameStatus)) {
|
||||
this._scrollStatusIntoView();
|
||||
}
|
||||
|
||||
// Only highlight replies after the initial load
|
||||
if (prevProps.descendantsIds.length && isSameStatus) {
|
||||
const newRepliesIds = difference(descendantsIds, prevProps.descendantsIds);
|
||||
@@ -647,6 +628,8 @@ class Status extends ImmutablePureComponent {
|
||||
showMedia={this.state.showMedia}
|
||||
onToggleMediaVisibility={this.handleToggleMediaVisibility}
|
||||
pictureInPicture={pictureInPicture}
|
||||
ancestors={this.props.ancestorsIds.length}
|
||||
multiColumn={multiColumn}
|
||||
/>
|
||||
|
||||
<ActionBar
|
||||
@@ -659,6 +642,7 @@ class Status extends ImmutablePureComponent {
|
||||
onDelete={this.handleDeleteClick}
|
||||
onRevokeQuote={this.handleRevokeQuoteClick}
|
||||
onQuotePolicyChange={this.handleQuotePolicyChange}
|
||||
onQuote={this.handleQuote}
|
||||
onEdit={this.handleEditClick}
|
||||
onDirect={this.handleDirectClick}
|
||||
onMention={this.handleMentionClick}
|
||||
|
||||
@@ -26,6 +26,7 @@ export const ConfirmationModal: React.FC<
|
||||
onSecondary?: () => void;
|
||||
onConfirm: () => void;
|
||||
closeWhenConfirm?: boolean;
|
||||
extraContent?: React.ReactNode;
|
||||
} & BaseConfirmationModalProps
|
||||
> = ({
|
||||
title,
|
||||
@@ -37,6 +38,7 @@ export const ConfirmationModal: React.FC<
|
||||
secondary,
|
||||
onSecondary,
|
||||
closeWhenConfirm = true,
|
||||
extraContent,
|
||||
}) => {
|
||||
const handleClick = useCallback(() => {
|
||||
if (closeWhenConfirm) {
|
||||
@@ -57,6 +59,8 @@ export const ConfirmationModal: React.FC<
|
||||
<div className='safety-action-modal__confirmation'>
|
||||
<h1>{title}</h1>
|
||||
{message && <p>{message}</p>}
|
||||
|
||||
{extraContent}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { forwardRef, useCallback, useState } from 'react';
|
||||
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { submitCompose } from '@/flavours/glitch/actions/compose';
|
||||
import { changeSetting } from '@/flavours/glitch/actions/settings';
|
||||
import { CheckBox } from '@/flavours/glitch/components/check_box';
|
||||
import { useAppDispatch } from '@/flavours/glitch/store';
|
||||
|
||||
import { ConfirmationModal } from './confirmation_modal';
|
||||
import type { BaseConfirmationModalProps } from './confirmation_modal';
|
||||
import classes from './styles.module.css';
|
||||
|
||||
export const PRIVATE_QUOTE_MODAL_ID = 'quote/private_notify';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'confirmations.private_quote_notify.title',
|
||||
defaultMessage: 'Share with followers and mentioned users?',
|
||||
},
|
||||
message: {
|
||||
id: 'confirmations.private_quote_notify.message',
|
||||
defaultMessage:
|
||||
'The person you are quoting and other mentions ' +
|
||||
"will be notified and will be able to view your post, even if they're not following you.",
|
||||
},
|
||||
confirm: {
|
||||
id: 'confirmations.private_quote_notify.confirm',
|
||||
defaultMessage: 'Publish post',
|
||||
},
|
||||
cancel: {
|
||||
id: 'confirmations.private_quote_notify.cancel',
|
||||
defaultMessage: 'Back to editing',
|
||||
},
|
||||
});
|
||||
|
||||
export const PrivateQuoteNotify = forwardRef<
|
||||
HTMLDivElement,
|
||||
BaseConfirmationModalProps
|
||||
>(
|
||||
(
|
||||
{ onClose },
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
_ref,
|
||||
) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const [dismiss, setDismissed] = useState(false);
|
||||
const handleDismissToggle = useCallback(() => {
|
||||
setDismissed((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const handleConfirm = useCallback(() => {
|
||||
dispatch(submitCompose());
|
||||
if (dismiss) {
|
||||
dispatch(
|
||||
changeSetting(['dismissed_banners', PRIVATE_QUOTE_MODAL_ID], true),
|
||||
);
|
||||
}
|
||||
}, [dismiss, dispatch]);
|
||||
|
||||
return (
|
||||
<ConfirmationModal
|
||||
title={intl.formatMessage(messages.title)}
|
||||
message={intl.formatMessage(messages.message)}
|
||||
confirm={intl.formatMessage(messages.confirm)}
|
||||
cancel={intl.formatMessage(messages.cancel)}
|
||||
onConfirm={handleConfirm}
|
||||
onClose={onClose}
|
||||
extraContent={
|
||||
<label className={classes.checkbox_wrapper}>
|
||||
<CheckBox
|
||||
value='hide'
|
||||
checked={dismiss}
|
||||
onChange={handleDismissToggle}
|
||||
/>{' '}
|
||||
<FormattedMessage
|
||||
id='confirmations.private_quote_notify.do_not_show_again'
|
||||
defaultMessage="Don't show me this message again"
|
||||
/>
|
||||
</label>
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
PrivateQuoteNotify.displayName = 'PrivateQuoteNotify';
|
||||
@@ -0,0 +1,7 @@
|
||||
.checkbox_wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 1rem 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -51,6 +51,7 @@ import MediaModal from './media_modal';
|
||||
import { ModalPlaceholder } from './modal_placeholder';
|
||||
import VideoModal from './video_modal';
|
||||
import { VisibilityModal } from './visibility_modal';
|
||||
import { PrivateQuoteNotify } from './confirmation_modals/private_quote_notify';
|
||||
|
||||
export const MODAL_COMPONENTS = {
|
||||
'MEDIA': () => Promise.resolve({ default: MediaModal }),
|
||||
@@ -72,6 +73,7 @@ export const MODAL_COMPONENTS = {
|
||||
'CONFIRM_LOG_OUT': () => Promise.resolve({ default: ConfirmLogOutModal }),
|
||||
'CONFIRM_FOLLOW_TO_LIST': () => Promise.resolve({ default: ConfirmFollowToListModal }),
|
||||
'CONFIRM_MISSING_ALT_TEXT': () => Promise.resolve({ default: ConfirmMissingAltTextModal }),
|
||||
'CONFIRM_PRIVATE_QUOTE_NOTIFY': () => Promise.resolve({ default: PrivateQuoteNotify }),
|
||||
'CONFIRM_REVOKE_QUOTE': () => Promise.resolve({ default: ConfirmRevokeQuoteModal }),
|
||||
'CONFIRM_QUIET_QUOTE': () => Promise.resolve({ default: QuietPostQuoteInfoModal }),
|
||||
'MUTE': MuteModal,
|
||||
|
||||
@@ -128,9 +128,12 @@ export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
|
||||
const disableVisibility = !!statusId;
|
||||
const disableQuotePolicy =
|
||||
visibility === 'private' || visibility === 'direct';
|
||||
const disablePublicVisibilities: boolean = useAppSelector(
|
||||
const disablePublicVisibilities = useAppSelector(
|
||||
selectDisablePublicVisibilities,
|
||||
);
|
||||
const isQuotePost = useAppSelector(
|
||||
(state) => state.compose.get('quoted_status_id') !== null,
|
||||
);
|
||||
|
||||
const visibilityItems = useMemo<SelectItem<StatusVisibility>[]>(() => {
|
||||
const items: SelectItem<StatusVisibility>[] = [
|
||||
@@ -315,6 +318,21 @@ export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
|
||||
id={quoteDescriptionId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isQuotePost && visibility === 'direct' && (
|
||||
<div className='visibility-modal__quote-warning'>
|
||||
<FormattedMessage
|
||||
id='visibility_modal.direct_quote_warning.title'
|
||||
defaultMessage="Quotes can't be embedded in private mentions"
|
||||
tagName='h3'
|
||||
/>
|
||||
<FormattedMessage
|
||||
id='visibility_modal.direct_quote_warning.text'
|
||||
defaultMessage='If you save the current settings, the embedded quote will be converted to a link.'
|
||||
tagName='p'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='dialog-modal__content__actions'>
|
||||
<Button onClick={onClose} secondary>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { initialState } from '@/flavours/glitch/initial_state';
|
||||
|
||||
interface FocusColumnOptions {
|
||||
index?: number;
|
||||
focusItem?: 'first' | 'first-visible';
|
||||
@@ -14,7 +12,10 @@ export function focusColumn({
|
||||
focusItem = 'first',
|
||||
}: FocusColumnOptions = {}) {
|
||||
// Skip the leftmost drawer in multi-column mode
|
||||
const indexOffset = initialState?.meta.advanced_layout ? 1 : 0;
|
||||
const isMultiColumnLayout = !!document.querySelector(
|
||||
'body.layout-multiple-columns',
|
||||
);
|
||||
const indexOffset = isMultiColumnLayout ? 1 : 0;
|
||||
|
||||
const column = document.querySelector(
|
||||
`.column:nth-child(${index + indexOffset})`,
|
||||
|
||||
@@ -37,7 +37,7 @@ interface InitialStateMeta {
|
||||
streaming_api_base_url: string;
|
||||
local_live_feed_access: 'public' | 'authenticated' | 'disabled';
|
||||
remote_live_feed_access: 'public' | 'authenticated' | 'disabled';
|
||||
local_topic_feed_access: 'public' | 'authenticated' | 'disabled';
|
||||
local_topic_feed_access: 'public' | 'authenticated';
|
||||
remote_topic_feed_access: 'public' | 'authenticated' | 'disabled';
|
||||
title: string;
|
||||
show_trends: boolean;
|
||||
@@ -156,17 +156,21 @@ export const statusPageUrl = getMeta('status_page_url');
|
||||
export const sso_redirect = getMeta('sso_redirect');
|
||||
export const termsOfServiceEnabled = getMeta('terms_of_service_enabled');
|
||||
|
||||
const displayNames = new Intl.DisplayNames(getMeta('locale'), {
|
||||
type: 'language',
|
||||
fallback: 'none',
|
||||
languageDisplay: 'standard',
|
||||
});
|
||||
const displayNames =
|
||||
// Intl.DisplayNames can be undefined in old browsers
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
Intl.DisplayNames &&
|
||||
(new Intl.DisplayNames(getMeta('locale'), {
|
||||
type: 'language',
|
||||
fallback: 'none',
|
||||
languageDisplay: 'standard',
|
||||
}) as Intl.DisplayNames | undefined);
|
||||
|
||||
export const languages = initialState?.languages.map((lang) => {
|
||||
// zh-YUE is not a valid CLDR unicode_language_id
|
||||
return [
|
||||
lang[0],
|
||||
displayNames.of(lang[0].replace('zh-YUE', 'yue')) ?? lang[1],
|
||||
displayNames?.of(lang[0].replace('zh-YUE', 'yue')) ?? lang[1],
|
||||
lang[2],
|
||||
];
|
||||
});
|
||||
|
||||
@@ -9,7 +9,6 @@ import { me, reduceMotion } from 'flavours/glitch/initial_state';
|
||||
import ready from 'flavours/glitch/ready';
|
||||
import { store } from 'flavours/glitch/store';
|
||||
|
||||
import { initializeEmoji } from './features/emoji';
|
||||
import { isProduction, isDevelopment } from './utils/environment';
|
||||
|
||||
function main() {
|
||||
@@ -30,6 +29,7 @@ function main() {
|
||||
});
|
||||
}
|
||||
|
||||
const { initializeEmoji } = await import('./features/emoji/index');
|
||||
initializeEmoji();
|
||||
|
||||
const root = createRoot(mountNode);
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable';
|
||||
|
||||
import {
|
||||
changeComposeVisibility,
|
||||
changeUploadCompose,
|
||||
quoteCompose,
|
||||
quoteComposeCancel,
|
||||
setComposeQuotePolicy,
|
||||
} from 'flavours/glitch/actions/compose_typed';
|
||||
pasteLinkCompose,
|
||||
cancelPasteLinkCompose,
|
||||
} from '@/flavours/glitch/actions/compose_typed';
|
||||
import { timelineDelete } from 'flavours/glitch/actions/timelines_typed';
|
||||
|
||||
import {
|
||||
@@ -39,7 +42,6 @@ import {
|
||||
COMPOSE_SENSITIVITY_CHANGE,
|
||||
COMPOSE_SPOILERNESS_CHANGE,
|
||||
COMPOSE_SPOILER_TEXT_CHANGE,
|
||||
COMPOSE_VISIBILITY_CHANGE,
|
||||
COMPOSE_LANGUAGE_CHANGE,
|
||||
COMPOSE_COMPOSING_CHANGE,
|
||||
COMPOSE_CONTENT_TYPE_CHANGE,
|
||||
@@ -119,6 +121,7 @@ const initialState = ImmutableMap({
|
||||
quoted_status_id: null,
|
||||
quote_policy: 'public',
|
||||
default_quote_policy: 'public', // Set in hydration.
|
||||
fetching_link: null,
|
||||
});
|
||||
|
||||
const initialPoll = ImmutableMap({
|
||||
@@ -210,6 +213,7 @@ function continueThread (state, status) {
|
||||
map.set('focusDate', new Date());
|
||||
map.set('caretPosition', null);
|
||||
map.set('preselectDate', new Date());
|
||||
map.set('quoted_status_id', null);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -391,7 +395,11 @@ const calculateProgress = (loaded, total) => Math.min(Math.round((loaded / total
|
||||
|
||||
/** @type {import('@reduxjs/toolkit').Reducer<typeof initialState>} */
|
||||
export const composeReducer = (state = initialState, action) => {
|
||||
if (changeUploadCompose.fulfilled.match(action)) {
|
||||
if (changeComposeVisibility.match(action)) {
|
||||
return state
|
||||
.set('privacy', action.payload)
|
||||
.set('idempotencyKey', uuid());
|
||||
} else if (changeUploadCompose.fulfilled.match(action)) {
|
||||
return state
|
||||
.set('is_changing_upload', false)
|
||||
.update('media_attachments', list => list.map(item => {
|
||||
@@ -407,15 +415,29 @@ export const composeReducer = (state = initialState, action) => {
|
||||
return state.set('is_changing_upload', false);
|
||||
} else if (quoteCompose.match(action)) {
|
||||
const status = action.payload;
|
||||
const isDirect = state.get('privacy') === 'direct';
|
||||
return state
|
||||
.set('quoted_status_id', status.get('id'))
|
||||
.set('spoiler', status.get('sensitive'))
|
||||
.set('spoiler_text', status.get('spoiler_text'))
|
||||
.update('privacy', (visibility) => ['public', 'unlisted'].includes(visibility) && status.get('visibility') === 'private' ? 'private' : visibility);
|
||||
.set('quoted_status_id', isDirect ? null : status.get('id'))
|
||||
.update('spoiler', spoiler => (spoiler) || !!status.get('spoiler_text'))
|
||||
.update('spoiler_text', (spoiler_text) => spoiler_text || status.get('spoiler_text'))
|
||||
.update('privacy', (visibility) => {
|
||||
if (['public', 'unlisted'].includes(visibility) && status.get('visibility') === 'private') {
|
||||
return 'private';
|
||||
}
|
||||
return visibility;
|
||||
}).update('advanced_options',
|
||||
map => map.merge(new ImmutableMap({ do_not_federate: !!status.get('local_only') })),
|
||||
);
|
||||
} else if (quoteComposeCancel.match(action)) {
|
||||
return state.set('quoted_status_id', null);
|
||||
} else if (setComposeQuotePolicy.match(action)) {
|
||||
return state.set('quote_policy', action.payload);
|
||||
} else if (pasteLinkCompose.pending.match(action)) {
|
||||
return state.set('fetching_link', action.meta.requestId);
|
||||
} else if (pasteLinkCompose.fulfilled.match(action) || pasteLinkCompose.rejected.match(action)) {
|
||||
return action.meta.requestId === state.get('fetching_link') ? state.set('fetching_link', null) : state;
|
||||
} else if (cancelPasteLinkCompose.match(action)) {
|
||||
return state.set('fetching_link', null);
|
||||
}
|
||||
|
||||
switch(action.type) {
|
||||
@@ -462,10 +484,6 @@ export const composeReducer = (state = initialState, action) => {
|
||||
return state
|
||||
.set('spoiler_text', action.text)
|
||||
.set('idempotencyKey', uuid());
|
||||
case COMPOSE_VISIBILITY_CHANGE:
|
||||
return state
|
||||
.set('privacy', action.value)
|
||||
.set('idempotencyKey', uuid());
|
||||
case COMPOSE_CONTENT_TYPE_CHANGE:
|
||||
return state
|
||||
.set('content_type', action.value)
|
||||
@@ -490,6 +508,7 @@ export const composeReducer = (state = initialState, action) => {
|
||||
map.set('caretPosition', null);
|
||||
map.set('preselectDate', new Date());
|
||||
map.set('idempotencyKey', uuid());
|
||||
map.set('quoted_status_id', null);
|
||||
|
||||
map.update('media_attachments', list => list.filter(media => media.get('unattached')));
|
||||
|
||||
@@ -628,7 +647,7 @@ export const composeReducer = (state = initialState, action) => {
|
||||
map => map.merge(new ImmutableMap({ do_not_federate })),
|
||||
);
|
||||
map.set('id', null);
|
||||
map.set('quoted_status_id', action.status.getIn(['quote', 'quoted_status']));
|
||||
map.set('quoted_status_id', action.status.getIn(['quote', 'quoted_status'], null));
|
||||
// Mastodon-authored posts can be expected to have at most one automatic approval policy
|
||||
map.set('quote_policy', action.status.getIn(['quote_approval', 'automatic', 0]) || 'nobody');
|
||||
|
||||
@@ -671,7 +690,7 @@ export const composeReducer = (state = initialState, action) => {
|
||||
map.set('idempotencyKey', uuid());
|
||||
map.set('sensitive', action.status.get('sensitive'));
|
||||
map.set('language', action.status.get('language'));
|
||||
map.set('quoted_status_id', action.status.getIn(['quote', 'quoted_status']));
|
||||
map.set('quoted_status_id', action.status.getIn(['quote', 'quoted_status'], null));
|
||||
// Mastodon-authored posts can be expected to have at most one automatic approval policy
|
||||
map.set('quote_policy', action.status.getIn(['quote_approval', 'automatic', 0]) || 'nobody');
|
||||
|
||||
|
||||
@@ -65,6 +65,10 @@ const statusTranslateUndo = (state, id) => {
|
||||
});
|
||||
};
|
||||
|
||||
const removeStatusStub = (state, id) => {
|
||||
return state.getIn([id, 'id']) ? state.deleteIn([id, 'isLoading']) : state.delete(id);
|
||||
}
|
||||
|
||||
|
||||
/** @type {ImmutableMap<string, import('flavours/glitch/models/status').Status>} */
|
||||
const initialState = ImmutableMap();
|
||||
@@ -92,11 +96,10 @@ export default function statuses(state = initialState, action) {
|
||||
return state.setIn([action.id, 'isLoading'], true);
|
||||
case STATUS_FETCH_FAIL: {
|
||||
if (action.parentQuotePostId && action.error.status === 404) {
|
||||
return state
|
||||
.delete(action.id)
|
||||
return removeStatusStub(state, action.id)
|
||||
.setIn([action.parentQuotePostId, 'quote', 'state'], 'deleted')
|
||||
} else {
|
||||
return state.delete(action.id);
|
||||
return removeStatusStub(state, action.id);
|
||||
}
|
||||
}
|
||||
case STATUS_IMPORT:
|
||||
|
||||
@@ -32,7 +32,11 @@ function getStatusResultFunction(
|
||||
};
|
||||
}
|
||||
|
||||
if (statusBase.get('isLoading')) {
|
||||
// When a status is loading, a `isLoading` property is set
|
||||
// A status can be loading because it is not known yet (in which case it will only contain `isLoading`)
|
||||
// or because it is being re-fetched; in the latter case, `visibility` will always be set to a non-empty
|
||||
// string.
|
||||
if (statusBase.get('isLoading') && !statusBase.get('visibility')) {
|
||||
return {
|
||||
status: null,
|
||||
loadingState: 'loading',
|
||||
@@ -75,7 +79,7 @@ function getStatusResultFunction(
|
||||
map.set('matched_filters', filtered);
|
||||
map.set('matched_media_filters', mediaFiltered);
|
||||
}),
|
||||
loadingState: 'complete'
|
||||
loadingState: statusBase.get('isLoading') ? 'loading' : 'complete'
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ interface AppThunkConfig {
|
||||
}
|
||||
export type AppThunkApi = Pick<
|
||||
GetThunkAPI<AppThunkConfig>,
|
||||
'getState' | 'dispatch'
|
||||
'getState' | 'dispatch' | 'requestId'
|
||||
>;
|
||||
|
||||
interface AppThunkOptions<Arg> {
|
||||
@@ -60,7 +60,7 @@ type AppThunk<Arg = void, Returned = void> = (
|
||||
|
||||
type AppThunkCreator<Arg = void, Returned = void, ExtraArg = unknown> = (
|
||||
arg: Arg,
|
||||
api: AppThunkApi,
|
||||
api: Pick<AppThunkApi, 'getState' | 'dispatch'>,
|
||||
extra?: ExtraArg,
|
||||
) => Returned;
|
||||
|
||||
@@ -143,10 +143,10 @@ export function createAsyncThunk<Arg = void, Returned = void>(
|
||||
name,
|
||||
async (
|
||||
arg: Arg,
|
||||
{ getState, dispatch, fulfillWithValue, rejectWithValue },
|
||||
{ getState, dispatch, requestId, fulfillWithValue, rejectWithValue },
|
||||
) => {
|
||||
try {
|
||||
const result = await creator(arg, { dispatch, getState });
|
||||
const result = await creator(arg, { dispatch, getState, requestId });
|
||||
|
||||
return fulfillWithValue(result, {
|
||||
useLoadingBar: options.useLoadingBar,
|
||||
@@ -280,10 +280,11 @@ export function createDataLoadingThunk<
|
||||
|
||||
return createAsyncThunk<Args, Returned>(
|
||||
name,
|
||||
async (arg, { getState, dispatch }) => {
|
||||
async (arg, { getState, dispatch, requestId }) => {
|
||||
const data = await loadData(arg, {
|
||||
dispatch,
|
||||
getState,
|
||||
requestId,
|
||||
});
|
||||
|
||||
if (!onData) return data as Returned;
|
||||
@@ -291,6 +292,7 @@ export function createDataLoadingThunk<
|
||||
const result = await onData(data, {
|
||||
dispatch,
|
||||
getState,
|
||||
requestId,
|
||||
discardLoadData: discardLoadDataInPayload,
|
||||
actionArg: arg,
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
@mixin search-input() {
|
||||
@mixin search-input {
|
||||
outline: 0;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
@@ -26,7 +26,7 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@mixin search-popout() {
|
||||
@mixin search-popout {
|
||||
background: $simple-background-color;
|
||||
border-radius: 4px;
|
||||
padding: 10px 14px;
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
margin-inline-start: 15px;
|
||||
text-align: start;
|
||||
|
||||
i[data-hidden] {
|
||||
svg[data-hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
|
||||
.newer {
|
||||
float: right;
|
||||
padding-inline-start: 0;
|
||||
padding-inline-end: 0;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
@@ -204,6 +204,7 @@
|
||||
}
|
||||
|
||||
.information-badge,
|
||||
.simple_form .overridden,
|
||||
.simple_form .recommended,
|
||||
.simple_form .not_recommended {
|
||||
background-color: color.change($ui-secondary-color, $alpha: 0.1);
|
||||
|
||||
@@ -163,6 +163,7 @@ $content-width: 840px;
|
||||
width: 100%;
|
||||
max-width: $content-width;
|
||||
flex: 1 1 auto;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
@media screen and (max-width: ($content-width + $sidebar-width)) {
|
||||
@@ -1330,6 +1331,10 @@ a.sparkline {
|
||||
line-height: 1;
|
||||
width: 100%;
|
||||
animation: skeleton 1.2s ease-in-out infinite;
|
||||
|
||||
.reduce-motion & {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes skeleton {
|
||||
|
||||
@@ -168,6 +168,10 @@ a {
|
||||
button {
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.app-holder {
|
||||
|
||||
@@ -775,16 +775,43 @@
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
&__preview {
|
||||
&__preview,
|
||||
&__visualizer {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 6px;
|
||||
z-index: -1;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
&__preview {
|
||||
border-radius: 6px;
|
||||
inset-inline-start: 0;
|
||||
}
|
||||
|
||||
&__visualizer {
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
|
||||
.audio-player__visualizer {
|
||||
margin: 0 auto;
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
inset-inline-start: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 0.75;
|
||||
color: var(--player-foreground-color);
|
||||
filter: var(--overlay-icon-shadow);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
&__thumbnail {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -3217,20 +3244,21 @@ a.account__display-name {
|
||||
}
|
||||
|
||||
.column__alert {
|
||||
--alert-height: 54px;
|
||||
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 10;
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
grid-template-rows: minmax(var(--alert-height), max-content);
|
||||
align-items: end;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
padding: 1rem;
|
||||
margin: auto auto 0;
|
||||
overflow: clip;
|
||||
|
||||
&:empty {
|
||||
padding: 0;
|
||||
}
|
||||
pointer-events: none;
|
||||
|
||||
@media (max-width: #{$mobile-menu-breakpoint - 1}) {
|
||||
// Compensate for mobile menubar
|
||||
@@ -3241,6 +3269,7 @@ a.account__display-name {
|
||||
// Make all nested alerts occupy the same space
|
||||
// rather than stack
|
||||
grid-area: 1 / 1;
|
||||
pointer-events: initial;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4542,13 +4571,19 @@ a.status-card {
|
||||
box-sizing: border-box;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
background: var(--on-surface-color);
|
||||
&--large {
|
||||
padding-block: 32px;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid $ui-button-focus-outline-color;
|
||||
outline-offset: -2px;
|
||||
&:is(button) {
|
||||
&:hover {
|
||||
background: var(--on-surface-color);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid $ui-button-focus-outline-color;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
@@ -5980,6 +6015,34 @@ a.status-card {
|
||||
}
|
||||
}
|
||||
|
||||
.visibility-modal {
|
||||
&__quote-warning {
|
||||
color: var(--nested-card-text);
|
||||
background:
|
||||
/* This is a bit of a silly hack for layering two background colours
|
||||
* since --nested-card-background is too transparent for a tooltip */
|
||||
linear-gradient(
|
||||
var(--nested-card-background),
|
||||
var(--nested-card-background)
|
||||
),
|
||||
linear-gradient(var(--background-color), var(--background-color));
|
||||
border: var(--nested-card-border);
|
||||
padding: 16px;
|
||||
border-radius: 4px;
|
||||
|
||||
h3 {
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
color: $darker-text-color;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 0.8em;
|
||||
color: $dark-text-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.visibility-dropdown {
|
||||
&__overlay[data-popper-placement] {
|
||||
z-index: 9999;
|
||||
|
||||
@@ -700,9 +700,10 @@ code {
|
||||
font-family: inherit;
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
max-width: 140px;
|
||||
max-width: 50%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
@@ -831,7 +832,7 @@ code {
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (width <= 740px) and (width >= 441px) {
|
||||
@media screen and (440px < width <= 740px) {
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
|
||||
@@ -41,27 +41,11 @@ body.rtl {
|
||||
float: left;
|
||||
}
|
||||
|
||||
.activity-stream .status.light {
|
||||
padding-left: 10px;
|
||||
padding-right: 68px;
|
||||
}
|
||||
|
||||
.status__info .status__display-name,
|
||||
.activity-stream .status.light .status__display-name {
|
||||
.status__info .status__display-name {
|
||||
padding-left: 25px;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.activity-stream .pre-header {
|
||||
padding-right: 68px;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.activity-stream .pre-header .pre-header__icon {
|
||||
left: auto;
|
||||
right: 42px;
|
||||
}
|
||||
|
||||
.account__header__tabs__buttons > .icon-button {
|
||||
margin-right: 0;
|
||||
margin-left: 8px;
|
||||
@@ -93,6 +77,10 @@ body.rtl {
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
.react-swipeable-view-container > * {
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
.column-back-button__icon {
|
||||
transform: scale(-1, 1);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { throttle } from 'lodash';
|
||||
|
||||
import api from 'mastodon/api';
|
||||
import { browserHistory } from 'mastodon/components/router';
|
||||
import { countableText } from 'mastodon/features/compose/util/counter';
|
||||
import { search as emojiSearch } from 'mastodon/features/emoji/emoji_mart_search_light';
|
||||
import { tagHistory } from 'mastodon/settings';
|
||||
|
||||
@@ -55,7 +56,6 @@ export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT';
|
||||
export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE';
|
||||
export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
|
||||
export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE';
|
||||
export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE';
|
||||
export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE';
|
||||
export const COMPOSE_LANGUAGE_CHANGE = 'COMPOSE_LANGUAGE_CHANGE';
|
||||
|
||||
@@ -88,6 +88,7 @@ const messages = defineMessages({
|
||||
open: { id: 'compose.published.open', defaultMessage: 'Open' },
|
||||
published: { id: 'compose.published.body', defaultMessage: 'Post published.' },
|
||||
saved: { id: 'compose.saved.body', defaultMessage: 'Post saved.' },
|
||||
blankPostError: { id: 'compose.error.blank_post', defaultMessage: 'Post can\'t be blank.' },
|
||||
});
|
||||
|
||||
export const ensureComposeIsVisible = (getState) => {
|
||||
@@ -197,7 +198,15 @@ export function submitCompose(successCallback) {
|
||||
const hasQuote = !!getState().getIn(['compose', 'quoted_status_id']);
|
||||
const spoiler_text = getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '';
|
||||
|
||||
if (!(status?.length || media.size !== 0 || (hasQuote && spoiler_text?.length))) {
|
||||
const fulltext = `${spoiler_text ?? ''}${countableText(status ?? '')}`;
|
||||
const hasText = fulltext.trim().length > 0;
|
||||
|
||||
if (!(hasText || media.size !== 0 || (hasQuote && spoiler_text?.length))) {
|
||||
dispatch(showAlert({
|
||||
message: messages.blankPostError,
|
||||
}));
|
||||
dispatch(focusCompose());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -666,8 +675,17 @@ export function selectComposeSuggestion(position, token, suggestion, path) {
|
||||
|
||||
dispatch(useEmoji(suggestion));
|
||||
} else if (suggestion.type === 'hashtag') {
|
||||
completion = suggestion.name.slice(token.length - 1);
|
||||
startPosition = position + token.length;
|
||||
// TODO: it could make sense to keep the “most capitalized” of the two
|
||||
const tokenName = token.slice(1); // strip leading '#'
|
||||
const suggestionPrefix = suggestion.name.slice(0, tokenName.length);
|
||||
const prefixMatchesSuggestion = suggestionPrefix.localeCompare(tokenName, undefined, { sensitivity: 'accent' }) === 0;
|
||||
if (prefixMatchesSuggestion) {
|
||||
completion = token + suggestion.name.slice(tokenName.length);
|
||||
} else {
|
||||
completion = `${token.slice(0, 1)}${suggestion.name}`;
|
||||
}
|
||||
|
||||
startPosition = position - 1;
|
||||
} else if (suggestion.type === 'account') {
|
||||
completion = `@${getState().getIn(['accounts', suggestion.id, 'acct'])}`;
|
||||
startPosition = position - 1;
|
||||
@@ -786,13 +804,6 @@ export function changeComposeSpoilerText(text) {
|
||||
};
|
||||
}
|
||||
|
||||
export function changeComposeVisibility(value) {
|
||||
return {
|
||||
type: COMPOSE_VISIBILITY_CHANGE,
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
export function insertEmojiCompose(position, emoji, needsSpace) {
|
||||
return {
|
||||
type: COMPOSE_EMOJI_INSERT,
|
||||
|
||||
@@ -13,10 +13,11 @@ import {
|
||||
} from 'mastodon/store/typed_functions';
|
||||
|
||||
import type { ApiQuotePolicy } from '../api_types/quotes';
|
||||
import type { Status } from '../models/status';
|
||||
import type { Status, StatusVisibility } from '../models/status';
|
||||
import type { RootState } from '../store';
|
||||
|
||||
import { showAlert } from './alerts';
|
||||
import { focusCompose } from './compose';
|
||||
import { changeCompose, focusCompose } from './compose';
|
||||
import { importFetchedStatuses } from './importer';
|
||||
import { openModal } from './modal';
|
||||
|
||||
@@ -41,6 +42,10 @@ const messages = defineMessages({
|
||||
id: 'quote_error.unauthorized',
|
||||
defaultMessage: 'You are not authorized to quote this post.',
|
||||
},
|
||||
quoteErrorPrivateMention: {
|
||||
id: 'quote_error.private_mentions',
|
||||
defaultMessage: 'Quoting is not allowed with direct mentions.',
|
||||
},
|
||||
});
|
||||
|
||||
type SimulatedMediaAttachmentJSON = ApiMediaAttachmentJSON & {
|
||||
@@ -67,6 +72,39 @@ const simulateModifiedApiResponse = (
|
||||
return data;
|
||||
};
|
||||
|
||||
export const changeComposeVisibility = createAppThunk(
|
||||
'compose/visibility_change',
|
||||
(visibility: StatusVisibility, { dispatch, getState }) => {
|
||||
if (visibility !== 'direct') {
|
||||
return visibility;
|
||||
}
|
||||
|
||||
const state = getState();
|
||||
const quotedStatusId = state.compose.get('quoted_status_id') as
|
||||
| string
|
||||
| null;
|
||||
if (!quotedStatusId) {
|
||||
return visibility;
|
||||
}
|
||||
|
||||
// Remove the quoted status
|
||||
dispatch(quoteComposeCancel());
|
||||
const quotedStatus = state.statuses.get(quotedStatusId) as Status | null;
|
||||
if (!quotedStatus) {
|
||||
return visibility;
|
||||
}
|
||||
|
||||
// Append the quoted status URL to the compose text
|
||||
const url = quotedStatus.get('url') as string;
|
||||
const text = state.compose.get('text') as string;
|
||||
if (!text.includes(url)) {
|
||||
const newText = text.trim() ? `${text}\n\n${url}` : url;
|
||||
dispatch(changeCompose(newText));
|
||||
}
|
||||
return visibility;
|
||||
},
|
||||
);
|
||||
|
||||
export const changeUploadCompose = createDataLoadingThunk(
|
||||
'compose/changeUpload',
|
||||
async (
|
||||
@@ -130,6 +168,8 @@ export const quoteComposeByStatus = createAppThunk(
|
||||
|
||||
if (composeState.get('id')) {
|
||||
dispatch(showAlert({ message: messages.quoteErrorEdit }));
|
||||
} else if (composeState.get('privacy') === 'direct') {
|
||||
dispatch(showAlert({ message: messages.quoteErrorPrivateMention }));
|
||||
} else if (composeState.get('poll')) {
|
||||
dispatch(showAlert({ message: messages.quoteErrorPoll }));
|
||||
} else if (
|
||||
@@ -173,6 +213,17 @@ export const quoteComposeById = createAppThunk(
|
||||
},
|
||||
);
|
||||
|
||||
const composeStateForbidsLink = (composeState: RootState['compose']) => {
|
||||
return (
|
||||
composeState.get('quoted_status_id') ||
|
||||
composeState.get('is_submitting') ||
|
||||
composeState.get('poll') ||
|
||||
composeState.get('is_uploading') ||
|
||||
composeState.get('id') ||
|
||||
composeState.get('privacy') === 'direct'
|
||||
);
|
||||
};
|
||||
|
||||
export const pasteLinkCompose = createDataLoadingThunk(
|
||||
'compose/pasteLink',
|
||||
async ({ url }: { url: string }) => {
|
||||
@@ -183,15 +234,12 @@ export const pasteLinkCompose = createDataLoadingThunk(
|
||||
limit: 2,
|
||||
});
|
||||
},
|
||||
(data, { dispatch, getState }) => {
|
||||
(data, { dispatch, getState, requestId }) => {
|
||||
const composeState = getState().compose;
|
||||
|
||||
if (
|
||||
composeState.get('quoted_status_id') ||
|
||||
composeState.get('is_submitting') ||
|
||||
composeState.get('poll') ||
|
||||
composeState.get('is_uploading') ||
|
||||
composeState.get('id')
|
||||
composeStateForbidsLink(composeState) ||
|
||||
composeState.get('fetching_link') !== requestId // Request has been cancelled
|
||||
)
|
||||
return;
|
||||
|
||||
@@ -207,6 +255,17 @@ export const pasteLinkCompose = createDataLoadingThunk(
|
||||
dispatch(quoteComposeById(data.statuses[0].id));
|
||||
}
|
||||
},
|
||||
{
|
||||
useLoadingBar: false,
|
||||
condition: (_, { getState }) =>
|
||||
!getState().compose.get('fetching_link') &&
|
||||
!composeStateForbidsLink(getState().compose),
|
||||
},
|
||||
);
|
||||
|
||||
// Ideally this would cancel the action and the HTTP request, but this is good enough
|
||||
export const cancelPasteLinkCompose = createAction(
|
||||
'compose/cancelPasteLinkCompose',
|
||||
);
|
||||
|
||||
export const quoteComposeCancel = createAction('compose/quoteComposeCancel');
|
||||
|
||||
@@ -46,11 +46,11 @@ export function importFetchedAccounts(accounts) {
|
||||
return importAccounts({ accounts: normalAccounts });
|
||||
}
|
||||
|
||||
export function importFetchedStatus(status) {
|
||||
return importFetchedStatuses([status]);
|
||||
export function importFetchedStatus(status, options = {}) {
|
||||
return importFetchedStatuses([status], options);
|
||||
}
|
||||
|
||||
export function importFetchedStatuses(statuses) {
|
||||
export function importFetchedStatuses(statuses, options = {}) {
|
||||
return (dispatch, getState) => {
|
||||
const accounts = [];
|
||||
const normalStatuses = [];
|
||||
@@ -58,7 +58,7 @@ export function importFetchedStatuses(statuses) {
|
||||
const filters = [];
|
||||
|
||||
function processStatus(status) {
|
||||
pushUnique(normalStatuses, normalizeStatus(status, getState().getIn(['statuses', status.id])));
|
||||
pushUnique(normalStatuses, normalizeStatus(status, getState().getIn(['statuses', status.id]), options));
|
||||
pushUnique(accounts, status.account);
|
||||
|
||||
if (status.filtered) {
|
||||
|
||||
@@ -27,9 +27,12 @@ function stripQuoteFallback(text) {
|
||||
return wrapper.innerHTML;
|
||||
}
|
||||
|
||||
export function normalizeStatus(status, normalOldStatus) {
|
||||
export function normalizeStatus(status, normalOldStatus, { bogusQuotePolicy = false }) {
|
||||
const normalStatus = { ...status };
|
||||
|
||||
if (bogusQuotePolicy)
|
||||
normalStatus.quote_approval = null;
|
||||
|
||||
normalStatus.account = status.account.id;
|
||||
|
||||
if (status.reblog && status.reblog.id) {
|
||||
@@ -109,6 +112,8 @@ export function normalizeStatus(status, normalOldStatus) {
|
||||
}
|
||||
|
||||
if (normalOldStatus) {
|
||||
normalStatus.quote_approval ||= normalOldStatus.get('quote_approval');
|
||||
|
||||
const list = normalOldStatus.get('media_attachments');
|
||||
if (normalStatus.media_attachments && list) {
|
||||
normalStatus.media_attachments.forEach(item => {
|
||||
|
||||
@@ -85,6 +85,8 @@ export function fetchStatus(id, {
|
||||
dispatch(fetchStatusSuccess(skipLoading));
|
||||
}).catch(error => {
|
||||
dispatch(fetchStatusFail(id, error, skipLoading, parentQuotePostId));
|
||||
if (error.status === 404)
|
||||
dispatch(deleteFromTimelines(id));
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -203,8 +205,8 @@ export function deleteStatusFail(id, error) {
|
||||
};
|
||||
}
|
||||
|
||||
export const updateStatus = status => dispatch =>
|
||||
dispatch(importFetchedStatus(status));
|
||||
export const updateStatus = (status, { bogusQuotePolicy }) => dispatch =>
|
||||
dispatch(importFetchedStatus(status, { bogusQuotePolicy }));
|
||||
|
||||
export function muteStatus(id) {
|
||||
return (dispatch) => {
|
||||
|
||||
@@ -52,6 +52,9 @@ const randomUpTo = max =>
|
||||
export const connectTimelineStream = (timelineId, channelName, params = {}, options = {}) => {
|
||||
const { messages } = getLocale();
|
||||
|
||||
// Public streams are currently not returning personalized quote policies
|
||||
const bogusQuotePolicy = channelName.startsWith('public') || channelName.startsWith('hashtag');
|
||||
|
||||
return connectStream(channelName, params, (dispatch, getState) => {
|
||||
// @ts-ignore
|
||||
const locale = getState().getIn(['meta', 'locale']);
|
||||
@@ -97,11 +100,11 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
|
||||
switch (data.event) {
|
||||
case 'update':
|
||||
// @ts-expect-error
|
||||
dispatch(updateTimeline(timelineId, JSON.parse(data.payload), options.accept));
|
||||
dispatch(updateTimeline(timelineId, JSON.parse(data.payload), { accept: options.accept, bogusQuotePolicy }));
|
||||
break;
|
||||
case 'status.update':
|
||||
// @ts-expect-error
|
||||
dispatch(updateStatus(JSON.parse(data.payload)));
|
||||
dispatch(updateStatus(JSON.parse(data.payload), { bogusQuotePolicy }));
|
||||
break;
|
||||
case 'delete':
|
||||
dispatch(deleteFromTimelines(data.payload));
|
||||
|
||||
@@ -32,7 +32,7 @@ export const loadPending = timeline => ({
|
||||
timeline,
|
||||
});
|
||||
|
||||
export function updateTimeline(timeline, status, accept) {
|
||||
export function updateTimeline(timeline, status, { accept = undefined, bogusQuotePolicy = false } = {}) {
|
||||
return (dispatch, getState) => {
|
||||
if (typeof accept === 'function' && !accept(status)) {
|
||||
return;
|
||||
@@ -45,7 +45,7 @@ export function updateTimeline(timeline, status, accept) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(importFetchedStatus(status));
|
||||
dispatch(importFetchedStatus(status, { bogusQuotePolicy }));
|
||||
|
||||
dispatch({
|
||||
type: TIMELINE_UPDATE,
|
||||
|
||||
@@ -6,7 +6,6 @@ import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import type { Account } from 'mastodon/models/account';
|
||||
|
||||
import { CustomEmojiProvider } from './emoji/context';
|
||||
import { EmojiHTML } from './emoji/html';
|
||||
import { useElementHandledLink } from './status/handled_link';
|
||||
|
||||
@@ -22,12 +21,13 @@ export const AccountFields: React.FC<Pick<Account, 'fields' | 'emojis'>> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<CustomEmojiProvider emojis={emojis}>
|
||||
<>
|
||||
{fields.map((pair, i) => (
|
||||
<dl key={i} className={classNames({ verified: pair.verified_at })}>
|
||||
<EmojiHTML
|
||||
as='dt'
|
||||
htmlString={pair.name_emojified}
|
||||
extraEmojis={emojis}
|
||||
className='translate'
|
||||
{...htmlHandlers}
|
||||
/>
|
||||
@@ -52,12 +52,13 @@ export const AccountFields: React.FC<Pick<Account, 'fields' | 'emojis'>> = ({
|
||||
<EmojiHTML
|
||||
as='span'
|
||||
htmlString={pair.value_emojified}
|
||||
extraEmojis={emojis}
|
||||
{...htmlHandlers}
|
||||
/>
|
||||
</dd>
|
||||
</dl>
|
||||
))}
|
||||
</CustomEmojiProvider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user