mirror of
https://github.com/glitch-soc/mastodon.git
synced 2026-03-29 11:11:11 +02:00
Compare commits
176 Commits
v4.5.0-rc.
...
v4.5.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a602cc9126 | ||
|
|
4d4611beba | ||
|
|
e8045de79b | ||
|
|
5f30206c5e | ||
|
|
6fd034cb77 | ||
|
|
527bed86b5 | ||
|
|
a1e0fbfb67 | ||
|
|
68a26ce7c6 | ||
|
|
ff20ce9acf | ||
|
|
1ba2b1cdc1 | ||
|
|
4c1fbe4e2e | ||
|
|
569ff6c8ad | ||
|
|
81716f7e27 | ||
|
|
8935137526 | ||
|
|
dcc5c2b6f6 | ||
|
|
f1c32f6a11 | ||
|
|
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 |
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}}
|
||||
|
||||
140
CHANGELOG.md
140
CHANGELOG.md
@@ -2,11 +2,138 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [4.5.0] - UNRELEASED
|
||||
## [4.5.6] - 2026-02-03
|
||||
|
||||
### Security
|
||||
|
||||
- Fix ActivityPub collection caching logic for pinned posts and featured tags not checking blocked accounts ([GHSA-ccpr-m53r-mfwr](https://github.com/mastodon/mastodon/security/advisories/GHSA-ccpr-m53r-mfwr))
|
||||
|
||||
### Changed
|
||||
|
||||
- Shorten caching of quote posts pending approval (#37570 and #37592 by @ClearlyClaire)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix relationship cache not being cleared when handling account migrations (#37664 by @ClearlyClaire)
|
||||
- Fix quote cancel button not appearing after edit then delete-and-redraft (#37066 by @PGrayCS)
|
||||
- Fix followers with profile subscription (bell icon) being notified of post edits (#37646 by @ClearlyClaire)
|
||||
- Fix error when encountering invalid tag in updated object (#37635 by @ClearlyClaire)
|
||||
- Fix cross-server conversation tracking (#37559 by @ClearlyClaire)
|
||||
- Fix recycled connections not being immediately closed (#37335 and #37674 by @ClearlyClaire and @shleeable)
|
||||
|
||||
## [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, #36559, #36693, #36704, #36690, #36689, #36696, #36721 and #36695 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)
|
||||
@@ -21,21 +148,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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -4,17 +4,31 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController
|
||||
vary_by -> { 'Signature' if authorized_fetch_mode? }
|
||||
|
||||
before_action :require_account_signature!, if: :authorized_fetch_mode?
|
||||
before_action :check_authorization
|
||||
before_action :set_items
|
||||
before_action :set_size
|
||||
before_action :set_type
|
||||
|
||||
def show
|
||||
expires_in 3.minutes, public: public_fetch_mode?
|
||||
render_with_cache json: collection_presenter, content_type: 'application/activity+json', serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
|
||||
|
||||
if @unauthorized
|
||||
render json: collection_presenter, content_type: 'application/activity+json', serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
|
||||
else
|
||||
render_with_cache json: collection_presenter, content_type: 'application/activity+json', serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def check_authorization
|
||||
# Because in public fetch mode we cache the response, there would be no
|
||||
# benefit from performing the check below, since a blocked account or domain
|
||||
# would likely be served the cache from the reverse proxy anyway
|
||||
|
||||
@unauthorized = authorized_fetch_mode? && !signed_request_account.nil? && (@account.blocking?(signed_request_account) || (!signed_request_account.domain.nil? && @account.domain_blocking?(signed_request_account.domain)))
|
||||
end
|
||||
|
||||
def set_items
|
||||
case params[:id]
|
||||
when 'featured'
|
||||
@@ -57,11 +71,7 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController
|
||||
end
|
||||
|
||||
def for_signed_account
|
||||
# Because in public fetch mode we cache the response, there would be no
|
||||
# benefit from performing the check below, since a blocked account or domain
|
||||
# would likely be served the cache from the reverse proxy anyway
|
||||
|
||||
if authorized_fetch_mode? && !signed_request_account.nil? && (@account.blocking?(signed_request_account) || (!signed_request_account.domain.nil? && @account.domain_blocking?(signed_request_account.domain)))
|
||||
if @unauthorized
|
||||
[]
|
||||
else
|
||||
yield
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ class ActivityPub::QuoteAuthorizationsController < ActivityPub::BaseController
|
||||
return not_found unless @quote.status.present? && @quote.quoted_status.present?
|
||||
|
||||
authorize @quote.quoted_status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
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
|
||||
|
||||
@@ -29,7 +29,7 @@ class StatusesController < ApplicationController
|
||||
end
|
||||
|
||||
format.json do
|
||||
expires_in 3.minutes, public: true if @status.distributable? && public_fetch_mode?
|
||||
expires_in @status.quote&.pending? ? 5.seconds : 3.minutes, public: true if @status.distributable? && public_fetch_mode?
|
||||
render_with_cache json: @status, content_type: 'application/activity+json', serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -709,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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -159,6 +159,7 @@ class Status extends ImmutablePureComponent {
|
||||
'expanded',
|
||||
'unread',
|
||||
'pictureInPicture',
|
||||
'onQuoteCancel',
|
||||
'previousId',
|
||||
'nextInReplyToId',
|
||||
'rootId',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -169,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]) {
|
||||
@@ -179,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,9 +131,9 @@ 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('');
|
||||
@@ -156,7 +158,7 @@ class ComposeForm extends ImmutablePureComponent {
|
||||
}
|
||||
|
||||
this.props.onSubmit({
|
||||
missingAltTextModal: missingAltTextModal && this.props.missingAltText && this.props.privacy !== 'direct',
|
||||
missingAltText: missingAltTextModal && this.props.missingAltText && this.props.privacy !== 'direct',
|
||||
quoteToPrivate: this.props.quoteToPrivate,
|
||||
overridePrivacy,
|
||||
});
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
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';
|
||||
@@ -56,6 +57,7 @@ const mapStateToProps = state => ({
|
||||
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']),
|
||||
|
||||
@@ -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;
|
||||
@@ -512,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);
|
||||
@@ -653,6 +628,8 @@ class Status extends ImmutablePureComponent {
|
||||
showMedia={this.state.showMedia}
|
||||
onToggleMediaVisibility={this.handleToggleMediaVisibility}
|
||||
pictureInPicture={pictureInPicture}
|
||||
ancestors={this.props.ancestorsIds.length}
|
||||
multiColumn={multiColumn}
|
||||
/>
|
||||
|
||||
<ActionBar
|
||||
|
||||
@@ -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})`,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -213,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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -417,14 +418,16 @@ export const composeReducer = (state = initialState, action) => {
|
||||
const isDirect = state.get('privacy') === 'direct';
|
||||
return state
|
||||
.set('quoted_status_id', isDirect ? null : status.get('id'))
|
||||
.set('spoiler', status.get('sensitive'))
|
||||
.set('spoiler_text', status.get('spoiler_text'))
|
||||
.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)) {
|
||||
@@ -505,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')));
|
||||
|
||||
@@ -643,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');
|
||||
|
||||
@@ -686,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'
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -675,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;
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -31,7 +31,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 'mastodon/actions/dropdown_menu';
|
||||
import { openModal, closeModal } from 'mastodon/actions/modal';
|
||||
import { fetchStatus } from 'mastodon/actions/statuses';
|
||||
import { CircularProgress } from 'mastodon/components/circular_progress';
|
||||
import { isUserTouching } from 'mastodon/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>
|
||||
|
||||
@@ -180,25 +180,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 });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -145,6 +145,7 @@ class Status extends ImmutablePureComponent {
|
||||
'hidden',
|
||||
'unread',
|
||||
'pictureInPicture',
|
||||
'onQuoteCancel',
|
||||
];
|
||||
|
||||
state = {
|
||||
|
||||
@@ -8,13 +8,14 @@ import classNames from 'classnames';
|
||||
import { quoteComposeById } from '@/mastodon/actions/compose_typed';
|
||||
import { toggleReblog } from '@/mastodon/actions/interactions';
|
||||
import { openModal } from '@/mastodon/actions/modal';
|
||||
import { fetchStatus } from '@/mastodon/actions/statuses';
|
||||
import { quickBoosting } from '@/mastodon/initial_state';
|
||||
import type { ActionMenuItem } from '@/mastodon/models/dropdown_menu';
|
||||
import type { Status } from '@/mastodon/models/status';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
import type { SomeRequired } from '@/mastodon/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>
|
||||
|
||||
@@ -27,16 +27,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}
|
||||
>
|
||||
@@ -73,7 +75,7 @@ export const HandledLink: FC<HandledLinkProps & ComponentProps<'a'>> = ({
|
||||
title={href}
|
||||
className={classNames('unhandled-link', className)}
|
||||
target='_blank'
|
||||
rel='noreferrer noopener'
|
||||
rel='noopener'
|
||||
translate='no'
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -404,6 +404,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||
<Dropdown
|
||||
scrollKey={scrollKey}
|
||||
status={status}
|
||||
needsStatusRefresh={quickBoosting && status.get('quote_approval') === null}
|
||||
items={menu}
|
||||
icon='ellipsis-h'
|
||||
iconComponent={MoreHorizIcon}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user