mirror of
https://github.com/mastodon/mastodon.git
synced 2025-08-09 05:08:20 +00:00
Compare commits
260 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
ce1680e6f9 | ||
![]() |
b8982cb881 | ||
![]() |
5d934c2835 | ||
![]() |
868c46bc76 | ||
![]() |
1fd147bf2b | ||
![]() |
8ee4b3f906 | ||
![]() |
a485f97d21 | ||
![]() |
496a5f423e | ||
![]() |
836a2bfee0 | ||
![]() |
39a3ffaf2f | ||
![]() |
d4e0784182 | ||
![]() |
e615d2f069 | ||
![]() |
ac59772dc6 | ||
![]() |
4838085d66 | ||
![]() |
9ec99ffef1 | ||
![]() |
6e48322055 | ||
![]() |
55a98580aa | ||
![]() |
c8f263c419 | ||
![]() |
6f6e7d8d49 | ||
![]() |
fcbd4b7afb | ||
![]() |
4c2ddbf2c4 | ||
![]() |
a4c05c694f | ||
![]() |
a968849e9c | ||
![]() |
ffeb5da991 | ||
![]() |
edece2a197 | ||
![]() |
1fd3510b32 | ||
![]() |
9c0a10f662 | ||
![]() |
54fd1c1f9b | ||
![]() |
8131268256 | ||
![]() |
5318957ab3 | ||
![]() |
081d38679f | ||
![]() |
570c9d16be | ||
![]() |
28b0e5ee78 | ||
![]() |
cb0b608fa7 | ||
![]() |
32791c9745 | ||
![]() |
0153a239db | ||
![]() |
49dcbd22d6 | ||
![]() |
5ed9410de0 | ||
![]() |
eb273f904f | ||
![]() |
d8397040d7 | ||
![]() |
c8ec649830 | ||
![]() |
80aadc55df | ||
![]() |
f68bd21600 | ||
![]() |
59e729e3fe | ||
![]() |
895975e2ab | ||
![]() |
1228e000a1 | ||
![]() |
3d3d2c93d6 | ||
![]() |
3caa318dfe | ||
![]() |
927cfea5ae | ||
![]() |
05cdd3f6eb | ||
![]() |
bf46cffd9e | ||
![]() |
ab1a5b4822 | ||
![]() |
591df1f205 | ||
![]() |
483da67204 | ||
![]() |
fba24cc4eb | ||
![]() |
bcab6a9318 | ||
![]() |
6268321316 | ||
![]() |
29a5f059d2 | ||
![]() |
d09f866daa | ||
![]() |
1d86df685b | ||
![]() |
6bca52453a | ||
![]() |
0e249cba4b | ||
![]() |
19db4cb7c1 | ||
![]() |
b81670776f | ||
![]() |
8452ec6f3b | ||
![]() |
f7388af721 | ||
![]() |
2dfdcc7dcb | ||
![]() |
572a0e128d | ||
![]() |
2131d1ff23 | ||
![]() |
fc1abed0dc | ||
![]() |
e5826777b6 | ||
![]() |
b80e95b2aa | ||
![]() |
2257612deb | ||
![]() |
92bf55afd0 | ||
![]() |
39250ab961 | ||
![]() |
efc0d237af | ||
![]() |
31ba52a57b | ||
![]() |
e8e6cf9510 | ||
![]() |
139025fce0 | ||
![]() |
3146109b08 | ||
![]() |
8896d6c1b1 | ||
![]() |
25add0af31 | ||
![]() |
027657b590 | ||
![]() |
7e6b134222 | ||
![]() |
4042bc959b | ||
![]() |
6dc55a2f4e | ||
![]() |
15b72591d4 | ||
![]() |
fd779c25b9 | ||
![]() |
ece49baa38 | ||
![]() |
ba9fa54f9c | ||
![]() |
1c89309db0 | ||
![]() |
a368b29e27 | ||
![]() |
20bbd20ef1 | ||
![]() |
8cf7a77808 | ||
![]() |
d121007927 | ||
![]() |
3eca8cce1c | ||
![]() |
d299b0d576 | ||
![]() |
ea976a5ffb | ||
![]() |
bedbab74b9 | ||
![]() |
c587c44975 | ||
![]() |
f1b9868980 | ||
![]() |
8d6f033326 | ||
![]() |
b5cebf45ea | ||
![]() |
513b6289d6 | ||
![]() |
040a638ab9 | ||
![]() |
eb73ae2f86 | ||
![]() |
916cc1365e | ||
![]() |
86ef4d4884 | ||
![]() |
456c3bda0b | ||
![]() |
63daf6b317 | ||
![]() |
e183d7dd9a | ||
![]() |
2acc942bb4 | ||
![]() |
038de44110 | ||
![]() |
3b01f98c11 | ||
![]() |
7cd3738c19 | ||
![]() |
018e5e303f | ||
![]() |
a57a9505d4 | ||
![]() |
720ee96969 | ||
![]() |
73f72ec8fe | ||
![]() |
5d69157e62 | ||
![]() |
b464b87c2b | ||
![]() |
9d0d6f011c | ||
![]() |
f3786e0816 | ||
![]() |
e93efe0e13 | ||
![]() |
5a88b7f683 | ||
![]() |
81da377d8e | ||
![]() |
d950298d29 | ||
![]() |
2e35defeec | ||
![]() |
960f693219 | ||
![]() |
e5e977c24f | ||
![]() |
a863e68d17 | ||
![]() |
847b37552a | ||
![]() |
dfaca794bf | ||
![]() |
6fc77a545b | ||
![]() |
c871c7398e | ||
![]() |
8baed8b90e | ||
![]() |
8a1c43bf3b | ||
![]() |
5c01ccc31f | ||
![]() |
67be8208db | ||
![]() |
7d136feccf | ||
![]() |
e54e96d61f | ||
![]() |
469304359a | ||
![]() |
290e36d7e8 | ||
![]() |
4241ce9888 | ||
![]() |
7f9ad7eabf | ||
![]() |
a6794c066d | ||
![]() |
7d3ef27a8d | ||
![]() |
14a781fa24 | ||
![]() |
cec26d58c8 | ||
![]() |
593cdae404 | ||
![]() |
d2ef9ac04a | ||
![]() |
d065ec9298 | ||
![]() |
b19131202f | ||
![]() |
70058ae49d | ||
![]() |
62a23b1985 | ||
![]() |
6917cd2f40 | ||
![]() |
d36236cbcd | ||
![]() |
760d00b7f7 | ||
![]() |
0af2c4829f | ||
![]() |
be3dc5b508 | ||
![]() |
ae13063460 | ||
![]() |
1ed58aaaf2 | ||
![]() |
bf17895d19 | ||
![]() |
20b3c43dde | ||
![]() |
ee21f72211 | ||
![]() |
4de5cbd6f5 | ||
![]() |
fab95b8dae | ||
![]() |
4d2655490c | ||
![]() |
6bb4113d0a | ||
![]() |
3e76f01db4 | ||
![]() |
cf580d8c90 | ||
![]() |
dbd0c3cbd9 | ||
![]() |
3771f9e04b | ||
![]() |
a842b14c84 | ||
![]() |
138746bdcc | ||
![]() |
9e6a9efe10 | ||
![]() |
19626ad89f | ||
![]() |
7e2d92284c | ||
![]() |
20fb6bd788 | ||
![]() |
faffb73cbd | ||
![]() |
02a4e30594 | ||
![]() |
f10b522f0c | ||
![]() |
331599fa2b | ||
![]() |
558b9c90a6 | ||
![]() |
7d2dda97b3 | ||
![]() |
74fc4dbacf | ||
![]() |
07912a1cb7 | ||
![]() |
d36bf3b6fb | ||
![]() |
594976a538 | ||
![]() |
0efb889a9c | ||
![]() |
c0eabe289b | ||
![]() |
5bbc3c5ebb | ||
![]() |
d5e2cf5d3c | ||
![]() |
82a6ff091f | ||
![]() |
4b8e60682d | ||
![]() |
6c2db9b1cf | ||
![]() |
30344d6abf | ||
![]() |
1637297085 | ||
![]() |
dec1fb71f4 | ||
![]() |
7273f6c03c | ||
![]() |
a3ffd2edf8 | ||
![]() |
a2c5eace88 | ||
![]() |
a643d9d498 | ||
![]() |
3b52dca405 | ||
![]() |
853a0c466e | ||
![]() |
94bceb8683 | ||
![]() |
88b0f3a172 | ||
![]() |
b69b5ba775 | ||
![]() |
c442589593 | ||
![]() |
28633a504a | ||
![]() |
ad78701b6f | ||
![]() |
1496488771 | ||
![]() |
dd3d958e75 | ||
![]() |
b363a3651d | ||
![]() |
86645fc14c | ||
![]() |
f9beecb343 | ||
![]() |
4ecfbd3920 | ||
![]() |
a315934314 | ||
![]() |
e9170e2de1 | ||
![]() |
5cfc1fabcf | ||
![]() |
786b12e379 | ||
![]() |
e7c5c25de8 | ||
![]() |
a1e8813522 | ||
![]() |
76c1446416 | ||
![]() |
8bd2c87399 | ||
![]() |
1e2d77f2c7 | ||
![]() |
fb6c22f5c2 | ||
![]() |
f7259f625f | ||
![]() |
b628a98d32 | ||
![]() |
d8fa807998 | ||
![]() |
ef66d8379c | ||
![]() |
8ee6cee36e | ||
![]() |
71b2120e5c | ||
![]() |
b10078633c | ||
![]() |
b5eebd4d2b | ||
![]() |
fdefc4d2b4 | ||
![]() |
f6b2609353 | ||
![]() |
bdffdcb12f | ||
![]() |
1ebb87a6a8 | ||
![]() |
83660ee381 | ||
![]() |
1fa72d6c44 | ||
![]() |
5a7c0d42f7 | ||
![]() |
e8d2432e6a | ||
![]() |
2af17adc34 | ||
![]() |
e97f43399b | ||
![]() |
c66c5fd73d | ||
![]() |
3c0767f543 | ||
![]() |
70cd1fdc63 | ||
![]() |
39028dde40 | ||
![]() |
6e39b5ef04 | ||
![]() |
49db8a9662 | ||
![]() |
2cfa6cb0e0 | ||
![]() |
1ae3510ede | ||
![]() |
6f1135d763 | ||
![]() |
52bc2f64f4 | ||
![]() |
b1375328e1 | ||
![]() |
9443e2cc4b | ||
![]() |
3a533c6c8d | ||
![]() |
c047014214 | ||
![]() |
68b05e994f |
|
@ -5,6 +5,7 @@
|
||||||
.gitattributes
|
.gitattributes
|
||||||
.gitignore
|
.gitignore
|
||||||
.github
|
.github
|
||||||
|
.vscode
|
||||||
public/system
|
public/system
|
||||||
public/assets
|
public/assets
|
||||||
public/packs
|
public/packs
|
||||||
|
@ -20,6 +21,7 @@ postgres14
|
||||||
redis
|
redis
|
||||||
elasticsearch
|
elasticsearch
|
||||||
chart
|
chart
|
||||||
|
storybook-static
|
||||||
.yarn/
|
.yarn/
|
||||||
!.yarn/patches
|
!.yarn/patches
|
||||||
!.yarn/plugins
|
!.yarn/plugins
|
||||||
|
|
2
.github/ISSUE_TEMPLATE/1.web_bug_report.yml
vendored
2
.github/ISSUE_TEMPLATE/1.web_bug_report.yml
vendored
|
@ -1,6 +1,6 @@
|
||||||
name: Bug Report (Web Interface)
|
name: Bug Report (Web Interface)
|
||||||
description: There is a problem using Mastodon's web interface.
|
description: There is a problem using Mastodon's web interface.
|
||||||
labels: ['status/to triage', 'area/web interface']
|
labels: ['area/web interface']
|
||||||
type: Bug
|
type: Bug
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
name: Bug Report (server / API)
|
name: Bug Report (server / API)
|
||||||
description: |
|
description: |
|
||||||
There is a problem with the HTTP server, REST API, ActivityPub interaction, etc.
|
There is a problem with the HTTP server, REST API, ActivityPub interaction, etc.
|
||||||
labels: ['status/to triage']
|
|
||||||
type: 'Bug'
|
type: 'Bug'
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
|
|
1
.github/renovate.json5
vendored
1
.github/renovate.json5
vendored
|
@ -23,7 +23,6 @@
|
||||||
matchManagers: ['npm'],
|
matchManagers: ['npm'],
|
||||||
matchPackageNames: [
|
matchPackageNames: [
|
||||||
'tesseract.js', // Requires code changes
|
'tesseract.js', // Requires code changes
|
||||||
'react-hotkeys', // Requires code changes
|
|
||||||
|
|
||||||
// react-router: Requires manual upgrade
|
// react-router: Requires manual upgrade
|
||||||
'history',
|
'history',
|
||||||
|
|
|
@ -50,7 +50,7 @@ jobs:
|
||||||
|
|
||||||
# Create or update the pull request
|
# Create or update the pull request
|
||||||
- name: Create Pull Request
|
- name: Create Pull Request
|
||||||
uses: peter-evans/create-pull-request@v7.0.6
|
uses: peter-evans/create-pull-request@v7.0.8
|
||||||
with:
|
with:
|
||||||
commit-message: 'New Crowdin translations'
|
commit-message: 'New Crowdin translations'
|
||||||
title: 'New Crowdin Translations for ${{ github.base_ref || github.ref_name }} (automated)'
|
title: 'New Crowdin Translations for ${{ github.base_ref || github.ref_name }} (automated)'
|
||||||
|
|
1
.github/workflows/crowdin-upload.yml
vendored
1
.github/workflows/crowdin-upload.yml
vendored
|
@ -14,6 +14,7 @@ on:
|
||||||
- config/locales/devise.en.yml
|
- config/locales/devise.en.yml
|
||||||
- config/locales/doorkeeper.en.yml
|
- config/locales/doorkeeper.en.yml
|
||||||
- .github/workflows/crowdin-upload.yml
|
- .github/workflows/crowdin-upload.yml
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
upload-translations:
|
upload-translations:
|
||||||
|
|
|
@ -1,15 +1,11 @@
|
||||||
# This configuration was generated by
|
# This configuration was generated by
|
||||||
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp`
|
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp`
|
||||||
# using RuboCop version 1.77.0.
|
# using RuboCop version 1.79.2.
|
||||||
# The point is for the user to remove these configuration records
|
# The point is for the user to remove these configuration records
|
||||||
# one by one as the offenses are removed from the code base.
|
# one by one as the offenses are removed from the code base.
|
||||||
# Note that changes in the inspected code, or installation of new
|
# Note that changes in the inspected code, or installation of new
|
||||||
# versions of RuboCop, may require this file to be generated again.
|
# versions of RuboCop, may require this file to be generated again.
|
||||||
|
|
||||||
Lint/NonLocalExitFromIterator:
|
|
||||||
Exclude:
|
|
||||||
- 'app/helpers/json_ld_helper.rb'
|
|
||||||
|
|
||||||
# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes.
|
# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes.
|
||||||
Metrics/AbcSize:
|
Metrics/AbcSize:
|
||||||
Max: 82
|
Max: 82
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
3.4.4
|
3.4.5
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
import type { StorybookConfig } from '@storybook/react-vite';
|
import type { StorybookConfig } from '@storybook/react-vite';
|
||||||
|
|
||||||
const config: StorybookConfig = {
|
const config: StorybookConfig = {
|
||||||
|
@ -26,6 +28,12 @@ const config: StorybookConfig = {
|
||||||
'oops.png',
|
'oops.png',
|
||||||
].map((path) => ({ from: `../public/${path}`, to: `/${path}` })),
|
].map((path) => ({ from: `../public/${path}`, to: `/${path}` })),
|
||||||
],
|
],
|
||||||
|
viteFinal(config) {
|
||||||
|
// For an unknown reason, Storybook does not use the root
|
||||||
|
// from the Vite config so we need to set it manually.
|
||||||
|
config.root = resolve(__dirname, '../app/javascript');
|
||||||
|
return config;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|
63
CHANGELOG.md
63
CHANGELOG.md
|
@ -2,7 +2,59 @@
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
## [4.4.0] - UNRELEASED
|
## [4.4.3] - 2025-08-05
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Update dependencies
|
||||||
|
- Fix incorrect rate-limit handling [GHSA-84ch-6436-c7mg](https://github.com/mastodon/mastodon/security/advisories/GHSA-84ch-6436-c7mg)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fix race condition caused by ActiveRecord query cache in `Create` critical path (#35662 by @ClearlyClaire)
|
||||||
|
- Fix race condition caused by quote post processing (#35657 by @ClearlyClaire)
|
||||||
|
- Fix WebUI crashing for accounts with `null` URL (#35651 by @ClearlyClaire)
|
||||||
|
- Fix friends-of-friends recommendations suggesting already-requested accounts (#35604 by @ClearlyClaire)
|
||||||
|
- Fix synchronous recursive fetching of deeply-nested quoted posts (#35600 by @ClearlyClaire)
|
||||||
|
- Fix “Expand this post” link including user `@undefined` (#35478 by @ClearlyClaire)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Change `StatusReachFinder` to consider quotes as well as reblogs (#35601 by @ClearlyClaire)
|
||||||
|
- Add restrictions on which quote posts can trend (#35507 by @ClearlyClaire)
|
||||||
|
- Change quote verification to not bypass authorization flow for mentions (#35528 by @ClearlyClaire)
|
||||||
|
|
||||||
|
## [4.4.2] - 2025-07-23
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Update dependencies
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fix menu not clickable in Firefox (#35390 and #35414 by @diondiondion)
|
||||||
|
- Add `lang` attribute to current composer language in alt text modal (#35412 by @diondiondion)
|
||||||
|
- Fix quote posts styling on notifications page (#35411 by @diondiondion)
|
||||||
|
- Improve a11y of custom select menus in notifications settings (#35403 by @diondiondion)
|
||||||
|
- Fix selected item in poll select menus is unreadable in Firefox (#35402 by @diondiondion)
|
||||||
|
- Update age limit wording (#35387 by @diondiondion)
|
||||||
|
- Fix support for quote verification in implicit status updates (#35384 by @ClearlyClaire)
|
||||||
|
- Improve `Dropdown` component accessibility (#35373 by @diondiondion)
|
||||||
|
- Fix processing some incoming quotes failing because of missing JSON-LD context (#35354 and #35380 by @ClearlyClaire)
|
||||||
|
- Make bio hashtags open the local page instead of the remote instance (#35349 by @ChaosExAnima)
|
||||||
|
- Fix styling of external log-in button (#35320 by @ClearlyClaire)
|
||||||
|
|
||||||
|
## [4.4.1] - 2025-07-09
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fix nearly every sub-directory being crawled as part of Vite build (#35323 by @ClearlyClaire)
|
||||||
|
- Fix assets not building when Redis is unavailable (#35321 by @oneiros)
|
||||||
|
- Fix replying from media modal or pop-in-player tagging user `@undefined` (#35317 by @ClearlyClaire)
|
||||||
|
- Fix support for special characters in various environment variables (#35314 by @mjankowski and @ClearlyClaire)
|
||||||
|
- Fix some database migrations failing for indexes manually removed by admins (#35309 by @mjankowski)
|
||||||
|
|
||||||
|
## [4.4.0] - 2025-07-08
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
@ -38,7 +90,7 @@ All notable changes to this project will be documented in this file.
|
||||||
Server administrators can now chose to opt in to transmit referrer information when following an external link. Only the domain name is transmitted, not the referrer path.
|
Server administrators can now chose to opt in to transmit referrer information when following an external link. Only the domain name is transmitted, not the referrer path.
|
||||||
- Add double tap to zoom and swipe to dismiss to media modal in web UI (#34210 by @Gargron)
|
- Add double tap to zoom and swipe to dismiss to media modal in web UI (#34210 by @Gargron)
|
||||||
- Add link from Web UI for Hashtags to the Moderation UI (#31448 by @ThisIsMissEm)
|
- Add link from Web UI for Hashtags to the Moderation UI (#31448 by @ThisIsMissEm)
|
||||||
- **Add terms of service** (#33055, #33233, #33230, #33703, #33699, #33994, #33993, #34105, #34122, #34200, #34527, #35053, #35115, #35126 and #35127 by @ClearlyClaire, @Gargron, @mjankowski, and @oneiros)\
|
- **Add terms of service** (#33055, #33233, #33230, #33703, #33699, #33994, #33993, #34105, #34122, #34200, #34527, #35053, #35115, #35126, #35127 and #35233 by @ClearlyClaire, @Gargron, @mjankowski, and @oneiros)\
|
||||||
Server administrators can now fill in Terms of Service and notify their users of upcoming changes.
|
Server administrators can now fill in Terms of Service and notify their users of upcoming changes.
|
||||||
- Add optional bulk mailer settings (#35191 and #35203 by @oneiros)\
|
- Add optional bulk mailer settings (#35191 and #35203 by @oneiros)\
|
||||||
This adds the optional environment variables `BULK_SMTP_PORT`, `BULK_SMTP_SERVER`, `BULK_SMTP_LOGIN` and so on analogous to `SMTP_PORT`, `SMTP_SERVER`, `SMTP_LOGIN` and related SMTP configuration environment variables.\
|
This adds the optional environment variables `BULK_SMTP_PORT`, `BULK_SMTP_SERVER`, `BULK_SMTP_LOGIN` and so on analogous to `SMTP_PORT`, `SMTP_SERVER`, `SMTP_LOGIN` and related SMTP configuration environment variables.\
|
||||||
|
@ -51,7 +103,7 @@ All notable changes to this project will be documented in this file.
|
||||||
- Add ability to dismiss alt text badge by tapping it in web UI (#33737 by @Gargron)
|
- Add ability to dismiss alt text badge by tapping it in web UI (#33737 by @Gargron)
|
||||||
- Add loading indicator to timeline gap indicators in web UI (#33762 by @Gargron)
|
- Add loading indicator to timeline gap indicators in web UI (#33762 by @Gargron)
|
||||||
- Add interaction modal when trying to interact with a poll while logged out (#32609 by @ThisIsMissEm)
|
- Add interaction modal when trying to interact with a poll while logged out (#32609 by @ThisIsMissEm)
|
||||||
- **Add experimental FASP support** (#34031, #34415, #34765, #34965, #34964, #34033 and #35218 by @oneiros)\
|
- **Add experimental FASP support** (#34031, #34415, #34765, #34965, #34964, #34033, #35218, #35262 and #35263 by @oneiros)\
|
||||||
This is a first step towards supporting “Fediverse Auxiliary Service Providers” (https://github.com/mastodon/fediverse_auxiliary_service_provider_specifications). This is mostly interesting to developers who would like to implement their own FASP, but also includes the capability to share data with a discovery provider (see https://www.fediscovery.org).
|
This is a first step towards supporting “Fediverse Auxiliary Service Providers” (https://github.com/mastodon/fediverse_auxiliary_service_provider_specifications). This is mostly interesting to developers who would like to implement their own FASP, but also includes the capability to share data with a discovery provider (see https://www.fediscovery.org).
|
||||||
- Add ability for admins to send announcements to all users via email (#33928 and #34411 by @ClearlyClaire)\
|
- Add ability for admins to send announcements to all users via email (#33928 and #34411 by @ClearlyClaire)\
|
||||||
This is meant for critical announcements only, as this will potentially send a lot of emails and cannot be opted out of by users.
|
This is meant for critical announcements only, as this will potentially send a lot of emails and cannot be opted out of by users.
|
||||||
|
@ -64,7 +116,7 @@ All notable changes to this project will be documented in this file.
|
||||||
- Add dropdown menu with quick actions to lists of accounts in web UI (#34391, #34709, and #34767 by @Gargron, @diondiondion, and @mkljczk)
|
- Add dropdown menu with quick actions to lists of accounts in web UI (#34391, #34709, and #34767 by @Gargron, @diondiondion, and @mkljczk)
|
||||||
- Add support for displaying “year in review” notification in web UI (#32710, #32765, #32709, #32807, #32914, #33148, and #33882 by @Gargron and @mjankowski)\
|
- Add support for displaying “year in review” notification in web UI (#32710, #32765, #32709, #32807, #32914, #33148, and #33882 by @Gargron and @mjankowski)\
|
||||||
Note that the notification is currently not generated automatically, and at the moment requires a manual undocumented administrator action.
|
Note that the notification is currently not generated automatically, and at the moment requires a manual undocumented administrator action.
|
||||||
- Add experimental support for receiving HTTP Message Signatures (RFC9421) (#34814, #35033 and #35109 by @oneiros)\
|
- Add experimental support for receiving HTTP Message Signatures (RFC9421) (#34814, #35033, #35109 and #35278 by @oneiros)\
|
||||||
For now, this needs to be explicitly enabled through the `http_message_signatures` feature flag (`EXPERIMENTAL_FEATURES=http_message_signatures`). This currently only covers verifying such signatures (inbound HTTP requests), not issuing them (outbound HTTP requests).
|
For now, this needs to be explicitly enabled through the `http_message_signatures` feature flag (`EXPERIMENTAL_FEATURES=http_message_signatures`). This currently only covers verifying such signatures (inbound HTTP requests), not issuing them (outbound HTTP requests).
|
||||||
- Add experimental Async Refreshes API (#34918 by @oneiros)
|
- Add experimental Async Refreshes API (#34918 by @oneiros)
|
||||||
- Add experimental server-side feature to fetch remote replies (#32615, #34147, #34149, #34151, #34615, #34682, and #34702 by @ClearlyClaire and @sneakers-the-rat)\
|
- Add experimental server-side feature to fetch remote replies (#32615, #34147, #34149, #34151, #34615, #34682, and #34702 by @ClearlyClaire and @sneakers-the-rat)\
|
||||||
|
@ -218,6 +270,7 @@ All notable changes to this project will be documented in this file.
|
||||||
- Fix admin dashboard crash on specific Elasticsearch connection errors (#34683 by @ClearlyClaire)
|
- Fix admin dashboard crash on specific Elasticsearch connection errors (#34683 by @ClearlyClaire)
|
||||||
- Fix OIDC account creation failing for long display names (#34639 by @defnull)
|
- Fix OIDC account creation failing for long display names (#34639 by @defnull)
|
||||||
- Fix use of the deprecated `/api/v1/instance` endpoint in the moderation interface (#34613 by @renchap)
|
- Fix use of the deprecated `/api/v1/instance` endpoint in the moderation interface (#34613 by @renchap)
|
||||||
|
- Fix inaccessible “Clear search” button (#35152 and #35281 by @diondiondion)
|
||||||
- Fix search operators sometimes getting lost (#35190 by @ClearlyClaire)
|
- Fix search operators sometimes getting lost (#35190 by @ClearlyClaire)
|
||||||
- Fix directory scroll position reset (#34560 by @przucidlo)
|
- Fix directory scroll position reset (#34560 by @przucidlo)
|
||||||
- Fix needlessly complex SVG paths for oEmbed and logo (#34538 by @edent)
|
- Fix needlessly complex SVG paths for oEmbed and logo (#34538 by @edent)
|
||||||
|
@ -232,7 +285,7 @@ All notable changes to this project will be documented in this file.
|
||||||
- Fix extra space under left-indented vertical videos (#34313 by @ClearlyClaire)
|
- Fix extra space under left-indented vertical videos (#34313 by @ClearlyClaire)
|
||||||
- Fix glitchy iOS media attachment drag interactions (#35057 by @diondiondion)
|
- Fix glitchy iOS media attachment drag interactions (#35057 by @diondiondion)
|
||||||
- Fix zoomed images being blurry in Safari (#35052 by @diondiondion)
|
- Fix zoomed images being blurry in Safari (#35052 by @diondiondion)
|
||||||
- Fix redundant focus stop within status component in Web UI and make focus style more noticeable (#35037, #35051, #35096 and #35150 by @diondiondion)
|
- Fix redundant focus stop within status component in Web UI and make focus style more noticeable (#35037, #35051, #35096, #35150 and #35251 by @diondiondion)
|
||||||
- Fix digits in media player time readout not having a consistent width (#35038 by @diondiondion)
|
- Fix digits in media player time readout not having a consistent width (#35038 by @diondiondion)
|
||||||
- Fix wrong text color for “Open in advanced web interface” banner in high-contrast theme (#35032 by @diondiondion)
|
- Fix wrong text color for “Open in advanced web interface” banner in high-contrast theme (#35032 by @diondiondion)
|
||||||
- Fix hover card for limited accounts not hiding information as expected (#35024 by @diondiondion)
|
- Fix hover card for limited accounts not hiding information as expected (#35024 by @diondiondion)
|
||||||
|
|
|
@ -13,7 +13,7 @@ ARG BASE_REGISTRY="docker.io"
|
||||||
|
|
||||||
# Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.4.x"]
|
# Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.4.x"]
|
||||||
# renovate: datasource=docker depName=docker.io/ruby
|
# renovate: datasource=docker depName=docker.io/ruby
|
||||||
ARG RUBY_VERSION="3.4.4"
|
ARG RUBY_VERSION="3.4.5"
|
||||||
# # Node.js version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"]
|
# # Node.js version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"]
|
||||||
# renovate: datasource=node-version depName=node
|
# renovate: datasource=node-version depName=node
|
||||||
ARG NODE_MAJOR_VERSION="22"
|
ARG NODE_MAJOR_VERSION="22"
|
||||||
|
@ -186,7 +186,7 @@ FROM build AS libvips
|
||||||
|
|
||||||
# libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"]
|
# libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"]
|
||||||
# renovate: datasource=github-releases depName=libvips packageName=libvips/libvips
|
# renovate: datasource=github-releases depName=libvips packageName=libvips/libvips
|
||||||
ARG VIPS_VERSION=8.17.0
|
ARG VIPS_VERSION=8.17.1
|
||||||
# libvips download URL, change with [--build-arg VIPS_URL="https://github.com/libvips/libvips/releases/download"]
|
# libvips download URL, change with [--build-arg VIPS_URL="https://github.com/libvips/libvips/releases/download"]
|
||||||
ARG VIPS_URL=https://github.com/libvips/libvips/releases/download
|
ARG VIPS_URL=https://github.com/libvips/libvips/releases/download
|
||||||
|
|
||||||
|
|
4
Gemfile
4
Gemfile
|
@ -62,7 +62,7 @@ gem 'inline_svg'
|
||||||
gem 'irb', '~> 1.8'
|
gem 'irb', '~> 1.8'
|
||||||
gem 'kaminari', '~> 1.2'
|
gem 'kaminari', '~> 1.2'
|
||||||
gem 'link_header', '~> 0.0'
|
gem 'link_header', '~> 0.0'
|
||||||
gem 'linzer', '~> 0.7.2'
|
gem 'linzer', '~> 0.7.7'
|
||||||
gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock'
|
gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock'
|
||||||
gem 'mime-types', '~> 3.7.0', require: 'mime/types/columnar'
|
gem 'mime-types', '~> 3.7.0', require: 'mime/types/columnar'
|
||||||
gem 'mutex_m'
|
gem 'mutex_m'
|
||||||
|
@ -84,7 +84,7 @@ gem 'sanitize', '~> 7.0'
|
||||||
gem 'scenic', '~> 1.7'
|
gem 'scenic', '~> 1.7'
|
||||||
gem 'sidekiq', '< 8'
|
gem 'sidekiq', '< 8'
|
||||||
gem 'sidekiq-bulk', '~> 0.2.0'
|
gem 'sidekiq-bulk', '~> 0.2.0'
|
||||||
gem 'sidekiq-scheduler', '~> 5.0'
|
gem 'sidekiq-scheduler', '~> 6.0'
|
||||||
gem 'sidekiq-unique-jobs', '> 8'
|
gem 'sidekiq-unique-jobs', '> 8'
|
||||||
gem 'simple_form', '~> 5.2'
|
gem 'simple_form', '~> 5.2'
|
||||||
gem 'simple-navigation', '~> 4.4'
|
gem 'simple-navigation', '~> 4.4'
|
||||||
|
|
141
Gemfile.lock
141
Gemfile.lock
|
@ -90,13 +90,13 @@ GEM
|
||||||
public_suffix (>= 2.0.2, < 7.0)
|
public_suffix (>= 2.0.2, < 7.0)
|
||||||
aes_key_wrap (1.1.0)
|
aes_key_wrap (1.1.0)
|
||||||
android_key_attestation (0.3.0)
|
android_key_attestation (0.3.0)
|
||||||
annotaterb (4.16.0)
|
annotaterb (4.18.0)
|
||||||
activerecord (>= 6.0.0)
|
activerecord (>= 6.0.0)
|
||||||
activesupport (>= 6.0.0)
|
activesupport (>= 6.0.0)
|
||||||
ast (2.4.3)
|
ast (2.4.3)
|
||||||
attr_required (1.0.2)
|
attr_required (1.0.2)
|
||||||
aws-eventstream (1.3.2)
|
aws-eventstream (1.4.0)
|
||||||
aws-partitions (1.1103.0)
|
aws-partitions (1.1135.0)
|
||||||
aws-sdk-core (3.215.1)
|
aws-sdk-core (3.215.1)
|
||||||
aws-eventstream (~> 1, >= 1.3.0)
|
aws-eventstream (~> 1, >= 1.3.0)
|
||||||
aws-partitions (~> 1, >= 1.992.0)
|
aws-partitions (~> 1, >= 1.992.0)
|
||||||
|
@ -109,9 +109,9 @@ GEM
|
||||||
aws-sdk-core (~> 3, >= 3.210.0)
|
aws-sdk-core (~> 3, >= 3.210.0)
|
||||||
aws-sdk-kms (~> 1)
|
aws-sdk-kms (~> 1)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sigv4 (1.11.0)
|
aws-sigv4 (1.12.1)
|
||||||
aws-eventstream (~> 1, >= 1.0.2)
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
azure-blob (0.5.8)
|
azure-blob (0.5.9.1)
|
||||||
rexml
|
rexml
|
||||||
base64 (0.3.0)
|
base64 (0.3.0)
|
||||||
bcp47_spec (0.2.1)
|
bcp47_spec (0.2.1)
|
||||||
|
@ -144,7 +144,7 @@ GEM
|
||||||
rack-test (>= 0.6.3)
|
rack-test (>= 0.6.3)
|
||||||
regexp_parser (>= 1.5, < 3.0)
|
regexp_parser (>= 1.5, < 3.0)
|
||||||
xpath (~> 3.2)
|
xpath (~> 3.2)
|
||||||
capybara-playwright-driver (0.5.6)
|
capybara-playwright-driver (0.5.7)
|
||||||
addressable
|
addressable
|
||||||
capybara
|
capybara
|
||||||
playwright-ruby-client (>= 1.16.0)
|
playwright-ruby-client (>= 1.16.0)
|
||||||
|
@ -175,9 +175,9 @@ GEM
|
||||||
css_parser (1.21.1)
|
css_parser (1.21.1)
|
||||||
addressable
|
addressable
|
||||||
csv (3.3.5)
|
csv (3.3.5)
|
||||||
database_cleaner-active_record (2.2.1)
|
database_cleaner-active_record (2.2.2)
|
||||||
activerecord (>= 5.a)
|
activerecord (>= 5.a)
|
||||||
database_cleaner-core (~> 2.0.0)
|
database_cleaner-core (~> 2.0)
|
||||||
database_cleaner-core (2.0.1)
|
database_cleaner-core (2.0.1)
|
||||||
date (3.4.1)
|
date (3.4.1)
|
||||||
debug (1.11.0)
|
debug (1.11.0)
|
||||||
|
@ -224,16 +224,16 @@ GEM
|
||||||
mail (~> 2.7)
|
mail (~> 2.7)
|
||||||
email_validator (2.2.4)
|
email_validator (2.2.4)
|
||||||
activemodel
|
activemodel
|
||||||
erb (5.0.1)
|
erb (5.0.2)
|
||||||
erubi (1.13.1)
|
erubi (1.13.1)
|
||||||
et-orbi (1.2.11)
|
et-orbi (1.2.11)
|
||||||
tzinfo
|
tzinfo
|
||||||
excon (1.2.5)
|
excon (1.2.8)
|
||||||
logger
|
logger
|
||||||
fabrication (3.0.0)
|
fabrication (3.0.0)
|
||||||
faker (3.5.1)
|
faker (3.5.2)
|
||||||
i18n (>= 1.8.11, < 2)
|
i18n (>= 1.8.11, < 2)
|
||||||
faraday (2.13.1)
|
faraday (2.13.4)
|
||||||
faraday-net_http (>= 2.0, < 3.5)
|
faraday-net_http (>= 2.0, < 3.5)
|
||||||
json
|
json
|
||||||
logger
|
logger
|
||||||
|
@ -241,7 +241,7 @@ GEM
|
||||||
faraday (>= 1, < 3)
|
faraday (>= 1, < 3)
|
||||||
faraday-httpclient (2.0.2)
|
faraday-httpclient (2.0.2)
|
||||||
httpclient (>= 2.2)
|
httpclient (>= 2.2)
|
||||||
faraday-net_http (3.4.0)
|
faraday-net_http (3.4.1)
|
||||||
net-http (>= 0.5.0)
|
net-http (>= 0.5.0)
|
||||||
fast_blank (1.0.1)
|
fast_blank (1.0.1)
|
||||||
fastimage (2.4.0)
|
fastimage (2.4.0)
|
||||||
|
@ -266,14 +266,14 @@ GEM
|
||||||
fog-openstack (1.1.5)
|
fog-openstack (1.1.5)
|
||||||
fog-core (~> 2.1)
|
fog-core (~> 2.1)
|
||||||
fog-json (>= 1.0)
|
fog-json (>= 1.0)
|
||||||
formatador (1.1.0)
|
formatador (1.1.1)
|
||||||
forwardable (1.3.3)
|
forwardable (1.3.3)
|
||||||
fugit (1.11.1)
|
fugit (1.11.1)
|
||||||
et-orbi (~> 1, >= 1.2.11)
|
et-orbi (~> 1, >= 1.2.11)
|
||||||
raabro (~> 1.4)
|
raabro (~> 1.4)
|
||||||
globalid (1.2.1)
|
globalid (1.2.1)
|
||||||
activesupport (>= 6.1)
|
activesupport (>= 6.1)
|
||||||
google-protobuf (4.31.0)
|
google-protobuf (4.31.1)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
rake (>= 13)
|
rake (>= 13)
|
||||||
googleapis-common-protos-types (1.20.0)
|
googleapis-common-protos-types (1.20.0)
|
||||||
|
@ -287,21 +287,21 @@ GEM
|
||||||
activesupport (>= 5.1)
|
activesupport (>= 5.1)
|
||||||
haml (>= 4.0.6)
|
haml (>= 4.0.6)
|
||||||
railties (>= 5.1)
|
railties (>= 5.1)
|
||||||
haml_lint (0.64.0)
|
haml_lint (0.66.0)
|
||||||
haml (>= 5.0)
|
haml (>= 5.0)
|
||||||
parallel (~> 1.10)
|
parallel (~> 1.10)
|
||||||
rainbow
|
rainbow
|
||||||
rubocop (>= 1.0)
|
rubocop (>= 1.0)
|
||||||
sysexits (~> 1.1)
|
sysexits (~> 1.1)
|
||||||
hashdiff (1.1.2)
|
hashdiff (1.2.0)
|
||||||
hashie (5.0.0)
|
hashie (5.0.0)
|
||||||
hcaptcha (7.1.0)
|
hcaptcha (7.1.0)
|
||||||
json
|
json
|
||||||
highline (3.1.2)
|
highline (3.1.2)
|
||||||
reline
|
reline
|
||||||
hiredis (0.6.3)
|
hiredis (0.6.3)
|
||||||
hiredis-client (0.24.0)
|
hiredis-client (0.25.1)
|
||||||
redis-client (= 0.24.0)
|
redis-client (= 0.25.1)
|
||||||
hkdf (0.3.0)
|
hkdf (0.3.0)
|
||||||
htmlentities (4.3.4)
|
htmlentities (4.3.4)
|
||||||
http (5.3.1)
|
http (5.3.1)
|
||||||
|
@ -315,7 +315,7 @@ GEM
|
||||||
http_accept_language (2.1.1)
|
http_accept_language (2.1.1)
|
||||||
httpclient (2.9.0)
|
httpclient (2.9.0)
|
||||||
mutex_m
|
mutex_m
|
||||||
httplog (1.7.0)
|
httplog (1.7.3)
|
||||||
rack (>= 2.0)
|
rack (>= 2.0)
|
||||||
rainbow (>= 2.0.0)
|
rainbow (>= 2.0.0)
|
||||||
i18n (1.14.7)
|
i18n (1.14.7)
|
||||||
|
@ -335,7 +335,7 @@ GEM
|
||||||
inline_svg (1.10.0)
|
inline_svg (1.10.0)
|
||||||
activesupport (>= 3.0)
|
activesupport (>= 3.0)
|
||||||
nokogiri (>= 1.6)
|
nokogiri (>= 1.6)
|
||||||
io-console (0.8.0)
|
io-console (0.8.1)
|
||||||
irb (1.15.2)
|
irb (1.15.2)
|
||||||
pp (>= 0.6.0)
|
pp (>= 0.6.0)
|
||||||
rdoc (>= 4.0.0)
|
rdoc (>= 4.0.0)
|
||||||
|
@ -345,7 +345,7 @@ GEM
|
||||||
azure-blob (~> 0.5.2)
|
azure-blob (~> 0.5.2)
|
||||||
hashie (~> 5.0)
|
hashie (~> 5.0)
|
||||||
jmespath (1.6.2)
|
jmespath (1.6.2)
|
||||||
json (2.12.2)
|
json (2.13.2)
|
||||||
json-canonicalization (1.0.0)
|
json-canonicalization (1.0.0)
|
||||||
json-jwt (1.16.7)
|
json-jwt (1.16.7)
|
||||||
activesupport (>= 4.2)
|
activesupport (>= 4.2)
|
||||||
|
@ -362,14 +362,14 @@ GEM
|
||||||
rack (>= 2.2, < 4)
|
rack (>= 2.2, < 4)
|
||||||
rdf (~> 3.3)
|
rdf (~> 3.3)
|
||||||
rexml (~> 3.2)
|
rexml (~> 3.2)
|
||||||
json-ld-preloaded (3.3.1)
|
json-ld-preloaded (3.3.2)
|
||||||
json-ld (~> 3.3)
|
json-ld (~> 3.3)
|
||||||
rdf (~> 3.3)
|
rdf (~> 3.3)
|
||||||
json-schema (5.1.1)
|
json-schema (5.2.1)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
bigdecimal (~> 3.1)
|
bigdecimal (~> 3.1)
|
||||||
jsonapi-renderer (0.2.2)
|
jsonapi-renderer (0.2.2)
|
||||||
jwt (2.10.1)
|
jwt (2.10.2)
|
||||||
base64
|
base64
|
||||||
kaminari (1.2.2)
|
kaminari (1.2.2)
|
||||||
activesupport (>= 4.1.0)
|
activesupport (>= 4.1.0)
|
||||||
|
@ -403,7 +403,7 @@ GEM
|
||||||
rexml
|
rexml
|
||||||
link_header (0.0.8)
|
link_header (0.0.8)
|
||||||
lint_roller (1.1.0)
|
lint_roller (1.1.0)
|
||||||
linzer (0.7.3)
|
linzer (0.7.7)
|
||||||
cgi (~> 0.4.2)
|
cgi (~> 0.4.2)
|
||||||
forwardable (~> 1.3, >= 1.3.3)
|
forwardable (~> 1.3, >= 1.3.3)
|
||||||
logger (~> 1.7, >= 1.7.0)
|
logger (~> 1.7, >= 1.7.0)
|
||||||
|
@ -433,21 +433,21 @@ GEM
|
||||||
marcel (1.0.4)
|
marcel (1.0.4)
|
||||||
mario-redis-lock (1.2.1)
|
mario-redis-lock (1.2.1)
|
||||||
redis (>= 3.0.5)
|
redis (>= 3.0.5)
|
||||||
matrix (0.4.2)
|
matrix (0.4.3)
|
||||||
memory_profiler (1.1.0)
|
memory_profiler (1.1.0)
|
||||||
mime-types (3.7.0)
|
mime-types (3.7.0)
|
||||||
logger
|
logger
|
||||||
mime-types-data (~> 3.2025, >= 3.2025.0507)
|
mime-types-data (~> 3.2025, >= 3.2025.0507)
|
||||||
mime-types-data (3.2025.0514)
|
mime-types-data (3.2025.0729)
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
mini_portile2 (2.8.9)
|
mini_portile2 (2.8.9)
|
||||||
minitest (5.25.5)
|
minitest (5.25.5)
|
||||||
msgpack (1.8.0)
|
msgpack (1.8.0)
|
||||||
multi_json (1.15.0)
|
multi_json (1.17.0)
|
||||||
mutex_m (0.3.0)
|
mutex_m (0.3.0)
|
||||||
net-http (0.6.0)
|
net-http (0.6.0)
|
||||||
uri
|
uri
|
||||||
net-imap (0.5.8)
|
net-imap (0.5.9)
|
||||||
date
|
date
|
||||||
net-protocol
|
net-protocol
|
||||||
net-ldap (0.19.0)
|
net-ldap (0.19.0)
|
||||||
|
@ -458,7 +458,7 @@ GEM
|
||||||
net-smtp (0.5.1)
|
net-smtp (0.5.1)
|
||||||
net-protocol
|
net-protocol
|
||||||
nio4r (2.7.4)
|
nio4r (2.7.4)
|
||||||
nokogiri (1.18.8)
|
nokogiri (1.18.9)
|
||||||
mini_portile2 (~> 2.8.2)
|
mini_portile2 (~> 2.8.2)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
oj (3.16.11)
|
oj (3.16.11)
|
||||||
|
@ -468,7 +468,7 @@ GEM
|
||||||
hashie (>= 3.4.6)
|
hashie (>= 3.4.6)
|
||||||
rack (>= 2.2.3)
|
rack (>= 2.2.3)
|
||||||
rack-protection
|
rack-protection
|
||||||
omniauth-cas (3.0.1)
|
omniauth-cas (3.0.2)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
nokogiri (~> 1.12)
|
nokogiri (~> 1.12)
|
||||||
omniauth (~> 2.1)
|
omniauth (~> 2.1)
|
||||||
|
@ -515,7 +515,7 @@ GEM
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-active_support (~> 0.7)
|
opentelemetry-instrumentation-active_support (~> 0.7)
|
||||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||||
opentelemetry-instrumentation-action_pack (0.12.1)
|
opentelemetry-instrumentation-action_pack (0.12.3)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||||
opentelemetry-instrumentation-rack (~> 0.21)
|
opentelemetry-instrumentation-rack (~> 0.21)
|
||||||
|
@ -553,7 +553,7 @@ GEM
|
||||||
opentelemetry-instrumentation-faraday (0.27.0)
|
opentelemetry-instrumentation-faraday (0.27.0)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||||
opentelemetry-instrumentation-http (0.25.0)
|
opentelemetry-instrumentation-http (0.25.1)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||||
opentelemetry-instrumentation-http_client (0.23.0)
|
opentelemetry-instrumentation-http_client (0.23.0)
|
||||||
|
@ -597,20 +597,20 @@ GEM
|
||||||
opentelemetry-semantic_conventions (1.11.0)
|
opentelemetry-semantic_conventions (1.11.0)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
orm_adapter (0.5.0)
|
orm_adapter (0.5.0)
|
||||||
ostruct (0.6.1)
|
ostruct (0.6.3)
|
||||||
ox (2.14.23)
|
ox (2.14.23)
|
||||||
bigdecimal (>= 3.0)
|
bigdecimal (>= 3.0)
|
||||||
parallel (1.27.0)
|
parallel (1.27.0)
|
||||||
parser (3.3.8.0)
|
parser (3.3.9.0)
|
||||||
ast (~> 2.4.1)
|
ast (~> 2.4.1)
|
||||||
racc
|
racc
|
||||||
parslet (2.0.0)
|
parslet (2.0.0)
|
||||||
pastel (0.8.0)
|
pastel (0.8.0)
|
||||||
tty-color (~> 0.5)
|
tty-color (~> 0.5)
|
||||||
pg (1.5.9)
|
pg (1.6.1)
|
||||||
pghero (3.7.0)
|
pghero (3.7.0)
|
||||||
activerecord (>= 7.1)
|
activerecord (>= 7.1)
|
||||||
playwright-ruby-client (1.52.0)
|
playwright-ruby-client (1.54.1)
|
||||||
concurrent-ruby (>= 1.1.6)
|
concurrent-ruby (>= 1.1.6)
|
||||||
mime-types (>= 3.0)
|
mime-types (>= 3.0)
|
||||||
pp (0.6.2)
|
pp (0.6.2)
|
||||||
|
@ -627,16 +627,15 @@ GEM
|
||||||
prism (1.4.0)
|
prism (1.4.0)
|
||||||
prometheus_exporter (2.2.0)
|
prometheus_exporter (2.2.0)
|
||||||
webrick
|
webrick
|
||||||
propshaft (1.1.0)
|
propshaft (1.2.1)
|
||||||
actionpack (>= 7.0.0)
|
actionpack (>= 7.0.0)
|
||||||
activesupport (>= 7.0.0)
|
activesupport (>= 7.0.0)
|
||||||
rack
|
rack
|
||||||
railties (>= 7.0.0)
|
|
||||||
psych (5.2.6)
|
psych (5.2.6)
|
||||||
date
|
date
|
||||||
stringio
|
stringio
|
||||||
public_suffix (6.0.2)
|
public_suffix (6.0.2)
|
||||||
puma (6.6.0)
|
puma (6.6.1)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
pundit (2.5.0)
|
pundit (2.5.0)
|
||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
|
@ -682,7 +681,7 @@ GEM
|
||||||
activesupport (= 8.0.2)
|
activesupport (= 8.0.2)
|
||||||
bundler (>= 1.15.0)
|
bundler (>= 1.15.0)
|
||||||
railties (= 8.0.2)
|
railties (= 8.0.2)
|
||||||
rails-dom-testing (2.2.0)
|
rails-dom-testing (2.3.0)
|
||||||
activesupport (>= 5.0.0)
|
activesupport (>= 5.0.0)
|
||||||
minitest
|
minitest
|
||||||
nokogiri (>= 1.6)
|
nokogiri (>= 1.6)
|
||||||
|
@ -702,23 +701,28 @@ GEM
|
||||||
zeitwerk (~> 2.6)
|
zeitwerk (~> 2.6)
|
||||||
rainbow (3.1.1)
|
rainbow (3.1.1)
|
||||||
rake (13.3.0)
|
rake (13.3.0)
|
||||||
rdf (3.3.2)
|
rdf (3.3.4)
|
||||||
bcp47_spec (~> 0.2)
|
bcp47_spec (~> 0.2)
|
||||||
bigdecimal (~> 3.1, >= 3.1.5)
|
bigdecimal (~> 3.1, >= 3.1.5)
|
||||||
link_header (~> 0.0, >= 0.0.8)
|
link_header (~> 0.0, >= 0.0.8)
|
||||||
|
logger (~> 1.5)
|
||||||
|
ostruct (~> 0.6)
|
||||||
|
readline (~> 0.0)
|
||||||
rdf-normalize (0.7.0)
|
rdf-normalize (0.7.0)
|
||||||
rdf (~> 3.3)
|
rdf (~> 3.3)
|
||||||
rdoc (6.14.1)
|
rdoc (6.14.2)
|
||||||
erb
|
erb
|
||||||
psych (>= 4.0.0)
|
psych (>= 4.0.0)
|
||||||
|
readline (0.0.4)
|
||||||
|
reline
|
||||||
redcarpet (3.6.1)
|
redcarpet (3.6.1)
|
||||||
redis (4.8.1)
|
redis (4.8.1)
|
||||||
redis-client (0.24.0)
|
redis-client (0.25.1)
|
||||||
connection_pool
|
connection_pool
|
||||||
redlock (1.3.2)
|
redlock (1.3.2)
|
||||||
redis (>= 3.0.0, < 6.0)
|
redis (>= 3.0.0, < 6.0)
|
||||||
regexp_parser (2.10.0)
|
regexp_parser (2.11.0)
|
||||||
reline (0.6.1)
|
reline (0.6.2)
|
||||||
io-console (~> 0.5)
|
io-console (~> 0.5)
|
||||||
request_store (1.7.0)
|
request_store (1.7.0)
|
||||||
rack (>= 1.4)
|
rack (>= 1.4)
|
||||||
|
@ -727,17 +731,17 @@ GEM
|
||||||
railties (>= 5.2)
|
railties (>= 5.2)
|
||||||
rexml (3.4.1)
|
rexml (3.4.1)
|
||||||
rotp (6.3.0)
|
rotp (6.3.0)
|
||||||
rouge (4.5.2)
|
rouge (4.6.0)
|
||||||
rpam2 (4.0.2)
|
rpam2 (4.0.2)
|
||||||
rqrcode (3.1.0)
|
rqrcode (3.1.0)
|
||||||
chunky_png (~> 1.0)
|
chunky_png (~> 1.0)
|
||||||
rqrcode_core (~> 2.0)
|
rqrcode_core (~> 2.0)
|
||||||
rqrcode_core (2.0.0)
|
rqrcode_core (2.0.0)
|
||||||
rspec (3.13.0)
|
rspec (3.13.1)
|
||||||
rspec-core (~> 3.13.0)
|
rspec-core (~> 3.13.0)
|
||||||
rspec-expectations (~> 3.13.0)
|
rspec-expectations (~> 3.13.0)
|
||||||
rspec-mocks (~> 3.13.0)
|
rspec-mocks (~> 3.13.0)
|
||||||
rspec-core (3.13.4)
|
rspec-core (3.13.5)
|
||||||
rspec-support (~> 3.13.0)
|
rspec-support (~> 3.13.0)
|
||||||
rspec-expectations (3.13.5)
|
rspec-expectations (3.13.5)
|
||||||
diff-lcs (>= 1.2.0, < 2.0)
|
diff-lcs (>= 1.2.0, < 2.0)
|
||||||
|
@ -755,13 +759,13 @@ GEM
|
||||||
rspec-expectations (~> 3.13)
|
rspec-expectations (~> 3.13)
|
||||||
rspec-mocks (~> 3.13)
|
rspec-mocks (~> 3.13)
|
||||||
rspec-support (~> 3.13)
|
rspec-support (~> 3.13)
|
||||||
rspec-sidekiq (5.1.0)
|
rspec-sidekiq (5.2.0)
|
||||||
rspec-core (~> 3.0)
|
rspec-core (~> 3.0)
|
||||||
rspec-expectations (~> 3.0)
|
rspec-expectations (~> 3.0)
|
||||||
rspec-mocks (~> 3.0)
|
rspec-mocks (~> 3.0)
|
||||||
sidekiq (>= 5, < 9)
|
sidekiq (>= 5, < 9)
|
||||||
rspec-support (3.13.4)
|
rspec-support (3.13.4)
|
||||||
rubocop (1.77.0)
|
rubocop (1.79.2)
|
||||||
json (~> 2.3)
|
json (~> 2.3)
|
||||||
language_server-protocol (~> 3.17.0.2)
|
language_server-protocol (~> 3.17.0.2)
|
||||||
lint_roller (~> 1.1.0)
|
lint_roller (~> 1.1.0)
|
||||||
|
@ -769,10 +773,10 @@ GEM
|
||||||
parser (>= 3.3.0.2)
|
parser (>= 3.3.0.2)
|
||||||
rainbow (>= 2.2.2, < 4.0)
|
rainbow (>= 2.2.2, < 4.0)
|
||||||
regexp_parser (>= 2.9.3, < 3.0)
|
regexp_parser (>= 2.9.3, < 3.0)
|
||||||
rubocop-ast (>= 1.45.1, < 2.0)
|
rubocop-ast (>= 1.46.0, < 2.0)
|
||||||
ruby-progressbar (~> 1.7)
|
ruby-progressbar (~> 1.7)
|
||||||
unicode-display_width (>= 2.4.0, < 4.0)
|
unicode-display_width (>= 2.4.0, < 4.0)
|
||||||
rubocop-ast (1.45.1)
|
rubocop-ast (1.46.0)
|
||||||
parser (>= 3.3.7.2)
|
parser (>= 3.3.7.2)
|
||||||
prism (~> 1.4)
|
prism (~> 1.4)
|
||||||
rubocop-capybara (2.22.1)
|
rubocop-capybara (2.22.1)
|
||||||
|
@ -801,7 +805,7 @@ GEM
|
||||||
ruby-prof (1.7.2)
|
ruby-prof (1.7.2)
|
||||||
base64
|
base64
|
||||||
ruby-progressbar (1.13.0)
|
ruby-progressbar (1.13.0)
|
||||||
ruby-saml (1.18.0)
|
ruby-saml (1.18.1)
|
||||||
nokogiri (>= 1.13.10)
|
nokogiri (>= 1.13.10)
|
||||||
rexml
|
rexml
|
||||||
ruby-vips (2.2.4)
|
ruby-vips (2.2.4)
|
||||||
|
@ -815,7 +819,7 @@ GEM
|
||||||
sanitize (7.0.0)
|
sanitize (7.0.0)
|
||||||
crass (~> 1.0.2)
|
crass (~> 1.0.2)
|
||||||
nokogiri (>= 1.16.8)
|
nokogiri (>= 1.16.8)
|
||||||
scenic (1.8.0)
|
scenic (1.9.0)
|
||||||
activerecord (>= 4.0.0)
|
activerecord (>= 4.0.0)
|
||||||
railties (>= 4.0.0)
|
railties (>= 4.0.0)
|
||||||
securerandom (0.4.1)
|
securerandom (0.4.1)
|
||||||
|
@ -829,10 +833,9 @@ GEM
|
||||||
redis-client (>= 0.22.2)
|
redis-client (>= 0.22.2)
|
||||||
sidekiq-bulk (0.2.0)
|
sidekiq-bulk (0.2.0)
|
||||||
sidekiq
|
sidekiq
|
||||||
sidekiq-scheduler (5.0.6)
|
sidekiq-scheduler (6.0.1)
|
||||||
rufus-scheduler (~> 3.2)
|
rufus-scheduler (~> 3.2)
|
||||||
sidekiq (>= 6, < 8)
|
sidekiq (>= 7.3, < 9)
|
||||||
tilt (>= 1.4.0, < 3)
|
|
||||||
sidekiq-unique-jobs (8.0.11)
|
sidekiq-unique-jobs (8.0.11)
|
||||||
concurrent-ruby (~> 1.0, >= 1.0.5)
|
concurrent-ruby (~> 1.0, >= 1.0.5)
|
||||||
sidekiq (>= 7.0.0, < 9.0.0)
|
sidekiq (>= 7.0.0, < 9.0.0)
|
||||||
|
@ -846,7 +849,7 @@ GEM
|
||||||
docile (~> 1.1)
|
docile (~> 1.1)
|
||||||
simplecov-html (~> 0.11)
|
simplecov-html (~> 0.11)
|
||||||
simplecov_json_formatter (~> 0.1)
|
simplecov_json_formatter (~> 0.1)
|
||||||
simplecov-html (0.13.1)
|
simplecov-html (0.13.2)
|
||||||
simplecov-lcov (0.8.0)
|
simplecov-lcov (0.8.0)
|
||||||
simplecov_json_formatter (0.1.4)
|
simplecov_json_formatter (0.1.4)
|
||||||
stackprof (0.2.27)
|
stackprof (0.2.27)
|
||||||
|
@ -855,7 +858,7 @@ GEM
|
||||||
stoplight (4.1.1)
|
stoplight (4.1.1)
|
||||||
redlock (~> 1.0)
|
redlock (~> 1.0)
|
||||||
stringio (3.1.7)
|
stringio (3.1.7)
|
||||||
strong_migrations (2.4.0)
|
strong_migrations (2.5.0)
|
||||||
activerecord (>= 7.1)
|
activerecord (>= 7.1)
|
||||||
swd (2.0.3)
|
swd (2.0.3)
|
||||||
activesupport (>= 3)
|
activesupport (>= 3)
|
||||||
|
@ -863,14 +866,14 @@ GEM
|
||||||
faraday (~> 2.0)
|
faraday (~> 2.0)
|
||||||
faraday-follow_redirects
|
faraday-follow_redirects
|
||||||
sysexits (1.2.0)
|
sysexits (1.2.0)
|
||||||
temple (0.10.3)
|
temple (0.10.4)
|
||||||
terminal-table (4.0.0)
|
terminal-table (4.0.0)
|
||||||
unicode-display_width (>= 1.1.1, < 4)
|
unicode-display_width (>= 1.1.1, < 4)
|
||||||
terrapin (1.1.0)
|
terrapin (1.1.1)
|
||||||
climate_control
|
climate_control
|
||||||
test-prof (1.4.4)
|
test-prof (1.4.4)
|
||||||
thor (1.3.2)
|
thor (1.4.0)
|
||||||
tilt (2.6.0)
|
tilt (2.6.1)
|
||||||
timeout (0.4.3)
|
timeout (0.4.3)
|
||||||
tpm-key_attestation (0.14.1)
|
tpm-key_attestation (0.14.1)
|
||||||
bindata (~> 2.4)
|
bindata (~> 2.4)
|
||||||
|
@ -932,7 +935,7 @@ GEM
|
||||||
crack (>= 0.3.2)
|
crack (>= 0.3.2)
|
||||||
hashdiff (>= 0.4.0, < 2.0.0)
|
hashdiff (>= 0.4.0, < 2.0.0)
|
||||||
webrick (1.9.1)
|
webrick (1.9.1)
|
||||||
websocket-driver (0.7.7)
|
websocket-driver (0.8.0)
|
||||||
base64
|
base64
|
||||||
websocket-extensions (>= 0.1.0)
|
websocket-extensions (>= 0.1.0)
|
||||||
websocket-extensions (0.1.5)
|
websocket-extensions (0.1.5)
|
||||||
|
@ -1008,7 +1011,7 @@ DEPENDENCIES
|
||||||
letter_opener (~> 1.8)
|
letter_opener (~> 1.8)
|
||||||
letter_opener_web (~> 3.0)
|
letter_opener_web (~> 3.0)
|
||||||
link_header (~> 0.0)
|
link_header (~> 0.0)
|
||||||
linzer (~> 0.7.2)
|
linzer (~> 0.7.7)
|
||||||
lograge (~> 0.12)
|
lograge (~> 0.12)
|
||||||
mail (~> 2.8)
|
mail (~> 2.8)
|
||||||
mario-redis-lock (~> 1.2)
|
mario-redis-lock (~> 1.2)
|
||||||
|
@ -1078,7 +1081,7 @@ DEPENDENCIES
|
||||||
shoulda-matchers
|
shoulda-matchers
|
||||||
sidekiq (< 8)
|
sidekiq (< 8)
|
||||||
sidekiq-bulk (~> 0.2.0)
|
sidekiq-bulk (~> 0.2.0)
|
||||||
sidekiq-scheduler (~> 5.0)
|
sidekiq-scheduler (~> 6.0)
|
||||||
sidekiq-unique-jobs (> 8)
|
sidekiq-unique-jobs (> 8)
|
||||||
simple-navigation (~> 4.4)
|
simple-navigation (~> 4.4)
|
||||||
simple_form (~> 5.2)
|
simple_form (~> 5.2)
|
||||||
|
@ -1102,4 +1105,4 @@ RUBY VERSION
|
||||||
ruby 3.4.1p0
|
ruby 3.4.1p0
|
||||||
|
|
||||||
BUNDLED WITH
|
BUNDLED WITH
|
||||||
2.6.9
|
2.7.1
|
||||||
|
|
64
README.md
64
README.md
|
@ -17,71 +17,71 @@
|
||||||
<img src="https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg" alt="Crowdin" /></a>
|
<img src="https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg" alt="Crowdin" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
Mastodon is a **free, open-source social network server** based on ActivityPub where users can follow friends and discover new ones. On Mastodon, users can publish anything they want: links, pictures, text, and video. All Mastodon servers are interoperable as a federated network (users on one server can seamlessly communicate with users from another one, including non-Mastodon software that implements ActivityPub!)
|
Mastodon is a **free, open-source social network server** based on [ActivityPub](https://www.w3.org/TR/activitypub/) where users can follow friends and discover new ones. On Mastodon, users can publish anything they want: links, pictures, text, and video. All Mastodon servers are interoperable as a federated network (users on one server can seamlessly communicate with users from another one, including non-Mastodon software that implements ActivityPub!)
|
||||||
|
|
||||||
## Navigation
|
## Navigation
|
||||||
|
|
||||||
- [Project homepage 🐘](https://joinmastodon.org)
|
- [Project homepage 🐘](https://joinmastodon.org)
|
||||||
- [Support the development via Patreon][patreon]
|
- [Donate to support development 🎁](https://joinmastodon.org/sponsors#donate)
|
||||||
- [View sponsors](https://joinmastodon.org/sponsors)
|
- [View sponsors](https://joinmastodon.org/sponsors)
|
||||||
- [Blog](https://blog.joinmastodon.org)
|
- [Blog 📰](https://blog.joinmastodon.org)
|
||||||
- [Documentation](https://docs.joinmastodon.org)
|
- [Documentation 📚](https://docs.joinmastodon.org)
|
||||||
- [Roadmap](https://joinmastodon.org/roadmap)
|
- [Official container image 🚢](https://github.com/mastodon/mastodon/pkgs/container/mastodon)
|
||||||
- [Official Docker image](https://github.com/mastodon/mastodon/pkgs/container/mastodon)
|
|
||||||
- [Browse Mastodon servers](https://joinmastodon.org/communities)
|
|
||||||
- [Browse Mastodon apps](https://joinmastodon.org/apps)
|
|
||||||
|
|
||||||
[patreon]: https://www.patreon.com/mastodon
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
<img src="/app/javascript/images/elephant_ui_working.svg?raw=true" align="right" width="30%" />
|
<img src="./app/javascript/images/elephant_ui_working.svg?raw=true" align="right" width="30%" />
|
||||||
|
|
||||||
**No vendor lock-in: Fully interoperable with any conforming platform** - It doesn't have to be Mastodon; whatever implements ActivityPub is part of the social network! [Learn more](https://blog.joinmastodon.org/2018/06/why-activitypub-is-the-future/)
|
**Part of the Fediverse. Based on open standards, with no vendor lock-in.** - the network goes beyond just Mastodon; anything that implements ActivityPub is part of a broader social network known as [the Fediverse](https://jointhefediverse.net/). You can follow and interact with users on other servers (including those running different software), and they can follow you back.
|
||||||
|
|
||||||
**Real-time, chronological timeline updates** - updates of people you're following appear in real-time in the UI via WebSockets. There's a firehose view as well!
|
**Real-time, chronological timeline updates** - updates of people you're following appear in real-time in the UI.
|
||||||
|
|
||||||
**Media attachments like images and short videos** - upload and view images and WebM/MP4 videos attached to the updates. Videos with no audio track are treated like GIFs; normal videos loop continuously!
|
**Media attachments** - upload and view images and videos attached to the updates. Videos with no audio track are treated like animated GIFs; normal videos loop continuously.
|
||||||
|
|
||||||
**Safety and moderation tools** - Mastodon includes private posts, locked accounts, phrase filtering, muting, blocking, and all sorts of other features, along with a reporting and moderation system. [Learn more](https://blog.joinmastodon.org/2018/07/cage-the-mastodon/)
|
**Safety and moderation tools** - Mastodon includes private posts, locked accounts, phrase filtering, muting, blocking, and many other features, along with a reporting and moderation system.
|
||||||
|
|
||||||
**OAuth2 and a straightforward REST API** - Mastodon acts as an OAuth2 provider, so 3rd party apps can use the REST and Streaming APIs. This results in a rich app ecosystem with a lot of choices!
|
**OAuth2 and a straightforward REST API** - Mastodon acts as an OAuth2 provider, and third party apps can use the REST and Streaming APIs. This results in a [rich app ecosystem](https://joinmastodon.org/apps) with a variety of choices!
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
### Tech stack
|
### Tech stack
|
||||||
|
|
||||||
- **Ruby on Rails** powers the REST API and other web pages
|
- [Ruby on Rails](https://github.com/rails/rails) powers the REST API and other web pages.
|
||||||
- **React.js** and **Redux** are used for the dynamic parts of the interface
|
- [PostgreSQL](https://www.postgresql.org/) is the main database.
|
||||||
- **Node.js** powers the streaming API
|
- [Redis](https://redis.io/) and [Sidekiq](https://sidekiq.org/) are used for caching and queueing.
|
||||||
|
- [Node.js](https://nodejs.org/) powers the streaming API.
|
||||||
|
- [React.js](https://reactjs.org/) and [Redux](https://redux.js.org/) are used for the dynamic parts of the interface.
|
||||||
|
- [BrowserStack](https://www.browserstack.com/) supports testing on real devices and browsers. (This project is tested with BrowserStack)
|
||||||
|
- [Chromatic](https://www.chromatic.com/) provides visual regression testing. (This project is tested with Chromatic)
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
|
- **Ruby** 3.2+
|
||||||
- **PostgreSQL** 13+
|
- **PostgreSQL** 13+
|
||||||
- **Redis** 6.2+
|
- **Redis** 6.2+
|
||||||
- **Ruby** 3.2+
|
|
||||||
- **Node.js** 20+
|
- **Node.js** 20+
|
||||||
|
|
||||||
The repository includes deployment configurations for **Docker and docker-compose** as well as specific platforms like **Heroku**, and **Scalingo**. For Helm charts, reference the [mastodon/chart repository](https://github.com/mastodon/chart). The [**standalone** installation guide](https://docs.joinmastodon.org/admin/install/) is available in the documentation.
|
This repository includes deployment configurations for **Docker and docker-compose**, as well as for other environments like Heroku and Scalingo. For Helm charts, reference the [mastodon/chart repository](https://github.com/mastodon/chart). A [**standalone** installation guide](https://docs.joinmastodon.org/admin/install/) is available in the main documentation.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Mastodon is **free, open-source software** licensed under **AGPLv3**.
|
Mastodon is **free, open-source software** licensed under **AGPLv3**. We welcome contributions and help from anyone who wants to improve the project.
|
||||||
|
|
||||||
You can open issues for bugs you've found or features you think are missing. You
|
You should read the overall [CONTRIBUTING](https://github.com/mastodon/.github/blob/main/CONTRIBUTING.md) guide, which covers our development processes.
|
||||||
can also submit pull requests to this repository or translations via Crowdin. To
|
|
||||||
get started, look at the [CONTRIBUTING] and [DEVELOPMENT] guides. For changes
|
|
||||||
accepted into Mastodon, you can request to be paid through our [OpenCollective].
|
|
||||||
|
|
||||||
**IRC channel**: #mastodon on [`irc.libera.chat`](https://libera.chat)
|
You should also read and understand the [CODE OF CONDUCT](https://github.com/mastodon/.github/blob/main/CODE_OF_CONDUCT.md) that enables us to maintain a welcoming and inclusive community. Collaboration begins with mutual respect and understanding.
|
||||||
|
|
||||||
## License
|
You can learn about setting up a development environment in the [DEVELOPMENT](docs/DEVELOPMENT.md) documentation.
|
||||||
|
|
||||||
|
If you would like to help with translations 🌐 you can do so on [Crowdin](https://crowdin.com/project/mastodon).
|
||||||
|
|
||||||
|
## LICENSE
|
||||||
|
|
||||||
Copyright (c) 2016-2025 Eugen Rochko (+ [`mastodon authors`](AUTHORS.md))
|
Copyright (c) 2016-2025 Eugen Rochko (+ [`mastodon authors`](AUTHORS.md))
|
||||||
|
|
||||||
Licensed under GNU Affero General Public License as stated in the [LICENSE](LICENSE):
|
Licensed under GNU Affero General Public License as stated in the [LICENSE](LICENSE):
|
||||||
|
|
||||||
```
|
```text
|
||||||
Copyright (c) 2016-2025 Eugen Rochko & other Mastodon contributors
|
Copyright (c) 2016-2025 Eugen Rochko & other Mastodon contributors
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify it under
|
This program is free software: you can redistribute it and/or modify it under
|
||||||
|
@ -97,7 +97,3 @@ details.
|
||||||
You should have received a copy of the GNU Affero General Public License along
|
You should have received a copy of the GNU Affero General Public License along
|
||||||
with this program. If not, see https://www.gnu.org/licenses/
|
with this program. If not, see https://www.gnu.org/licenses/
|
||||||
```
|
```
|
||||||
|
|
||||||
[CONTRIBUTING]: CONTRIBUTING.md
|
|
||||||
[DEVELOPMENT]: docs/DEVELOPMENT.md
|
|
||||||
[OpenCollective]: https://opencollective.com/mastodon
|
|
||||||
|
|
11
SECURITY.md
11
SECURITY.md
|
@ -13,8 +13,9 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through
|
||||||
|
|
||||||
## Supported Versions
|
## Supported Versions
|
||||||
|
|
||||||
| Version | Supported |
|
| Version | Supported |
|
||||||
| ------- | --------- |
|
| ------- | ---------------- |
|
||||||
| 4.3.x | Yes |
|
| 4.4.x | Yes |
|
||||||
| 4.2.x | Yes |
|
| 4.3.x | Yes |
|
||||||
| < 4.2 | No |
|
| 4.2.x | Until 2026-01-08 |
|
||||||
|
| < 4.2 | No |
|
||||||
|
|
|
@ -0,0 +1,28 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class ActivityPub::QuoteAuthorizationsController < ActivityPub::BaseController
|
||||||
|
include Authorization
|
||||||
|
|
||||||
|
vary_by -> { 'Signature' if authorized_fetch_mode? }
|
||||||
|
|
||||||
|
before_action :require_account_signature!, if: :authorized_fetch_mode?
|
||||||
|
before_action :set_quote_authorization
|
||||||
|
|
||||||
|
def show
|
||||||
|
expires_in 0, public: @quote.status.distributable? && public_fetch_mode?
|
||||||
|
render json: @quote, serializer: ActivityPub::QuoteAuthorizationSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def pundit_user
|
||||||
|
signed_request_account
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_quote_authorization
|
||||||
|
@quote = Quote.accepted.where(quoted_account: @account).find(params[:id])
|
||||||
|
authorize @quote.status, :show?
|
||||||
|
rescue Mastodon::NotPermittedError
|
||||||
|
not_found
|
||||||
|
end
|
||||||
|
end
|
|
@ -14,16 +14,20 @@ module Admin
|
||||||
def create
|
def create
|
||||||
authorize @account, :show?
|
authorize @account, :show?
|
||||||
|
|
||||||
account_action = Admin::AccountAction.new(resource_params)
|
@account_action = Admin::AccountAction.new(resource_params)
|
||||||
account_action.target_account = @account
|
@account_action.target_account = @account
|
||||||
account_action.current_account = current_account
|
@account_action.current_account = current_account
|
||||||
|
|
||||||
account_action.save!
|
if @account_action.save
|
||||||
|
if @account_action.with_report?
|
||||||
if account_action.with_report?
|
redirect_to admin_reports_path, notice: I18n.t('admin.reports.processed_msg', id: resource_params[:report_id])
|
||||||
redirect_to admin_reports_path, notice: I18n.t('admin.reports.processed_msg', id: resource_params[:report_id])
|
else
|
||||||
|
redirect_to admin_account_path(@account.id)
|
||||||
|
end
|
||||||
else
|
else
|
||||||
redirect_to admin_account_path(@account.id)
|
@warning_presets = AccountWarningPreset.all
|
||||||
|
|
||||||
|
render :new
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -16,11 +16,14 @@ module Admin
|
||||||
def batch
|
def batch
|
||||||
authorize :account, :index?
|
authorize :account, :index?
|
||||||
|
|
||||||
@form = Form::AccountBatch.new(form_account_batch_params)
|
@form = Form::AccountBatch.new(
|
||||||
@form.current_account = current_account
|
form_account_batch_params.merge(
|
||||||
@form.action = action_from_button
|
action: action_from_button,
|
||||||
@form.select_all_matching = params[:select_all_matching]
|
current_account:,
|
||||||
@form.query = filtered_accounts
|
query: filtered_accounts,
|
||||||
|
select_all_matching: params[:select_all_matching]
|
||||||
|
)
|
||||||
|
)
|
||||||
@form.save
|
@form.save
|
||||||
rescue ActionController::ParameterMissing
|
rescue ActionController::ParameterMissing
|
||||||
flash[:alert] = I18n.t('admin.accounts.no_account_selected')
|
flash[:alert] = I18n.t('admin.accounts.no_account_selected')
|
||||||
|
|
|
@ -6,7 +6,7 @@ module Admin
|
||||||
|
|
||||||
def index
|
def index
|
||||||
authorize :audit_log, :index?
|
authorize :audit_log, :index?
|
||||||
@auditable_accounts = Account.auditable.select(:id, :username)
|
@auditable_accounts = Account.auditable.select(:id, :username).order(username: :asc)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
|
@ -19,15 +19,13 @@ module Admin
|
||||||
|
|
||||||
log_action :resend, @user
|
log_action :resend, @user
|
||||||
|
|
||||||
flash[:notice] = I18n.t('admin.accounts.resend_confirmation.success')
|
redirect_to admin_accounts_path, notice: t('admin.accounts.resend_confirmation.success')
|
||||||
redirect_to admin_accounts_path
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def redirect_confirmed_user
|
def redirect_confirmed_user
|
||||||
flash[:error] = I18n.t('admin.accounts.resend_confirmation.already_confirmed')
|
redirect_to admin_accounts_path, flash: { error: t('admin.accounts.resend_confirmation.already_confirmed') }
|
||||||
redirect_to admin_accounts_path
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def user_confirmed?
|
def user_confirmed?
|
||||||
|
|
|
@ -18,7 +18,7 @@ class Admin::Disputes::AppealsController < Admin::BaseController
|
||||||
end
|
end
|
||||||
|
|
||||||
def reject
|
def reject
|
||||||
authorize @appeal, :approve?
|
authorize @appeal, :reject?
|
||||||
log_action :reject, @appeal
|
log_action :reject, @appeal
|
||||||
@appeal.reject!(current_account)
|
@appeal.reject!(current_account)
|
||||||
UserMailer.appeal_rejected(@appeal.account.user, @appeal).deliver_later
|
UserMailer.appeal_rejected(@appeal.account.user, @appeal).deliver_later
|
||||||
|
|
|
@ -36,7 +36,7 @@ module Admin
|
||||||
end
|
end
|
||||||
|
|
||||||
def edit
|
def edit
|
||||||
authorize :domain_block, :create?
|
authorize :domain_block, :update?
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
|
@ -129,7 +129,7 @@ module Admin
|
||||||
end
|
end
|
||||||
|
|
||||||
def requires_confirmation?
|
def requires_confirmation?
|
||||||
@domain_block.valid? && (@domain_block.new_record? || @domain_block.severity_changed?) && @domain_block.severity.to_s == 'suspend' && !params[:confirm]
|
@domain_block.valid? && (@domain_block.new_record? || @domain_block.severity_changed?) && @domain_block.suspend? && !params[:confirm]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -13,27 +13,9 @@ class Admin::Reports::ActionsController < Admin::BaseController
|
||||||
|
|
||||||
case action_from_button
|
case action_from_button
|
||||||
when 'delete', 'mark_as_sensitive'
|
when 'delete', 'mark_as_sensitive'
|
||||||
status_batch_action = Admin::StatusBatchAction.new(
|
Admin::StatusBatchAction.new(status_batch_action_params).save!
|
||||||
type: action_from_button,
|
|
||||||
status_ids: @report.status_ids,
|
|
||||||
current_account: current_account,
|
|
||||||
report_id: @report.id,
|
|
||||||
send_email_notification: !@report.spam?,
|
|
||||||
text: params[:text]
|
|
||||||
)
|
|
||||||
|
|
||||||
status_batch_action.save!
|
|
||||||
when 'silence', 'suspend'
|
when 'silence', 'suspend'
|
||||||
account_action = Admin::AccountAction.new(
|
Admin::AccountAction.new(account_action_params).save!
|
||||||
type: action_from_button,
|
|
||||||
report_id: @report.id,
|
|
||||||
target_account: @report.target_account,
|
|
||||||
current_account: current_account,
|
|
||||||
send_email_notification: !@report.spam?,
|
|
||||||
text: params[:text]
|
|
||||||
)
|
|
||||||
|
|
||||||
account_action.save!
|
|
||||||
else
|
else
|
||||||
return redirect_to admin_report_path(@report), alert: I18n.t('admin.reports.unknown_action_msg', action: action_from_button)
|
return redirect_to admin_report_path(@report), alert: I18n.t('admin.reports.unknown_action_msg', action: action_from_button)
|
||||||
end
|
end
|
||||||
|
@ -43,6 +25,26 @@ class Admin::Reports::ActionsController < Admin::BaseController
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def status_batch_action_params
|
||||||
|
shared_params
|
||||||
|
.merge(status_ids: @report.status_ids)
|
||||||
|
end
|
||||||
|
|
||||||
|
def account_action_params
|
||||||
|
shared_params
|
||||||
|
.merge(target_account: @report.target_account)
|
||||||
|
end
|
||||||
|
|
||||||
|
def shared_params
|
||||||
|
{
|
||||||
|
current_account: current_account,
|
||||||
|
report_id: @report.id,
|
||||||
|
send_email_notification: !@report.spam?,
|
||||||
|
text: params[:text],
|
||||||
|
type: action_from_button,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
def set_report
|
def set_report
|
||||||
@report = Report.find(params[:report_id])
|
@report = Report.find(params[:report_id])
|
||||||
end
|
end
|
||||||
|
|
|
@ -14,8 +14,7 @@ module Admin
|
||||||
@admin_settings = Form::AdminSettings.new(settings_params)
|
@admin_settings = Form::AdminSettings.new(settings_params)
|
||||||
|
|
||||||
if @admin_settings.save
|
if @admin_settings.save
|
||||||
flash[:notice] = I18n.t('generic.changes_saved_msg')
|
redirect_to after_update_redirect_path, notice: t('generic.changes_saved_msg')
|
||||||
redirect_to after_update_redirect_path
|
|
||||||
else
|
else
|
||||||
render :show
|
render :show
|
||||||
end
|
end
|
||||||
|
|
|
@ -5,6 +5,7 @@ module Admin
|
||||||
before_action :set_tag, except: [:index]
|
before_action :set_tag, except: [:index]
|
||||||
|
|
||||||
PER_PAGE = 20
|
PER_PAGE = 20
|
||||||
|
PERIOD_DAYS = 6.days
|
||||||
|
|
||||||
def index
|
def index
|
||||||
authorize :tag, :index?
|
authorize :tag, :index?
|
||||||
|
@ -15,7 +16,7 @@ module Admin
|
||||||
def show
|
def show
|
||||||
authorize @tag, :show?
|
authorize @tag, :show?
|
||||||
|
|
||||||
@time_period = (6.days.ago.to_date...Time.now.utc.to_date)
|
@time_period = report_range
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
|
@ -24,7 +25,7 @@ module Admin
|
||||||
if @tag.update(tag_params.merge(reviewed_at: Time.now.utc))
|
if @tag.update(tag_params.merge(reviewed_at: Time.now.utc))
|
||||||
redirect_to admin_tag_path(@tag.id), notice: I18n.t('admin.tags.updated_msg')
|
redirect_to admin_tag_path(@tag.id), notice: I18n.t('admin.tags.updated_msg')
|
||||||
else
|
else
|
||||||
@time_period = (6.days.ago.to_date...Time.now.utc.to_date)
|
@time_period = report_range
|
||||||
|
|
||||||
render :show
|
render :show
|
||||||
end
|
end
|
||||||
|
@ -36,6 +37,10 @@ module Admin
|
||||||
@tag = Tag.find(params[:id])
|
@tag = Tag.find(params[:id])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def report_range
|
||||||
|
(PERIOD_DAYS.ago.to_date...Time.now.utc.to_date)
|
||||||
|
end
|
||||||
|
|
||||||
def tag_params
|
def tag_params
|
||||||
params
|
params
|
||||||
.expect(tag: [:name, :display_name, :trendable, :usable, :listable])
|
.expect(tag: [:name, :display_name, :trendable, :usable, :listable])
|
||||||
|
|
77
app/controllers/admin/username_blocks_controller.rb
Normal file
77
app/controllers/admin/username_blocks_controller.rb
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Admin::UsernameBlocksController < Admin::BaseController
|
||||||
|
before_action :set_username_block, only: [:edit, :update]
|
||||||
|
|
||||||
|
def index
|
||||||
|
authorize :username_block, :index?
|
||||||
|
@username_blocks = UsernameBlock.order(username: :asc).page(params[:page])
|
||||||
|
@form = Form::UsernameBlockBatch.new
|
||||||
|
end
|
||||||
|
|
||||||
|
def batch
|
||||||
|
authorize :username_block, :index?
|
||||||
|
|
||||||
|
@form = Form::UsernameBlockBatch.new(form_username_block_batch_params.merge(current_account: current_account, action: action_from_button))
|
||||||
|
@form.save
|
||||||
|
rescue ActionController::ParameterMissing
|
||||||
|
flash[:alert] = I18n.t('admin.username_blocks.no_username_block_selected')
|
||||||
|
rescue Mastodon::NotPermittedError
|
||||||
|
flash[:alert] = I18n.t('admin.username_blocks.not_permitted')
|
||||||
|
ensure
|
||||||
|
redirect_to admin_username_blocks_path
|
||||||
|
end
|
||||||
|
|
||||||
|
def new
|
||||||
|
authorize :username_block, :create?
|
||||||
|
@username_block = UsernameBlock.new(exact: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
def edit
|
||||||
|
authorize @username_block, :update?
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
authorize :username_block, :create?
|
||||||
|
|
||||||
|
@username_block = UsernameBlock.new(resource_params)
|
||||||
|
|
||||||
|
if @username_block.save
|
||||||
|
log_action :create, @username_block
|
||||||
|
redirect_to admin_username_blocks_path, notice: I18n.t('admin.username_blocks.created_msg')
|
||||||
|
else
|
||||||
|
render :new
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
authorize @username_block, :update?
|
||||||
|
|
||||||
|
if @username_block.update(resource_params)
|
||||||
|
log_action :update, @username_block
|
||||||
|
redirect_to admin_username_blocks_path, notice: I18n.t('admin.username_blocks.updated_msg')
|
||||||
|
else
|
||||||
|
render :new
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_username_block
|
||||||
|
@username_block = UsernameBlock.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def form_username_block_batch_params
|
||||||
|
params
|
||||||
|
.expect(form_username_block_batch: [username_block_ids: []])
|
||||||
|
end
|
||||||
|
|
||||||
|
def resource_params
|
||||||
|
params
|
||||||
|
.expect(username_block: [:username, :comparison, :allow_with_approval])
|
||||||
|
end
|
||||||
|
|
||||||
|
def action_from_button
|
||||||
|
'delete' if params[:delete]
|
||||||
|
end
|
||||||
|
end
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
class Api::V1::Admin::TagsController < Api::BaseController
|
class Api::V1::Admin::TagsController < Api::BaseController
|
||||||
include Authorization
|
include Authorization
|
||||||
|
|
||||||
before_action -> { authorize_if_got_token! :'admin:read' }, only: [:index, :show]
|
before_action -> { authorize_if_got_token! :'admin:read' }, only: [:index, :show]
|
||||||
before_action -> { authorize_if_got_token! :'admin:write' }, only: :update
|
before_action -> { authorize_if_got_token! :'admin:write' }, only: :update
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,7 @@ class Api::V1::InvitesController < Api::BaseController
|
||||||
skip_around_action :set_locale
|
skip_around_action :set_locale
|
||||||
|
|
||||||
before_action :set_invite
|
before_action :set_invite
|
||||||
|
before_action :check_valid_usage!
|
||||||
before_action :check_enabled_registrations!
|
before_action :check_enabled_registrations!
|
||||||
|
|
||||||
# Override `current_user` to avoid reading session cookies
|
# Override `current_user` to avoid reading session cookies
|
||||||
|
@ -22,9 +23,11 @@ class Api::V1::InvitesController < Api::BaseController
|
||||||
@invite = Invite.find_by!(code: params[:invite_code])
|
@invite = Invite.find_by!(code: params[:invite_code])
|
||||||
end
|
end
|
||||||
|
|
||||||
def check_enabled_registrations!
|
def check_valid_usage!
|
||||||
return render json: { error: I18n.t('invites.invalid') }, status: 401 unless @invite.valid_for_use?
|
render json: { error: I18n.t('invites.invalid') }, status: 401 unless @invite.valid_for_use?
|
||||||
|
end
|
||||||
|
|
||||||
|
def check_enabled_registrations!
|
||||||
raise Mastodon::NotPermittedError unless allowed_registration?(request.remote_ip, @invite)
|
raise Mastodon::NotPermittedError unless allowed_registration?(request.remote_ip, @invite)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -16,16 +16,7 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController
|
||||||
def create
|
def create
|
||||||
with_redis_lock("push_subscription:#{current_user.id}") do
|
with_redis_lock("push_subscription:#{current_user.id}") do
|
||||||
destroy_web_push_subscriptions!
|
destroy_web_push_subscriptions!
|
||||||
|
@push_subscription = Web::PushSubscription.create!(web_push_subscription_params)
|
||||||
@push_subscription = Web::PushSubscription.create!(
|
|
||||||
endpoint: subscription_params[:endpoint],
|
|
||||||
key_p256dh: subscription_params[:keys][:p256dh],
|
|
||||||
key_auth: subscription_params[:keys][:auth],
|
|
||||||
standard: subscription_params[:standard] || false,
|
|
||||||
data: data_params,
|
|
||||||
user_id: current_user.id,
|
|
||||||
access_token_id: doorkeeper_token.id
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer
|
render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer
|
||||||
|
@ -55,6 +46,18 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController
|
||||||
not_found if @push_subscription.nil?
|
not_found if @push_subscription.nil?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def web_push_subscription_params
|
||||||
|
{
|
||||||
|
access_token_id: doorkeeper_token.id,
|
||||||
|
data: data_params,
|
||||||
|
endpoint: subscription_params[:endpoint],
|
||||||
|
key_auth: subscription_params[:keys][:auth],
|
||||||
|
key_p256dh: subscription_params[:keys][:p256dh],
|
||||||
|
standard: subscription_params[:standard] || false,
|
||||||
|
user_id: current_user.id,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
def subscription_params
|
def subscription_params
|
||||||
params.expect(subscription: [:endpoint, :standard, keys: [:auth, :p256dh]])
|
params.expect(subscription: [:endpoint, :standard, keys: [:auth, :p256dh]])
|
||||||
end
|
end
|
||||||
|
|
72
app/controllers/api/v1/statuses/quotes_controller.rb
Normal file
72
app/controllers/api/v1/statuses/quotes_controller.rb
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Api::V1::Statuses::QuotesController < Api::V1::Statuses::BaseController
|
||||||
|
before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, only: :index
|
||||||
|
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: :revoke
|
||||||
|
|
||||||
|
before_action :check_owner!
|
||||||
|
before_action :set_quote, only: :revoke
|
||||||
|
after_action :insert_pagination_headers, only: :index
|
||||||
|
|
||||||
|
def index
|
||||||
|
cache_if_unauthenticated!
|
||||||
|
@statuses = load_statuses
|
||||||
|
render json: @statuses, each_serializer: REST::StatusSerializer
|
||||||
|
end
|
||||||
|
|
||||||
|
def revoke
|
||||||
|
authorize @quote, :revoke?
|
||||||
|
|
||||||
|
RevokeQuoteService.new.call(@quote)
|
||||||
|
|
||||||
|
render json: @quote.status, serializer: REST::StatusSerializer
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def check_owner!
|
||||||
|
authorize @status, :list_quotes?
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_quote
|
||||||
|
@quote = @status.quotes.find_by!(status_id: params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def load_statuses
|
||||||
|
scope = default_statuses
|
||||||
|
scope = scope.not_excluded_by_account(current_account) unless current_account.nil?
|
||||||
|
scope.merge(paginated_quotes).to_a
|
||||||
|
end
|
||||||
|
|
||||||
|
def default_statuses
|
||||||
|
Status.includes(:quote).references(:quote)
|
||||||
|
end
|
||||||
|
|
||||||
|
def paginated_quotes
|
||||||
|
@status.quotes.accepted.paginate_by_max_id(
|
||||||
|
limit_param(DEFAULT_STATUSES_LIMIT),
|
||||||
|
params[:max_id],
|
||||||
|
params[:since_id]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def next_path
|
||||||
|
api_v1_status_quotes_url pagination_params(max_id: pagination_max_id) if records_continue?
|
||||||
|
end
|
||||||
|
|
||||||
|
def prev_path
|
||||||
|
api_v1_status_quotes_url pagination_params(since_id: pagination_since_id) unless @statuses.empty?
|
||||||
|
end
|
||||||
|
|
||||||
|
def pagination_max_id
|
||||||
|
@statuses.last.quote.id
|
||||||
|
end
|
||||||
|
|
||||||
|
def pagination_since_id
|
||||||
|
@statuses.first.quote.id
|
||||||
|
end
|
||||||
|
|
||||||
|
def records_continue?
|
||||||
|
@statuses.size == limit_param(DEFAULT_STATUSES_LIMIT)
|
||||||
|
end
|
||||||
|
end
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
class Api::V1::StatusesController < Api::BaseController
|
class Api::V1::StatusesController < Api::BaseController
|
||||||
include Authorization
|
include Authorization
|
||||||
|
include AsyncRefreshesConcern
|
||||||
|
|
||||||
before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [:create, :update, :destroy]
|
before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [:create, :update, :destroy]
|
||||||
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:create, :update, :destroy]
|
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:create, :update, :destroy]
|
||||||
|
@ -9,6 +10,7 @@ class Api::V1::StatusesController < Api::BaseController
|
||||||
before_action :set_statuses, only: [:index]
|
before_action :set_statuses, only: [:index]
|
||||||
before_action :set_status, only: [:show, :context]
|
before_action :set_status, only: [:show, :context]
|
||||||
before_action :set_thread, only: [:create]
|
before_action :set_thread, only: [:create]
|
||||||
|
before_action :set_quoted_status, only: [:create]
|
||||||
before_action :check_statuses_limit, only: [:index]
|
before_action :check_statuses_limit, only: [:index]
|
||||||
|
|
||||||
override_rate_limit_headers :create, family: :statuses
|
override_rate_limit_headers :create, family: :statuses
|
||||||
|
@ -57,9 +59,21 @@ class Api::V1::StatusesController < Api::BaseController
|
||||||
@context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants)
|
@context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants)
|
||||||
statuses = [@status] + @context.ancestors + @context.descendants
|
statuses = [@status] + @context.ancestors + @context.descendants
|
||||||
|
|
||||||
render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id)
|
refresh_key = "context:#{@status.id}:refresh"
|
||||||
|
async_refresh = AsyncRefresh.new(refresh_key)
|
||||||
|
|
||||||
ActivityPub::FetchAllRepliesWorker.perform_async(@status.id) if !current_account.nil? && @status.should_fetch_replies?
|
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))
|
||||||
|
|
||||||
|
WorkerBatch.new.within do |batch|
|
||||||
|
batch.connect(refresh_key, threshold: 1.0)
|
||||||
|
ActivityPub::FetchAllRepliesWorker.perform_async(@status.id, { 'batch_id' => batch.id })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
|
@ -67,6 +81,8 @@ class Api::V1::StatusesController < Api::BaseController
|
||||||
current_user.account,
|
current_user.account,
|
||||||
text: status_params[:status],
|
text: status_params[:status],
|
||||||
thread: @thread,
|
thread: @thread,
|
||||||
|
quoted_status: @quoted_status,
|
||||||
|
quote_approval_policy: quote_approval_policy,
|
||||||
media_ids: status_params[:media_ids],
|
media_ids: status_params[:media_ids],
|
||||||
sensitive: status_params[:sensitive],
|
sensitive: status_params[:sensitive],
|
||||||
spoiler_text: status_params[:spoiler_text],
|
spoiler_text: status_params[:spoiler_text],
|
||||||
|
@ -98,7 +114,8 @@ class Api::V1::StatusesController < Api::BaseController
|
||||||
sensitive: status_params[:sensitive],
|
sensitive: status_params[:sensitive],
|
||||||
language: status_params[:language],
|
language: status_params[:language],
|
||||||
spoiler_text: status_params[:spoiler_text],
|
spoiler_text: status_params[:spoiler_text],
|
||||||
poll: status_params[:poll]
|
poll: status_params[:poll],
|
||||||
|
quote_approval_policy: quote_approval_policy
|
||||||
)
|
)
|
||||||
|
|
||||||
render json: @status, serializer: REST::StatusSerializer
|
render json: @status, serializer: REST::StatusSerializer
|
||||||
|
@ -138,6 +155,16 @@ class Api::V1::StatusesController < Api::BaseController
|
||||||
render json: { error: I18n.t('statuses.errors.in_reply_not_found') }, status: 404
|
render json: { error: I18n.t('statuses.errors.in_reply_not_found') }, status: 404
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def set_quoted_status
|
||||||
|
return unless Mastodon::Feature.outgoing_quotes_enabled?
|
||||||
|
|
||||||
|
@quoted_status = Status.find(status_params[:quoted_status_id]) if status_params[:quoted_status_id].present?
|
||||||
|
authorize(@quoted_status, :quote?) if @quoted_status.present?
|
||||||
|
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||||
|
# TODO: distinguish between non-existing and non-quotable posts
|
||||||
|
render json: { error: I18n.t('statuses.errors.quoted_status_not_found') }, status: 404
|
||||||
|
end
|
||||||
|
|
||||||
def check_statuses_limit
|
def check_statuses_limit
|
||||||
raise(Mastodon::ValidationError) if status_ids.size > DEFAULT_STATUSES_LIMIT
|
raise(Mastodon::ValidationError) if status_ids.size > DEFAULT_STATUSES_LIMIT
|
||||||
end
|
end
|
||||||
|
@ -154,6 +181,8 @@ class Api::V1::StatusesController < Api::BaseController
|
||||||
params.permit(
|
params.permit(
|
||||||
:status,
|
:status,
|
||||||
:in_reply_to_id,
|
:in_reply_to_id,
|
||||||
|
:quoted_status_id,
|
||||||
|
:quote_approval_policy,
|
||||||
:sensitive,
|
:sensitive,
|
||||||
:spoiler_text,
|
:spoiler_text,
|
||||||
:visibility,
|
:visibility,
|
||||||
|
@ -176,6 +205,23 @@ class Api::V1::StatusesController < Api::BaseController
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def quote_approval_policy
|
||||||
|
# TODO: handle `nil` separately
|
||||||
|
return nil unless Mastodon::Feature.outgoing_quotes_enabled? && status_params[:quote_approval_policy].present?
|
||||||
|
|
||||||
|
case status_params[:quote_approval_policy]
|
||||||
|
when 'public'
|
||||||
|
Status::QUOTE_APPROVAL_POLICY_FLAGS[:public] << 16
|
||||||
|
when 'followers'
|
||||||
|
Status::QUOTE_APPROVAL_POLICY_FLAGS[:followers] << 16
|
||||||
|
when 'nobody'
|
||||||
|
0
|
||||||
|
else
|
||||||
|
# TODO: raise more useful message
|
||||||
|
raise ActiveRecord::RecordInvalid
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def serializer_for_status
|
def serializer_for_status
|
||||||
@status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer
|
@status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer
|
||||||
end
|
end
|
||||||
|
|
|
@ -20,7 +20,7 @@ class Api::V2::SearchController < Api::BaseController
|
||||||
@search = Search.new(search_results)
|
@search = Search.new(search_results)
|
||||||
render json: @search, serializer: REST::SearchSerializer
|
render json: @search, serializer: REST::SearchSerializer
|
||||||
rescue Mastodon::SyntaxError
|
rescue Mastodon::SyntaxError
|
||||||
unprocessable_entity
|
unprocessable_content
|
||||||
rescue ActiveRecord::RecordNotFound
|
rescue ActiveRecord::RecordNotFound
|
||||||
not_found
|
not_found
|
||||||
end
|
end
|
||||||
|
|
|
@ -49,7 +49,7 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
|
||||||
{
|
{
|
||||||
policy: 'all',
|
policy: 'all',
|
||||||
alerts: Notification::TYPES.index_with { alerts_enabled },
|
alerts: Notification::TYPES.index_with { alerts_enabled },
|
||||||
}
|
}.deep_stringify_keys
|
||||||
end
|
end
|
||||||
|
|
||||||
def alerts_enabled
|
def alerts_enabled
|
||||||
|
|
|
@ -28,7 +28,7 @@ class ApplicationController < ActionController::Base
|
||||||
rescue_from Mastodon::NotPermittedError, with: :forbidden
|
rescue_from Mastodon::NotPermittedError, with: :forbidden
|
||||||
rescue_from ActionController::RoutingError, ActiveRecord::RecordNotFound, with: :not_found
|
rescue_from ActionController::RoutingError, ActiveRecord::RecordNotFound, with: :not_found
|
||||||
rescue_from ActionController::UnknownFormat, with: :not_acceptable
|
rescue_from ActionController::UnknownFormat, with: :not_acceptable
|
||||||
rescue_from ActionController::InvalidAuthenticityToken, with: :unprocessable_entity
|
rescue_from ActionController::InvalidAuthenticityToken, with: :unprocessable_content
|
||||||
rescue_from Mastodon::RateLimitExceededError, with: :too_many_requests
|
rescue_from Mastodon::RateLimitExceededError, with: :too_many_requests
|
||||||
|
|
||||||
rescue_from(*Mastodon::HTTP_CONNECTION_ERRORS, with: :internal_server_error)
|
rescue_from(*Mastodon::HTTP_CONNECTION_ERRORS, with: :internal_server_error)
|
||||||
|
@ -123,7 +123,7 @@ class ApplicationController < ActionController::Base
|
||||||
respond_with_error(410)
|
respond_with_error(410)
|
||||||
end
|
end
|
||||||
|
|
||||||
def unprocessable_entity
|
def unprocessable_content
|
||||||
respond_with_error(422)
|
respond_with_error(422)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -38,8 +38,7 @@ class Auth::OmniauthCallbacksController < Devise::OmniauthCallbacksController
|
||||||
private
|
private
|
||||||
|
|
||||||
def record_login_activity
|
def record_login_activity
|
||||||
LoginActivity.create(
|
@user.login_activities.create(
|
||||||
user: @user,
|
|
||||||
success: true,
|
success: true,
|
||||||
authentication_method: :omniauth,
|
authentication_method: :omniauth,
|
||||||
provider: @provider,
|
provider: @provider,
|
||||||
|
|
|
@ -19,8 +19,7 @@ class Auth::PasswordsController < Devise::PasswordsController
|
||||||
private
|
private
|
||||||
|
|
||||||
def redirect_invalid_reset_token
|
def redirect_invalid_reset_token
|
||||||
flash[:error] = I18n.t('auth.invalid_reset_password_token')
|
redirect_to new_password_path(resource_name), flash: { error: t('auth.invalid_reset_password_token') }
|
||||||
redirect_to new_password_path(resource_name)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def reset_password_token_is_valid?
|
def reset_password_token_is_valid?
|
||||||
|
|
|
@ -12,6 +12,8 @@ class Auth::SessionsController < Devise::SessionsController
|
||||||
skip_before_action :require_functional!
|
skip_before_action :require_functional!
|
||||||
skip_before_action :update_user_sign_in
|
skip_before_action :update_user_sign_in
|
||||||
|
|
||||||
|
around_action :preserve_stored_location, only: :destroy, if: :continue_after?
|
||||||
|
|
||||||
prepend_before_action :check_suspicious!, only: [:create]
|
prepend_before_action :check_suspicious!, only: [:create]
|
||||||
|
|
||||||
include Auth::TwoFactorAuthenticationConcern
|
include Auth::TwoFactorAuthenticationConcern
|
||||||
|
@ -31,11 +33,9 @@ class Auth::SessionsController < Devise::SessionsController
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
tmp_stored_location = stored_location_for(:user)
|
|
||||||
super
|
super
|
||||||
session.delete(:challenge_passed_at)
|
session.delete(:challenge_passed_at)
|
||||||
flash.delete(:notice)
|
flash.delete(:notice)
|
||||||
store_location_for(:user, tmp_stored_location) if continue_after?
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def webauthn_options
|
def webauthn_options
|
||||||
|
@ -96,6 +96,12 @@ class Auth::SessionsController < Devise::SessionsController
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def preserve_stored_location
|
||||||
|
original_stored_location = stored_location_for(:user)
|
||||||
|
yield
|
||||||
|
store_location_for(:user, original_stored_location)
|
||||||
|
end
|
||||||
|
|
||||||
def check_suspicious!
|
def check_suspicious!
|
||||||
user = find_user
|
user = find_user
|
||||||
@login_is_suspicious = suspicious_sign_in?(user) unless user.nil?
|
@login_is_suspicious = suspicious_sign_in?(user) unless user.nil?
|
||||||
|
@ -151,12 +157,11 @@ class Auth::SessionsController < Devise::SessionsController
|
||||||
sign_in(user)
|
sign_in(user)
|
||||||
flash.delete(:notice)
|
flash.delete(:notice)
|
||||||
|
|
||||||
LoginActivity.create(
|
user.login_activities.create(
|
||||||
user: user,
|
request_details.merge(
|
||||||
success: true,
|
authentication_method: security_measure,
|
||||||
authentication_method: security_measure,
|
success: true
|
||||||
ip: request.remote_ip,
|
)
|
||||||
user_agent: request.user_agent
|
|
||||||
)
|
)
|
||||||
|
|
||||||
UserMailer.suspicious_sign_in(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later! if @login_is_suspicious
|
UserMailer.suspicious_sign_in(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later! if @login_is_suspicious
|
||||||
|
@ -167,13 +172,12 @@ class Auth::SessionsController < Devise::SessionsController
|
||||||
end
|
end
|
||||||
|
|
||||||
def on_authentication_failure(user, security_measure, failure_reason)
|
def on_authentication_failure(user, security_measure, failure_reason)
|
||||||
LoginActivity.create(
|
user.login_activities.create(
|
||||||
user: user,
|
request_details.merge(
|
||||||
success: false,
|
authentication_method: security_measure,
|
||||||
authentication_method: security_measure,
|
failure_reason: failure_reason,
|
||||||
failure_reason: failure_reason,
|
success: false
|
||||||
ip: request.remote_ip,
|
)
|
||||||
user_agent: request.user_agent
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Only send a notification email every hour at most
|
# Only send a notification email every hour at most
|
||||||
|
@ -182,6 +186,13 @@ class Auth::SessionsController < Devise::SessionsController
|
||||||
UserMailer.failed_2fa(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later!
|
UserMailer.failed_2fa(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later!
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def request_details
|
||||||
|
{
|
||||||
|
ip: request.remote_ip,
|
||||||
|
user_agent: request.user_agent,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
def second_factor_attempts_key(user)
|
def second_factor_attempts_key(user)
|
||||||
"2fa_auth_attempts:#{user.id}:#{Time.now.utc.hour}"
|
"2fa_auth_attempts:#{user.id}:#{Time.now.utc.hour}"
|
||||||
end
|
end
|
||||||
|
|
|
@ -5,6 +5,18 @@ module Auth::CaptchaConcern
|
||||||
|
|
||||||
include Hcaptcha::Adapters::ViewMethods
|
include Hcaptcha::Adapters::ViewMethods
|
||||||
|
|
||||||
|
CAPTCHA_DIRECTIVES = %w(
|
||||||
|
connect_src
|
||||||
|
frame_src
|
||||||
|
script_src
|
||||||
|
style_src
|
||||||
|
).freeze
|
||||||
|
|
||||||
|
CAPTCHA_SOURCES = %w(
|
||||||
|
https://*.hcaptcha.com
|
||||||
|
https://hcaptcha.com
|
||||||
|
).freeze
|
||||||
|
|
||||||
included do
|
included do
|
||||||
helper_method :render_captcha
|
helper_method :render_captcha
|
||||||
end
|
end
|
||||||
|
@ -42,20 +54,9 @@ module Auth::CaptchaConcern
|
||||||
end
|
end
|
||||||
|
|
||||||
def extend_csp_for_captcha!
|
def extend_csp_for_captcha!
|
||||||
policy = request.content_security_policy&.clone
|
return unless captcha_required? && request.content_security_policy.present?
|
||||||
|
|
||||||
return unless captcha_required? && policy.present?
|
request.content_security_policy = captcha_adjusted_policy
|
||||||
|
|
||||||
%w(script_src frame_src style_src connect_src).each do |directive|
|
|
||||||
values = policy.send(directive)
|
|
||||||
|
|
||||||
values << 'https://hcaptcha.com' unless values.include?('https://hcaptcha.com') || values.include?('https:')
|
|
||||||
values << 'https://*.hcaptcha.com' unless values.include?('https://*.hcaptcha.com') || values.include?('https:')
|
|
||||||
|
|
||||||
policy.send(directive, *values)
|
|
||||||
end
|
|
||||||
|
|
||||||
request.content_security_policy = policy
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def render_captcha
|
def render_captcha
|
||||||
|
@ -63,4 +64,24 @@ module Auth::CaptchaConcern
|
||||||
|
|
||||||
hcaptcha_tags
|
hcaptcha_tags
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def captcha_adjusted_policy
|
||||||
|
request.content_security_policy.clone.tap do |policy|
|
||||||
|
populate_captcha_policy(policy)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def populate_captcha_policy(policy)
|
||||||
|
CAPTCHA_DIRECTIVES.each do |directive|
|
||||||
|
values = policy.send(directive)
|
||||||
|
|
||||||
|
CAPTCHA_SOURCES.each do |source|
|
||||||
|
values << source unless values.include?(source) || values.include?('https:')
|
||||||
|
end
|
||||||
|
|
||||||
|
policy.send(directive, *values)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -64,6 +64,9 @@ module SignatureVerification
|
||||||
return (@signed_request_actor = actor) if signed_request.verified?(actor)
|
return (@signed_request_actor = actor) if signed_request.verified?(actor)
|
||||||
|
|
||||||
fail_with! "Verification failed for #{actor.to_log_human_identifier} #{actor.uri}"
|
fail_with! "Verification failed for #{actor.to_log_human_identifier} #{actor.uri}"
|
||||||
|
rescue Mastodon::MalformedHeaderError => e
|
||||||
|
@signature_verification_failure_code = 400
|
||||||
|
fail_with! e.message
|
||||||
rescue Mastodon::SignatureVerificationError => e
|
rescue Mastodon::SignatureVerificationError => e
|
||||||
fail_with! e.message
|
fail_with! e.message
|
||||||
rescue *Mastodon::HTTP_CONNECTION_ERRORS => e
|
rescue *Mastodon::HTTP_CONNECTION_ERRORS => e
|
||||||
|
|
|
@ -50,6 +50,13 @@ module WebAppControllerConcern
|
||||||
return unless current_user&.require_tos_interstitial?
|
return unless current_user&.require_tos_interstitial?
|
||||||
|
|
||||||
@terms_of_service = TermsOfService.published.first
|
@terms_of_service = TermsOfService.published.first
|
||||||
|
|
||||||
|
# Handle case where terms of service have been removed from the database
|
||||||
|
if @terms_of_service.nil?
|
||||||
|
current_user.update(require_tos_interstitial: false)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
render 'terms_of_service_interstitial/show', layout: 'auth'
|
render 'terms_of_service_interstitial/show', layout: 'auth'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -5,6 +5,6 @@ class Settings::LoginActivitiesController < Settings::BaseController
|
||||||
skip_before_action :require_functional!
|
skip_before_action :require_functional!
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@login_activities = LoginActivity.where(user: current_user).order(id: :desc).page(params[:page])
|
@login_activities = current_user.login_activities.order(id: :desc).page(params[:page])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -22,7 +22,7 @@ class Settings::Migration::RedirectsController < Settings::BaseController
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
if current_account.moved_to_account_id.present?
|
if current_account.moved?
|
||||||
current_account.update!(moved_to_account: nil)
|
current_account.update!(moved_to_account: nil)
|
||||||
ActivityPub::UpdateDistributionWorker.perform_async(current_account.id)
|
ActivityPub::UpdateDistributionWorker.perform_async(current_account.id)
|
||||||
end
|
end
|
||||||
|
|
|
@ -8,8 +8,7 @@ class Settings::SessionsController < Settings::BaseController
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
@session.destroy!
|
@session.destroy!
|
||||||
flash[:notice] = I18n.t('sessions.revoke_success')
|
redirect_to edit_user_registration_path, notice: t('sessions.revoke_success')
|
||||||
redirect_to edit_user_registration_path
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
|
@ -52,7 +52,7 @@ module Settings
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
flash[:error] = I18n.t('webauthn_credentials.create.error')
|
flash[:error] = I18n.t('webauthn_credentials.create.error')
|
||||||
status = :unprocessable_entity
|
status = :unprocessable_content
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
flash[:error] = t('webauthn_credentials.create.error')
|
flash[:error] = t('webauthn_credentials.create.error')
|
||||||
|
@ -86,13 +86,11 @@ module Settings
|
||||||
private
|
private
|
||||||
|
|
||||||
def redirect_invalid_otp
|
def redirect_invalid_otp
|
||||||
flash[:error] = t('webauthn_credentials.otp_required')
|
redirect_to settings_two_factor_authentication_methods_path, flash: { error: t('webauthn_credentials.otp_required') }
|
||||||
redirect_to settings_two_factor_authentication_methods_path
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def redirect_invalid_webauthn
|
def redirect_invalid_webauthn
|
||||||
flash[:error] = t('webauthn_credentials.not_enabled')
|
redirect_to settings_two_factor_authentication_methods_path, flash: { error: t('webauthn_credentials.not_enabled') }
|
||||||
redirect_to settings_two_factor_authentication_methods_path
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -11,6 +11,7 @@ class StatusesController < ApplicationController
|
||||||
before_action :require_account_signature!, only: [:show, :activity], if: -> { request.format == :json && authorized_fetch_mode? }
|
before_action :require_account_signature!, only: [:show, :activity], if: -> { request.format == :json && authorized_fetch_mode? }
|
||||||
before_action :set_status
|
before_action :set_status
|
||||||
before_action :redirect_to_original, only: :show
|
before_action :redirect_to_original, only: :show
|
||||||
|
before_action :verify_embed_allowed, only: :embed
|
||||||
|
|
||||||
after_action :set_link_headers
|
after_action :set_link_headers
|
||||||
|
|
||||||
|
@ -40,8 +41,6 @@ class StatusesController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def embed
|
def embed
|
||||||
return not_found if @status.hidden? || @status.reblog?
|
|
||||||
|
|
||||||
expires_in 180, public: true
|
expires_in 180, public: true
|
||||||
response.headers.delete('X-Frame-Options')
|
response.headers.delete('X-Frame-Options')
|
||||||
|
|
||||||
|
@ -50,6 +49,10 @@ class StatusesController < ApplicationController
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def verify_embed_allowed
|
||||||
|
not_found if @status.hidden? || @status.reblog?
|
||||||
|
end
|
||||||
|
|
||||||
def set_link_headers
|
def set_link_headers
|
||||||
response.headers['Link'] = LinkHeader.new(
|
response.headers['Link'] = LinkHeader.new(
|
||||||
[[ActivityPub::TagManager.instance.uri_for(@status), [%w(rel alternate), %w(type application/activity+json)]]]
|
[[ActivityPub::TagManager.instance.uri_for(@status), [%w(rel alternate), %w(type application/activity+json)]]]
|
||||||
|
|
|
@ -13,6 +13,8 @@ module Admin::ActionLogsHelper
|
||||||
end
|
end
|
||||||
when 'UserRole'
|
when 'UserRole'
|
||||||
link_to log.human_identifier, admin_roles_path(log.target_id)
|
link_to log.human_identifier, admin_roles_path(log.target_id)
|
||||||
|
when 'UsernameBlock'
|
||||||
|
link_to log.human_identifier, edit_admin_username_block_path(log.target_id)
|
||||||
when 'Report'
|
when 'Report'
|
||||||
link_to "##{log.human_identifier.presence || log.target_id}", admin_report_path(log.target_id)
|
link_to "##{log.human_identifier.presence || log.target_id}", admin_report_path(log.target_id)
|
||||||
when 'Instance', 'DomainBlock', 'DomainAllow', 'UnavailableDomain'
|
when 'Instance', 'DomainBlock', 'DomainAllow', 'UnavailableDomain'
|
||||||
|
|
|
@ -66,7 +66,7 @@ module ApplicationHelper
|
||||||
|
|
||||||
def provider_sign_in_link(provider)
|
def provider_sign_in_link(provider)
|
||||||
label = Devise.omniauth_configs[provider]&.strategy&.display_name.presence || I18n.t("auth.providers.#{provider}", default: provider.to_s.chomp('_oauth2').capitalize)
|
label = Devise.omniauth_configs[provider]&.strategy&.display_name.presence || I18n.t("auth.providers.#{provider}", default: provider.to_s.chomp('_oauth2').capitalize)
|
||||||
link_to label, omniauth_authorize_path(:user, provider), class: "button button-#{provider}", method: :post
|
link_to label, omniauth_authorize_path(:user, provider), class: "btn button-#{provider}", method: :post
|
||||||
end
|
end
|
||||||
|
|
||||||
def locale_direction
|
def locale_direction
|
||||||
|
|
|
@ -26,6 +26,12 @@ module ContextHelper
|
||||||
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
|
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
|
||||||
attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@type' => '@id' } },
|
attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@type' => '@id' } },
|
||||||
quote_requests: { 'QuoteRequest' => 'https://w3id.org/fep/044f#QuoteRequest' },
|
quote_requests: { 'QuoteRequest' => 'https://w3id.org/fep/044f#QuoteRequest' },
|
||||||
|
quotes: {
|
||||||
|
'quote' => 'https://w3id.org/fep/044f#quote',
|
||||||
|
'quoteUri' => 'http://fedibird.com/ns#quoteUri',
|
||||||
|
'_misskey_quote' => 'https://misskey-hub.net/ns#_misskey_quote',
|
||||||
|
'quoteAuthorization' => { '@id' => 'https://w3id.org/fep/044f#quoteAuthorization', '@type' => '@id' },
|
||||||
|
},
|
||||||
interaction_policies: {
|
interaction_policies: {
|
||||||
'gts' => 'https://gotosocial.org/ns#',
|
'gts' => 'https://gotosocial.org/ns#',
|
||||||
'interactionPolicy' => { '@id' => 'gts:interactionPolicy', '@type' => '@id' },
|
'interactionPolicy' => { '@id' => 'gts:interactionPolicy', '@type' => '@id' },
|
||||||
|
@ -33,6 +39,12 @@ module ContextHelper
|
||||||
'automaticApproval' => { '@id' => 'gts:automaticApproval', '@type' => '@id' },
|
'automaticApproval' => { '@id' => 'gts:automaticApproval', '@type' => '@id' },
|
||||||
'manualApproval' => { '@id' => 'gts:manualApproval', '@type' => '@id' },
|
'manualApproval' => { '@id' => 'gts:manualApproval', '@type' => '@id' },
|
||||||
},
|
},
|
||||||
|
quote_authorizations: {
|
||||||
|
'gts' => 'https://gotosocial.org/ns#',
|
||||||
|
'quoteAuthorization' => { '@id' => 'https://w3id.org/fep/044f#quoteAuthorization', '@type' => '@id' },
|
||||||
|
'interactingObject' => { '@id' => 'gts:interactingObject' },
|
||||||
|
'interactionTarget' => { '@id' => 'gts:interactionTarget' },
|
||||||
|
},
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
def full_context
|
def full_context
|
||||||
|
|
|
@ -1,18 +0,0 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
module EmailHelper
|
|
||||||
def self.included(base)
|
|
||||||
base.extend(self)
|
|
||||||
end
|
|
||||||
|
|
||||||
def email_to_canonical_email(str)
|
|
||||||
username, domain = str.downcase.split('@', 2)
|
|
||||||
username, = username.delete('.').split('+', 2)
|
|
||||||
|
|
||||||
"#{username}@#{domain}"
|
|
||||||
end
|
|
||||||
|
|
||||||
def email_to_canonical_email_hash(str)
|
|
||||||
Digest::SHA2.new(256).hexdigest(email_to_canonical_email(str))
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -65,12 +65,12 @@ module FormattingHelper
|
||||||
end
|
end
|
||||||
|
|
||||||
def rss_content_preroll(status)
|
def rss_content_preroll(status)
|
||||||
if status.spoiler_text?
|
return unless status.spoiler_text?
|
||||||
safe_join [
|
|
||||||
tag.p { spoiler_with_warning(status) },
|
safe_join [
|
||||||
tag.hr,
|
tag.p { spoiler_with_warning(status) },
|
||||||
]
|
tag.hr,
|
||||||
end
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
def spoiler_with_warning(status)
|
def spoiler_with_warning(status)
|
||||||
|
@ -81,10 +81,10 @@ module FormattingHelper
|
||||||
end
|
end
|
||||||
|
|
||||||
def rss_content_postroll(status)
|
def rss_content_postroll(status)
|
||||||
if status.preloadable_poll
|
return unless status.preloadable_poll
|
||||||
tag.p do
|
|
||||||
poll_option_tags(status)
|
tag.p do
|
||||||
end
|
poll_option_tags(status)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -39,18 +39,8 @@ module HomeHelper
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def obscured_counter(count)
|
def field_verified_class(verified)
|
||||||
if count <= 0
|
if verified
|
||||||
'0'
|
|
||||||
elsif count == 1
|
|
||||||
'1'
|
|
||||||
else
|
|
||||||
'1+'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def custom_field_classes(field)
|
|
||||||
if field.verified?
|
|
||||||
'verified'
|
'verified'
|
||||||
else
|
else
|
||||||
'emojify'
|
'emojify'
|
||||||
|
|
|
@ -134,7 +134,7 @@ module JsonLdHelper
|
||||||
patch_for_forwarding!(value, compacted_value)
|
patch_for_forwarding!(value, compacted_value)
|
||||||
elsif value.is_a?(Array)
|
elsif value.is_a?(Array)
|
||||||
compacted_value = [compacted_value] unless compacted_value.is_a?(Array)
|
compacted_value = [compacted_value] unless compacted_value.is_a?(Array)
|
||||||
return if value.size != compacted_value.size
|
return nil if value.size != compacted_value.size
|
||||||
|
|
||||||
compacted[key] = value.zip(compacted_value).map do |v, vc|
|
compacted[key] = value.zip(compacted_value).map do |v, vc|
|
||||||
if v.is_a?(Hash) && vc.is_a?(Hash)
|
if v.is_a?(Hash) && vc.is_a?(Hash)
|
||||||
|
|
|
@ -24,24 +24,24 @@ module ThemeHelper
|
||||||
end
|
end
|
||||||
|
|
||||||
def custom_stylesheet
|
def custom_stylesheet
|
||||||
if active_custom_stylesheet.present?
|
return if active_custom_stylesheet.blank?
|
||||||
stylesheet_link_tag(
|
|
||||||
custom_css_path(active_custom_stylesheet),
|
stylesheet_link_tag(
|
||||||
host: root_url,
|
custom_css_path(active_custom_stylesheet),
|
||||||
media: :all,
|
host: root_url,
|
||||||
skip_pipeline: true
|
media: :all,
|
||||||
)
|
skip_pipeline: true
|
||||||
end
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def active_custom_stylesheet
|
def active_custom_stylesheet
|
||||||
if cached_custom_css_digest.present?
|
return if cached_custom_css_digest.blank?
|
||||||
[:custom, cached_custom_css_digest.to_s.first(8)]
|
|
||||||
.compact_blank
|
[:custom, cached_custom_css_digest.to_s.first(8)]
|
||||||
.join('-')
|
.compact_blank
|
||||||
end
|
.join('-')
|
||||||
end
|
end
|
||||||
|
|
||||||
def cached_custom_css_digest
|
def cached_custom_css_digest
|
||||||
|
|
|
@ -1 +1,3 @@
|
||||||
Images in this folder are based on [Tabler.io icons](https://tabler.io/icons).
|
Images in this folder are based on [Tabler.io icons](https://tabler.io/icons).
|
||||||
|
|
||||||
|
Seems to be 1.5 width icons scaled to 64×64px and centered above a blue square with round corners (24px).
|
||||||
|
|
BIN
app/javascript/images/mailer-new/heading/quote.png
Normal file
BIN
app/javascript/images/mailer-new/heading/quote.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.8 KiB |
|
@ -228,6 +228,8 @@ export function submitCompose() {
|
||||||
visibility: getState().getIn(['compose', 'privacy']),
|
visibility: getState().getIn(['compose', 'privacy']),
|
||||||
poll: getState().getIn(['compose', 'poll'], null),
|
poll: getState().getIn(['compose', 'poll'], null),
|
||||||
language: getState().getIn(['compose', 'language']),
|
language: getState().getIn(['compose', 'language']),
|
||||||
|
quoted_status_id: getState().getIn(['compose', 'quoted_status_id']),
|
||||||
|
quote_approval_policy: getState().getIn(['compose', 'quote_policy']),
|
||||||
},
|
},
|
||||||
headers: {
|
headers: {
|
||||||
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
|
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
|
||||||
|
|
|
@ -1,9 +1,18 @@
|
||||||
|
import { createAction } from '@reduxjs/toolkit';
|
||||||
import type { List as ImmutableList, Map as ImmutableMap } from 'immutable';
|
import type { List as ImmutableList, Map as ImmutableMap } from 'immutable';
|
||||||
|
|
||||||
import { apiUpdateMedia } from 'mastodon/api/compose';
|
import { apiUpdateMedia } from 'mastodon/api/compose';
|
||||||
import type { ApiMediaAttachmentJSON } from 'mastodon/api_types/media_attachments';
|
import type { ApiMediaAttachmentJSON } from 'mastodon/api_types/media_attachments';
|
||||||
import type { MediaAttachment } from 'mastodon/models/media_attachment';
|
import type { MediaAttachment } from 'mastodon/models/media_attachment';
|
||||||
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
import {
|
||||||
|
createDataLoadingThunk,
|
||||||
|
createAppThunk,
|
||||||
|
} from 'mastodon/store/typed_functions';
|
||||||
|
|
||||||
|
import type { ApiQuotePolicy } from '../api_types/quotes';
|
||||||
|
import type { Status } from '../models/status';
|
||||||
|
|
||||||
|
import { ensureComposeIsVisible } from './compose';
|
||||||
|
|
||||||
type SimulatedMediaAttachmentJSON = ApiMediaAttachmentJSON & {
|
type SimulatedMediaAttachmentJSON = ApiMediaAttachmentJSON & {
|
||||||
unattached?: boolean;
|
unattached?: boolean;
|
||||||
|
@ -68,3 +77,26 @@ export const changeUploadCompose = createDataLoadingThunk(
|
||||||
useLoadingBar: false,
|
useLoadingBar: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const quoteComposeByStatus = createAppThunk(
|
||||||
|
'compose/quoteComposeStatus',
|
||||||
|
(status: Status, { getState }) => {
|
||||||
|
ensureComposeIsVisible(getState);
|
||||||
|
return status;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const quoteComposeById = createAppThunk(
|
||||||
|
(statusId: string, { dispatch, getState }) => {
|
||||||
|
const status = getState().statuses.get(statusId);
|
||||||
|
if (status) {
|
||||||
|
dispatch(quoteComposeByStatus(status));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const quoteComposeCancel = createAction('compose/quoteComposeCancel');
|
||||||
|
|
||||||
|
export const setQuotePolicy = createAction<ApiQuotePolicy>(
|
||||||
|
'compose/setQuotePolicy',
|
||||||
|
);
|
||||||
|
|
|
@ -1,4 +1,8 @@
|
||||||
import { apiReblog, apiUnreblog } from 'mastodon/api/interactions';
|
import {
|
||||||
|
apiReblog,
|
||||||
|
apiUnreblog,
|
||||||
|
apiRevokeQuote,
|
||||||
|
} from 'mastodon/api/interactions';
|
||||||
import type { StatusVisibility } from 'mastodon/models/status';
|
import type { StatusVisibility } from 'mastodon/models/status';
|
||||||
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
||||||
|
|
||||||
|
@ -33,3 +37,19 @@ export const unreblog = createDataLoadingThunk(
|
||||||
return discardLoadData;
|
return discardLoadData;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const revokeQuote = createDataLoadingThunk(
|
||||||
|
'status/revoke_quote',
|
||||||
|
({
|
||||||
|
statusId,
|
||||||
|
quotedStatusId,
|
||||||
|
}: {
|
||||||
|
statusId: string;
|
||||||
|
quotedStatusId: string;
|
||||||
|
}) => apiRevokeQuote(quotedStatusId, statusId),
|
||||||
|
(data, { dispatch, discardLoadData }) => {
|
||||||
|
dispatch(importFetchedStatus(data));
|
||||||
|
|
||||||
|
return discardLoadData;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
|
@ -31,7 +31,9 @@ import { NOTIFICATIONS_FILTER_SET } from './notifications';
|
||||||
import { saveSettings } from './settings';
|
import { saveSettings } from './settings';
|
||||||
|
|
||||||
function excludeAllTypesExcept(filter: string) {
|
function excludeAllTypesExcept(filter: string) {
|
||||||
return allNotificationTypes.filter((item) => item !== filter);
|
return allNotificationTypes.filter(
|
||||||
|
(item) => item !== filter && !(item === 'quote' && filter === 'mention'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getExcludedTypes(state: RootState) {
|
function getExcludedTypes(state: RootState) {
|
||||||
|
@ -156,12 +158,15 @@ export const processNewNotificationForGroups = createAppAsyncThunk(
|
||||||
const showInColumn =
|
const showInColumn =
|
||||||
activeFilter === 'all'
|
activeFilter === 'all'
|
||||||
? notificationShows[notification.type] !== false
|
? notificationShows[notification.type] !== false
|
||||||
: activeFilter === notification.type;
|
: activeFilter === notification.type ||
|
||||||
|
(activeFilter === 'mention' && notification.type === 'quote');
|
||||||
|
|
||||||
if (!showInColumn) return;
|
if (!showInColumn) return;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(notification.type === 'mention' || notification.type === 'update') &&
|
(notification.type === 'mention' ||
|
||||||
|
notification.type === 'update' ||
|
||||||
|
notification.type === 'quote') &&
|
||||||
notification.status?.filtered
|
notification.status?.filtered
|
||||||
) {
|
) {
|
||||||
const filters = notification.status.filtered.filter((result) =>
|
const filters = notification.status.filtered.filter((result) =>
|
||||||
|
|
|
@ -31,7 +31,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
|
||||||
|
|
||||||
let filtered = false;
|
let filtered = false;
|
||||||
|
|
||||||
if (['mention', 'status'].includes(notification.type) && notification.status.filtered) {
|
if (['mention', 'status', 'quote'].includes(notification.type) && notification.status.filtered) {
|
||||||
const filters = notification.status.filtered.filter(result => result.filter.context.includes('notifications'));
|
const filters = notification.status.filtered.filter(result => result.filter.context.includes('notifications'));
|
||||||
|
|
||||||
if (filters.some(result => result.filter.filter_action === 'hide')) {
|
if (filters.some(result => result.filter.filter_action === 'hide')) {
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { createAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
import { apiGetContext } from 'mastodon/api/statuses';
|
import { apiGetContext } from 'mastodon/api/statuses';
|
||||||
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
||||||
|
|
||||||
|
@ -6,13 +8,18 @@ import { importFetchedStatuses } from './importer';
|
||||||
export const fetchContext = createDataLoadingThunk(
|
export const fetchContext = createDataLoadingThunk(
|
||||||
'status/context',
|
'status/context',
|
||||||
({ statusId }: { statusId: string }) => apiGetContext(statusId),
|
({ statusId }: { statusId: string }) => apiGetContext(statusId),
|
||||||
(context, { dispatch }) => {
|
({ context, refresh }, { dispatch }) => {
|
||||||
const statuses = context.ancestors.concat(context.descendants);
|
const statuses = context.ancestors.concat(context.descendants);
|
||||||
|
|
||||||
dispatch(importFetchedStatuses(statuses));
|
dispatch(importFetchedStatuses(statuses));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
context,
|
context,
|
||||||
|
refresh,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const completeContextRefresh = createAction<{ statusId: string }>(
|
||||||
|
'status/context/complete',
|
||||||
|
);
|
||||||
|
|
|
@ -20,6 +20,50 @@ export const getLinks = (response: AxiosResponse) => {
|
||||||
return LinkHeader.parse(value);
|
return LinkHeader.parse(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface AsyncRefreshHeader {
|
||||||
|
id: string;
|
||||||
|
retry: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAsyncRefreshHeader = (obj: object): obj is AsyncRefreshHeader =>
|
||||||
|
'id' in obj && 'retry' in obj;
|
||||||
|
|
||||||
|
export const getAsyncRefreshHeader = (
|
||||||
|
response: AxiosResponse,
|
||||||
|
): AsyncRefreshHeader | null => {
|
||||||
|
const value = response.headers['mastodon-async-refresh'] as
|
||||||
|
| string
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const asyncRefreshHeader: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
value.split(/,\s*/).forEach((pair) => {
|
||||||
|
const [key, val] = pair.split('=', 2);
|
||||||
|
|
||||||
|
let typedValue: string | number;
|
||||||
|
|
||||||
|
if (key && ['id', 'retry'].includes(key) && val) {
|
||||||
|
if (val.startsWith('"')) {
|
||||||
|
typedValue = val.slice(1, -1);
|
||||||
|
} else {
|
||||||
|
typedValue = parseInt(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
asyncRefreshHeader[key] = typedValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isAsyncRefreshHeader(asyncRefreshHeader)) {
|
||||||
|
return asyncRefreshHeader;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const csrfHeader: RawAxiosRequestHeaders = {};
|
const csrfHeader: RawAxiosRequestHeaders = {};
|
||||||
|
|
||||||
const setCSRFHeader = () => {
|
const setCSRFHeader = () => {
|
||||||
|
@ -83,7 +127,7 @@ export default function api(withAuthorization = true) {
|
||||||
return instance;
|
return instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ApiUrl = `v${1 | 2}/${string}`;
|
type ApiUrl = `v${1 | '1_alpha' | 2}/${string}`;
|
||||||
type RequestParamsOrData = Record<string, unknown>;
|
type RequestParamsOrData = Record<string, unknown>;
|
||||||
|
|
||||||
export async function apiRequest<ApiResponse = unknown>(
|
export async function apiRequest<ApiResponse = unknown>(
|
||||||
|
|
5
app/javascript/mastodon/api/async_refreshes.ts
Normal file
5
app/javascript/mastodon/api/async_refreshes.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
import { apiRequestGet } from 'mastodon/api';
|
||||||
|
import type { ApiAsyncRefreshJSON } from 'mastodon/api_types/async_refreshes';
|
||||||
|
|
||||||
|
export const apiGetAsyncRefresh = (id: string) =>
|
||||||
|
apiRequestGet<ApiAsyncRefreshJSON>(`v1_alpha/async_refreshes/${id}`);
|
|
@ -8,3 +8,8 @@ export const apiReblog = (statusId: string, visibility: StatusVisibility) =>
|
||||||
|
|
||||||
export const apiUnreblog = (statusId: string) =>
|
export const apiUnreblog = (statusId: string) =>
|
||||||
apiRequestPost<Status>(`v1/statuses/${statusId}/unreblog`);
|
apiRequestPost<Status>(`v1/statuses/${statusId}/unreblog`);
|
||||||
|
|
||||||
|
export const apiRevokeQuote = (quotedStatusId: string, statusId: string) =>
|
||||||
|
apiRequestPost<Status>(
|
||||||
|
`v1/statuses/${quotedStatusId}/quotes/${statusId}/revoke`,
|
||||||
|
);
|
||||||
|
|
|
@ -1,5 +1,14 @@
|
||||||
import { apiRequestGet } from 'mastodon/api';
|
import api, { getAsyncRefreshHeader } from 'mastodon/api';
|
||||||
import type { ApiContextJSON } from 'mastodon/api_types/statuses';
|
import type { ApiContextJSON } from 'mastodon/api_types/statuses';
|
||||||
|
|
||||||
export const apiGetContext = (statusId: string) =>
|
export const apiGetContext = async (statusId: string) => {
|
||||||
apiRequestGet<ApiContextJSON>(`v1/statuses/${statusId}/context`);
|
const response = await api().request<ApiContextJSON>({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/v1/statuses/${statusId}/context`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
context: response.data,
|
||||||
|
refresh: getAsyncRefreshHeader(response),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
|
@ -37,7 +37,7 @@ export interface BaseApiAccountJSON {
|
||||||
roles?: ApiAccountJSON[];
|
roles?: ApiAccountJSON[];
|
||||||
statuses_count: number;
|
statuses_count: number;
|
||||||
uri: string;
|
uri: string;
|
||||||
url: string;
|
url?: string;
|
||||||
username: string;
|
username: string;
|
||||||
moved?: ApiAccountJSON;
|
moved?: ApiAccountJSON;
|
||||||
suspended?: boolean;
|
suspended?: boolean;
|
||||||
|
|
7
app/javascript/mastodon/api_types/async_refreshes.ts
Normal file
7
app/javascript/mastodon/api_types/async_refreshes.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
export interface ApiAsyncRefreshJSON {
|
||||||
|
async_refresh: {
|
||||||
|
id: string;
|
||||||
|
status: 'running' | 'finished';
|
||||||
|
result_count: number;
|
||||||
|
};
|
||||||
|
}
|
|
@ -13,6 +13,7 @@ export const allNotificationTypes = [
|
||||||
'favourite',
|
'favourite',
|
||||||
'reblog',
|
'reblog',
|
||||||
'mention',
|
'mention',
|
||||||
|
'quote',
|
||||||
'poll',
|
'poll',
|
||||||
'status',
|
'status',
|
||||||
'update',
|
'update',
|
||||||
|
@ -28,6 +29,7 @@ export type NotificationWithStatusType =
|
||||||
| 'reblog'
|
| 'reblog'
|
||||||
| 'status'
|
| 'status'
|
||||||
| 'mention'
|
| 'mention'
|
||||||
|
| 'quote'
|
||||||
| 'poll'
|
| 'poll'
|
||||||
| 'update';
|
| 'update';
|
||||||
|
|
||||||
|
|
23
app/javascript/mastodon/api_types/quotes.ts
Normal file
23
app/javascript/mastodon/api_types/quotes.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import type { ApiStatusJSON } from './statuses';
|
||||||
|
|
||||||
|
export type ApiQuoteState = 'accepted' | 'pending' | 'revoked' | 'unauthorized';
|
||||||
|
export type ApiQuotePolicy = 'public' | 'followers' | 'nobody';
|
||||||
|
|
||||||
|
interface ApiQuoteEmptyJSON {
|
||||||
|
state: Exclude<ApiQuoteState, 'accepted'>;
|
||||||
|
quoted_status: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiNestedQuoteJSON {
|
||||||
|
state: 'accepted';
|
||||||
|
quoted_status_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiQuoteAcceptedJSON {
|
||||||
|
state: 'accepted';
|
||||||
|
quoted_status: Omit<ApiStatusJSON, 'quote'> & {
|
||||||
|
quote: ApiNestedQuoteJSON | ApiQuoteEmptyJSON;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApiQuoteJSON = ApiQuoteAcceptedJSON | ApiQuoteEmptyJSON;
|
|
@ -4,6 +4,7 @@ import type { ApiAccountJSON } from './accounts';
|
||||||
import type { ApiCustomEmojiJSON } from './custom_emoji';
|
import type { ApiCustomEmojiJSON } from './custom_emoji';
|
||||||
import type { ApiMediaAttachmentJSON } from './media_attachments';
|
import type { ApiMediaAttachmentJSON } from './media_attachments';
|
||||||
import type { ApiPollJSON } from './polls';
|
import type { ApiPollJSON } from './polls';
|
||||||
|
import type { ApiQuoteJSON } from './quotes';
|
||||||
|
|
||||||
// See app/modals/status.rb
|
// See app/modals/status.rb
|
||||||
export type StatusVisibility =
|
export type StatusVisibility =
|
||||||
|
@ -118,6 +119,7 @@ export interface ApiStatusJSON {
|
||||||
|
|
||||||
card?: ApiPreviewCardJSON;
|
card?: ApiPreviewCardJSON;
|
||||||
poll?: ApiPollJSON;
|
poll?: ApiPollJSON;
|
||||||
|
quote?: ApiQuoteJSON;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiContextJSON {
|
export interface ApiContextJSON {
|
||||||
|
|
|
@ -1,20 +1,76 @@
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
import { useLinks } from 'mastodon/hooks/useLinks';
|
import { useLinks } from 'mastodon/hooks/useLinks';
|
||||||
|
|
||||||
export const AccountBio: React.FC<{
|
import { EmojiHTML } from '../features/emoji/emoji_html';
|
||||||
note: string;
|
import { useAppSelector } from '../store';
|
||||||
className: string;
|
import { isModernEmojiEnabled } from '../utils/environment';
|
||||||
}> = ({ note, className }) => {
|
|
||||||
const handleClick = useLinks();
|
|
||||||
|
|
||||||
if (note.length === 0 || note === '<p></p>') {
|
interface AccountBioProps {
|
||||||
|
className: string;
|
||||||
|
accountId: string;
|
||||||
|
showDropdown?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AccountBio: React.FC<AccountBioProps> = ({
|
||||||
|
className,
|
||||||
|
accountId,
|
||||||
|
showDropdown = false,
|
||||||
|
}) => {
|
||||||
|
const handleClick = useLinks(showDropdown);
|
||||||
|
const handleNodeChange = useCallback(
|
||||||
|
(node: HTMLDivElement | null) => {
|
||||||
|
if (!showDropdown || !node || node.childNodes.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
addDropdownToHashtags(node, accountId);
|
||||||
|
},
|
||||||
|
[showDropdown, accountId],
|
||||||
|
);
|
||||||
|
const note = useAppSelector((state) => {
|
||||||
|
const account = state.accounts.get(accountId);
|
||||||
|
if (!account) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return isModernEmojiEnabled() ? account.note : account.note_emojified;
|
||||||
|
});
|
||||||
|
const extraEmojis = useAppSelector((state) => {
|
||||||
|
const account = state.accounts.get(accountId);
|
||||||
|
return account?.emojis;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (note.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`${className} translate`}
|
className={`${className} translate`}
|
||||||
dangerouslySetInnerHTML={{ __html: note }}
|
|
||||||
onClickCapture={handleClick}
|
onClickCapture={handleClick}
|
||||||
/>
|
ref={handleNodeChange}
|
||||||
|
>
|
||||||
|
<EmojiHTML htmlString={note} extraEmojis={extraEmojis} />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function addDropdownToHashtags(node: HTMLElement | null, accountId: string) {
|
||||||
|
if (!node) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const childNode of node.childNodes) {
|
||||||
|
if (!(childNode instanceof HTMLElement)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
childNode instanceof HTMLAnchorElement &&
|
||||||
|
(childNode.classList.contains('hashtag') ||
|
||||||
|
childNode.innerText.startsWith('#')) &&
|
||||||
|
!childNode.dataset.menuHashtag
|
||||||
|
) {
|
||||||
|
childNode.dataset.menuHashtag = accountId;
|
||||||
|
} else if (childNode.childNodes.length > 0) {
|
||||||
|
addDropdownToHashtags(childNode, accountId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@ import {
|
||||||
useCallback,
|
useCallback,
|
||||||
cloneElement,
|
cloneElement,
|
||||||
Children,
|
Children,
|
||||||
|
useId,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
@ -16,6 +17,7 @@ import Overlay from 'react-overlays/Overlay';
|
||||||
import type {
|
import type {
|
||||||
OffsetValue,
|
OffsetValue,
|
||||||
UsePopperOptions,
|
UsePopperOptions,
|
||||||
|
Placement,
|
||||||
} from 'react-overlays/esm/usePopper';
|
} from 'react-overlays/esm/usePopper';
|
||||||
|
|
||||||
import { fetchRelationships } from 'mastodon/actions/accounts';
|
import { fetchRelationships } from 'mastodon/actions/accounts';
|
||||||
|
@ -295,6 +297,11 @@ interface DropdownProps<Item = MenuItem> {
|
||||||
title?: string;
|
title?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
scrollable?: boolean;
|
scrollable?: boolean;
|
||||||
|
placement?: Placement;
|
||||||
|
/**
|
||||||
|
* Prevent the `ScrollableList` with this scrollKey
|
||||||
|
* from being scrolled while the dropdown is open
|
||||||
|
*/
|
||||||
scrollKey?: string;
|
scrollKey?: string;
|
||||||
status?: ImmutableMap<string, unknown>;
|
status?: ImmutableMap<string, unknown>;
|
||||||
forceDropdown?: boolean;
|
forceDropdown?: boolean;
|
||||||
|
@ -316,6 +323,7 @@ export const Dropdown = <Item = MenuItem,>({
|
||||||
title = 'Menu',
|
title = 'Menu',
|
||||||
disabled,
|
disabled,
|
||||||
scrollable,
|
scrollable,
|
||||||
|
placement = 'bottom',
|
||||||
status,
|
status,
|
||||||
forceDropdown = false,
|
forceDropdown = false,
|
||||||
renderItem,
|
renderItem,
|
||||||
|
@ -331,16 +339,15 @@ export const Dropdown = <Item = MenuItem,>({
|
||||||
);
|
);
|
||||||
const [currentId] = useState(id++);
|
const [currentId] = useState(id++);
|
||||||
const open = currentId === openDropdownId;
|
const open = currentId === openDropdownId;
|
||||||
const activeElement = useRef<HTMLElement | null>(null);
|
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const targetRef = useRef<HTMLButtonElement | null>(null);
|
const menuId = useId();
|
||||||
const prefetchAccountId = status
|
const prefetchAccountId = status
|
||||||
? status.getIn(['account', 'id'])
|
? status.getIn(['account', 'id'])
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
if (activeElement.current) {
|
if (buttonRef.current) {
|
||||||
activeElement.current.focus({ preventScroll: true });
|
buttonRef.current.focus({ preventScroll: true });
|
||||||
activeElement.current = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch(
|
dispatch(
|
||||||
|
@ -375,7 +382,7 @@ export const Dropdown = <Item = MenuItem,>({
|
||||||
[handleClose, onItemClick, items],
|
[handleClose, onItemClick, items],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleClick = useCallback(
|
const toggleDropdown = useCallback(
|
||||||
(e: React.MouseEvent | React.KeyboardEvent) => {
|
(e: React.MouseEvent | React.KeyboardEvent) => {
|
||||||
const { type } = e;
|
const { type } = e;
|
||||||
|
|
||||||
|
@ -423,38 +430,6 @@ export const Dropdown = <Item = MenuItem,>({
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMouseDown = useCallback(() => {
|
|
||||||
if (!open && document.activeElement instanceof HTMLElement) {
|
|
||||||
activeElement.current = document.activeElement;
|
|
||||||
}
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
const handleButtonKeyDown = useCallback(
|
|
||||||
(e: React.KeyboardEvent) => {
|
|
||||||
switch (e.key) {
|
|
||||||
case ' ':
|
|
||||||
case 'Enter':
|
|
||||||
handleMouseDown();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[handleMouseDown],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleKeyPress = useCallback(
|
|
||||||
(e: React.KeyboardEvent) => {
|
|
||||||
switch (e.key) {
|
|
||||||
case ' ':
|
|
||||||
case 'Enter':
|
|
||||||
handleClick(e);
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[handleClick],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (currentId === openDropdownId) {
|
if (currentId === openDropdownId) {
|
||||||
|
@ -465,14 +440,16 @@ export const Dropdown = <Item = MenuItem,>({
|
||||||
|
|
||||||
let button: React.ReactElement;
|
let button: React.ReactElement;
|
||||||
|
|
||||||
|
const buttonProps = {
|
||||||
|
disabled,
|
||||||
|
onClick: toggleDropdown,
|
||||||
|
'aria-expanded': open,
|
||||||
|
'aria-controls': menuId,
|
||||||
|
ref: buttonRef,
|
||||||
|
};
|
||||||
|
|
||||||
if (children) {
|
if (children) {
|
||||||
button = cloneElement(Children.only(children), {
|
button = cloneElement(Children.only(children), buttonProps);
|
||||||
onClick: handleClick,
|
|
||||||
onMouseDown: handleMouseDown,
|
|
||||||
onKeyDown: handleButtonKeyDown,
|
|
||||||
onKeyPress: handleKeyPress,
|
|
||||||
ref: targetRef,
|
|
||||||
});
|
|
||||||
} else if (icon && iconComponent) {
|
} else if (icon && iconComponent) {
|
||||||
button = (
|
button = (
|
||||||
<IconButton
|
<IconButton
|
||||||
|
@ -480,12 +457,7 @@ export const Dropdown = <Item = MenuItem,>({
|
||||||
iconComponent={iconComponent}
|
iconComponent={iconComponent}
|
||||||
title={title}
|
title={title}
|
||||||
active={open}
|
active={open}
|
||||||
disabled={disabled}
|
{...buttonProps}
|
||||||
onClick={handleClick}
|
|
||||||
onMouseDown={handleMouseDown}
|
|
||||||
onKeyDown={handleButtonKeyDown}
|
|
||||||
onKeyPress={handleKeyPress}
|
|
||||||
ref={targetRef}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
@ -499,13 +471,13 @@ export const Dropdown = <Item = MenuItem,>({
|
||||||
<Overlay
|
<Overlay
|
||||||
show={open}
|
show={open}
|
||||||
offset={offset}
|
offset={offset}
|
||||||
placement='bottom'
|
placement={placement}
|
||||||
flip
|
flip
|
||||||
target={targetRef}
|
target={buttonRef}
|
||||||
popperConfig={popperConfig}
|
popperConfig={popperConfig}
|
||||||
>
|
>
|
||||||
{({ props, arrowProps, placement }) => (
|
{({ props, arrowProps, placement }) => (
|
||||||
<div {...props}>
|
<div {...props} id={menuId}>
|
||||||
<div className={`dropdown-animation dropdown-menu ${placement}`}>
|
<div className={`dropdown-animation dropdown-menu ${placement}`}>
|
||||||
<div
|
<div
|
||||||
className={`dropdown-menu__arrow ${placement}`}
|
className={`dropdown-menu__arrow ${placement}`}
|
||||||
|
|
|
@ -37,7 +37,6 @@ export const GIFV = forwardRef<HTMLVideoElement, Props>(
|
||||||
role='button'
|
role='button'
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label={alt}
|
aria-label={alt}
|
||||||
title={alt}
|
|
||||||
lang={lang}
|
lang={lang}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
/>
|
/>
|
||||||
|
@ -49,7 +48,6 @@ export const GIFV = forwardRef<HTMLVideoElement, Props>(
|
||||||
role='button'
|
role='button'
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label={alt}
|
aria-label={alt}
|
||||||
title={alt}
|
|
||||||
lang={lang}
|
lang={lang}
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
|
|
171
app/javascript/mastodon/components/hotkeys/hotkeys.stories.tsx
Normal file
171
app/javascript/mastodon/components/hotkeys/hotkeys.stories.tsx
Normal file
|
@ -0,0 +1,171 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
|
import { expect } from 'storybook/test';
|
||||||
|
|
||||||
|
import type { HandlerMap } from '.';
|
||||||
|
import { Hotkeys } from '.';
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Components/Hotkeys',
|
||||||
|
component: Hotkeys,
|
||||||
|
args: {
|
||||||
|
global: undefined,
|
||||||
|
focusable: undefined,
|
||||||
|
handlers: {},
|
||||||
|
},
|
||||||
|
tags: ['test'],
|
||||||
|
} satisfies Meta<typeof Hotkeys>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
const hotkeyTest: Story['play'] = async ({ canvas, userEvent }) => {
|
||||||
|
async function confirmHotkey(name: string, shouldFind = true) {
|
||||||
|
// 'status' is the role of the 'output' element
|
||||||
|
const output = await canvas.findByRole('status');
|
||||||
|
if (shouldFind) {
|
||||||
|
await expect(output).toHaveTextContent(name);
|
||||||
|
} else {
|
||||||
|
await expect(output).not.toHaveTextContent(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const button = await canvas.findByRole('button');
|
||||||
|
await userEvent.click(button);
|
||||||
|
|
||||||
|
await userEvent.keyboard('n');
|
||||||
|
await confirmHotkey('new');
|
||||||
|
|
||||||
|
await userEvent.keyboard('/');
|
||||||
|
await confirmHotkey('search');
|
||||||
|
|
||||||
|
await userEvent.keyboard('o');
|
||||||
|
await confirmHotkey('open');
|
||||||
|
|
||||||
|
await userEvent.keyboard('{Alt>}N{/Alt}');
|
||||||
|
await confirmHotkey('forceNew');
|
||||||
|
|
||||||
|
await userEvent.keyboard('gh');
|
||||||
|
await confirmHotkey('goToHome');
|
||||||
|
|
||||||
|
await userEvent.keyboard('gn');
|
||||||
|
await confirmHotkey('goToNotifications');
|
||||||
|
|
||||||
|
await userEvent.keyboard('gf');
|
||||||
|
await confirmHotkey('goToFavourites');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure that hotkeys are not triggered when certain
|
||||||
|
* interactive elements are focused:
|
||||||
|
*/
|
||||||
|
|
||||||
|
await userEvent.keyboard('{enter}');
|
||||||
|
await confirmHotkey('open', false);
|
||||||
|
|
||||||
|
const input = await canvas.findByRole('textbox');
|
||||||
|
await userEvent.click(input);
|
||||||
|
|
||||||
|
await userEvent.keyboard('n');
|
||||||
|
await confirmHotkey('new', false);
|
||||||
|
|
||||||
|
await userEvent.keyboard('{backspace}');
|
||||||
|
await confirmHotkey('None', false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset playground:
|
||||||
|
*/
|
||||||
|
|
||||||
|
await userEvent.click(button);
|
||||||
|
await userEvent.keyboard('{backspace}');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Default = {
|
||||||
|
render: function Render() {
|
||||||
|
const [matchedHotkey, setMatchedHotkey] = useState<keyof HandlerMap | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlers = {
|
||||||
|
back: () => {
|
||||||
|
setMatchedHotkey(null);
|
||||||
|
},
|
||||||
|
new: () => {
|
||||||
|
setMatchedHotkey('new');
|
||||||
|
},
|
||||||
|
forceNew: () => {
|
||||||
|
setMatchedHotkey('forceNew');
|
||||||
|
},
|
||||||
|
search: () => {
|
||||||
|
setMatchedHotkey('search');
|
||||||
|
},
|
||||||
|
open: () => {
|
||||||
|
setMatchedHotkey('open');
|
||||||
|
},
|
||||||
|
goToHome: () => {
|
||||||
|
setMatchedHotkey('goToHome');
|
||||||
|
},
|
||||||
|
goToNotifications: () => {
|
||||||
|
setMatchedHotkey('goToNotifications');
|
||||||
|
},
|
||||||
|
goToFavourites: () => {
|
||||||
|
setMatchedHotkey('goToFavourites');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Hotkeys handlers={handlers}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 8,
|
||||||
|
padding: '1em',
|
||||||
|
border: '1px dashed #ccc',
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#222',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: 22,
|
||||||
|
marginBottom: '0.3em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Hotkey playground
|
||||||
|
</h1>
|
||||||
|
<p>
|
||||||
|
Last pressed hotkey: <output>{matchedHotkey ?? 'None'}</output>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Click within the dashed border and press the "<kbd>n</kbd>
|
||||||
|
" or "<kbd>/</kbd>" key. Press "
|
||||||
|
<kbd>Backspace</kbd>" to clear the displayed hotkey.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Try typing a sequence, like "<kbd>g</kbd>" shortly
|
||||||
|
followed by "<kbd>h</kbd>", "<kbd>n</kbd>", or
|
||||||
|
"<kbd>f</kbd>"
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Note that this playground doesn't support all hotkeys we use in
|
||||||
|
the app.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
When a <button>Button</button> is focused, "
|
||||||
|
<kbd>Enter</kbd>
|
||||||
|
" should not trigger "open", but "<kbd>o</kbd>
|
||||||
|
" should.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
When an input element is focused, hotkeys should not interfere with
|
||||||
|
regular typing:
|
||||||
|
</p>
|
||||||
|
<input type='text' />
|
||||||
|
</div>
|
||||||
|
</Hotkeys>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
play: hotkeyTest,
|
||||||
|
};
|
286
app/javascript/mastodon/components/hotkeys/index.tsx
Normal file
286
app/javascript/mastodon/components/hotkeys/index.tsx
Normal file
|
@ -0,0 +1,286 @@
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
import { normalizeKey, isKeyboardEvent } from './utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In case of multiple hotkeys matching the pressed key(s),
|
||||||
|
* the hotkey with a higher priority is selected. All others
|
||||||
|
* are ignored.
|
||||||
|
*/
|
||||||
|
const hotkeyPriority = {
|
||||||
|
singleKey: 0,
|
||||||
|
combo: 1,
|
||||||
|
sequence: 2,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This type of function receives a keyboard event and an array of
|
||||||
|
* previously pressed keys (within the last second), and returns
|
||||||
|
* `isMatch` (whether the pressed keys match a hotkey) and `priority`
|
||||||
|
* (a weighting used to resolve conflicts when two hotkeys match the
|
||||||
|
* pressed keys)
|
||||||
|
*/
|
||||||
|
type KeyMatcher = (
|
||||||
|
event: KeyboardEvent,
|
||||||
|
bufferedKeys?: string[],
|
||||||
|
) => {
|
||||||
|
/**
|
||||||
|
* Whether the event.key matches the hotkey
|
||||||
|
*/
|
||||||
|
isMatch: boolean;
|
||||||
|
/**
|
||||||
|
* If there are multiple matching hotkeys, the
|
||||||
|
* first one with the highest priority will be handled
|
||||||
|
*/
|
||||||
|
priority: (typeof hotkeyPriority)[keyof typeof hotkeyPriority];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches a single key
|
||||||
|
*/
|
||||||
|
function just(keyName: string): KeyMatcher {
|
||||||
|
return (event) => ({
|
||||||
|
isMatch:
|
||||||
|
normalizeKey(event.key) === keyName &&
|
||||||
|
!event.altKey &&
|
||||||
|
!event.ctrlKey &&
|
||||||
|
!event.metaKey,
|
||||||
|
priority: hotkeyPriority.singleKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches any single key out of those provided
|
||||||
|
*/
|
||||||
|
function any(...keys: string[]): KeyMatcher {
|
||||||
|
return (event) => ({
|
||||||
|
isMatch: keys.some((keyName) => just(keyName)(event).isMatch),
|
||||||
|
priority: hotkeyPriority.singleKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches a single key combined with the option/alt modifier
|
||||||
|
*/
|
||||||
|
function optionPlus(key: string): KeyMatcher {
|
||||||
|
return (event) => ({
|
||||||
|
// Matching against event.code here as alt combos are often
|
||||||
|
// mapped to other characters
|
||||||
|
isMatch: event.altKey && event.code === `Key${key.toUpperCase()}`,
|
||||||
|
priority: hotkeyPriority.combo,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches when all provided keys are pressed in sequence.
|
||||||
|
*/
|
||||||
|
function sequence(...sequence: string[]): KeyMatcher {
|
||||||
|
return (event, bufferedKeys) => {
|
||||||
|
const lastKeyInSequence = sequence.at(-1);
|
||||||
|
const startOfSequence = sequence.slice(0, -1);
|
||||||
|
const relevantBufferedKeys = bufferedKeys?.slice(-startOfSequence.length);
|
||||||
|
|
||||||
|
const bufferMatchesStartOfSequence =
|
||||||
|
!!relevantBufferedKeys &&
|
||||||
|
startOfSequence.join('') === relevantBufferedKeys.join('');
|
||||||
|
|
||||||
|
return {
|
||||||
|
isMatch:
|
||||||
|
bufferMatchesStartOfSequence &&
|
||||||
|
normalizeKey(event.key) === lastKeyInSequence,
|
||||||
|
priority: hotkeyPriority.sequence,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is a map of all global hotkeys we support.
|
||||||
|
* To trigger a hotkey, a handler with a matching name must be
|
||||||
|
* provided to the `useHotkeys` hook or `Hotkeys` component.
|
||||||
|
*/
|
||||||
|
const hotkeyMatcherMap = {
|
||||||
|
help: just('?'),
|
||||||
|
search: any('s', '/'),
|
||||||
|
back: just('backspace'),
|
||||||
|
new: just('n'),
|
||||||
|
forceNew: optionPlus('n'),
|
||||||
|
focusColumn: any('1', '2', '3', '4', '5', '6', '7', '8', '9'),
|
||||||
|
reply: just('r'),
|
||||||
|
favourite: just('f'),
|
||||||
|
boost: just('b'),
|
||||||
|
mention: just('m'),
|
||||||
|
open: any('enter', 'o'),
|
||||||
|
openProfile: just('p'),
|
||||||
|
moveDown: any('down', 'j'),
|
||||||
|
moveUp: any('up', 'k'),
|
||||||
|
toggleHidden: just('x'),
|
||||||
|
toggleSensitive: just('h'),
|
||||||
|
toggleComposeSpoilers: optionPlus('x'),
|
||||||
|
openMedia: just('e'),
|
||||||
|
onTranslate: just('t'),
|
||||||
|
goToHome: sequence('g', 'h'),
|
||||||
|
goToNotifications: sequence('g', 'n'),
|
||||||
|
goToLocal: sequence('g', 'l'),
|
||||||
|
goToFederated: sequence('g', 't'),
|
||||||
|
goToDirect: sequence('g', 'd'),
|
||||||
|
goToStart: sequence('g', 's'),
|
||||||
|
goToFavourites: sequence('g', 'f'),
|
||||||
|
goToPinned: sequence('g', 'p'),
|
||||||
|
goToProfile: sequence('g', 'u'),
|
||||||
|
goToBlocked: sequence('g', 'b'),
|
||||||
|
goToMuted: sequence('g', 'm'),
|
||||||
|
goToRequests: sequence('g', 'r'),
|
||||||
|
cheat: sequence(
|
||||||
|
'up',
|
||||||
|
'up',
|
||||||
|
'down',
|
||||||
|
'down',
|
||||||
|
'left',
|
||||||
|
'right',
|
||||||
|
'left',
|
||||||
|
'right',
|
||||||
|
'b',
|
||||||
|
'a',
|
||||||
|
'enter',
|
||||||
|
),
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type HotkeyName = keyof typeof hotkeyMatcherMap;
|
||||||
|
|
||||||
|
export type HandlerMap = Partial<
|
||||||
|
Record<HotkeyName, (event: KeyboardEvent) => void>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function useHotkeys<T extends HTMLElement>(handlers: HandlerMap) {
|
||||||
|
const ref = useRef<T>(null);
|
||||||
|
const bufferedKeys = useRef<string[]>([]);
|
||||||
|
const sequenceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store the latest handlers object in a ref so we don't need to
|
||||||
|
* add it as a dependency to the main event listener effect
|
||||||
|
*/
|
||||||
|
const handlersRef = useRef(handlers);
|
||||||
|
useEffect(() => {
|
||||||
|
handlersRef.current = handlers;
|
||||||
|
}, [handlers]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = ref.current ?? document;
|
||||||
|
|
||||||
|
function listener(event: Event) {
|
||||||
|
// Ignore key presses from input, textarea, or select elements
|
||||||
|
const tagName = (event.target as HTMLElement).tagName.toLowerCase();
|
||||||
|
const shouldHandleEvent =
|
||||||
|
isKeyboardEvent(event) &&
|
||||||
|
!event.defaultPrevented &&
|
||||||
|
!['input', 'textarea', 'select'].includes(tagName) &&
|
||||||
|
!(
|
||||||
|
['a', 'button'].includes(tagName) &&
|
||||||
|
normalizeKey(event.key) === 'enter'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (shouldHandleEvent) {
|
||||||
|
const matchCandidates: {
|
||||||
|
handler: (event: KeyboardEvent) => void;
|
||||||
|
priority: number;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
(Object.keys(hotkeyMatcherMap) as HotkeyName[]).forEach(
|
||||||
|
(handlerName) => {
|
||||||
|
const handler = handlersRef.current[handlerName];
|
||||||
|
|
||||||
|
if (handler) {
|
||||||
|
const hotkeyMatcher = hotkeyMatcherMap[handlerName];
|
||||||
|
|
||||||
|
const { isMatch, priority } = hotkeyMatcher(
|
||||||
|
event,
|
||||||
|
bufferedKeys.current,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isMatch) {
|
||||||
|
matchCandidates.push({ handler, priority });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sort all matches by priority
|
||||||
|
matchCandidates.sort((a, b) => b.priority - a.priority);
|
||||||
|
|
||||||
|
const bestMatchingHandler = matchCandidates.at(0)?.handler;
|
||||||
|
if (bestMatchingHandler) {
|
||||||
|
bestMatchingHandler(event);
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add last keypress to buffer
|
||||||
|
bufferedKeys.current.push(normalizeKey(event.key));
|
||||||
|
|
||||||
|
// Reset the timeout
|
||||||
|
if (sequenceTimer.current) {
|
||||||
|
clearTimeout(sequenceTimer.current);
|
||||||
|
}
|
||||||
|
sequenceTimer.current = setTimeout(() => {
|
||||||
|
bufferedKeys.current = [];
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
element.addEventListener('keydown', listener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
element.removeEventListener('keydown', listener);
|
||||||
|
if (sequenceTimer.current) {
|
||||||
|
clearTimeout(sequenceTimer.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Hotkeys component allows us to globally register keyboard combinations
|
||||||
|
* under a name and assign actions to them, either globally or scoped to a portion
|
||||||
|
* of the app.
|
||||||
|
*
|
||||||
|
* ### How to use
|
||||||
|
*
|
||||||
|
* To add a new hotkey, add its key combination to the `hotkeyMatcherMap` object
|
||||||
|
* and give it a name.
|
||||||
|
*
|
||||||
|
* Use the `<Hotkeys>` component or the `useHotkeys` hook in the part of of the app
|
||||||
|
* where you want to handle the action, and pass in a handlers object.
|
||||||
|
*
|
||||||
|
* ```tsx
|
||||||
|
* <Hotkeys handlers={{open: openStatus}} />
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Now this function will be called when the 'open' hotkey is pressed by the user.
|
||||||
|
*/
|
||||||
|
export const Hotkeys: React.FC<{
|
||||||
|
/**
|
||||||
|
* An object containing functions to be run when a hotkey is pressed.
|
||||||
|
* The key must be the name of a registered hotkey, e.g. "help" or "search"
|
||||||
|
*/
|
||||||
|
handlers: HandlerMap;
|
||||||
|
/**
|
||||||
|
* When enabled, hotkeys will be matched against the document root
|
||||||
|
* rather than only inside of this component's DOM node.
|
||||||
|
*/
|
||||||
|
global?: boolean;
|
||||||
|
/**
|
||||||
|
* Allow the rendered `div` to be focused
|
||||||
|
*/
|
||||||
|
focusable?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}> = ({ handlers, global, focusable = true, children }) => {
|
||||||
|
const ref = useHotkeys<HTMLDivElement>(handlers);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={global ? undefined : ref} tabIndex={focusable ? -1 : undefined}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
29
app/javascript/mastodon/components/hotkeys/utils.ts
Normal file
29
app/javascript/mastodon/components/hotkeys/utils.ts
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
export function isKeyboardEvent(event: Event): event is KeyboardEvent {
|
||||||
|
return 'key' in event;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeKey(key: string): string {
|
||||||
|
const lowerKey = key.toLowerCase();
|
||||||
|
|
||||||
|
switch (lowerKey) {
|
||||||
|
case ' ':
|
||||||
|
case 'spacebar': // for older browsers
|
||||||
|
return 'space';
|
||||||
|
|
||||||
|
case 'arrowup':
|
||||||
|
return 'up';
|
||||||
|
case 'arrowdown':
|
||||||
|
return 'down';
|
||||||
|
case 'arrowleft':
|
||||||
|
return 'left';
|
||||||
|
case 'arrowright':
|
||||||
|
return 'right';
|
||||||
|
|
||||||
|
case 'esc':
|
||||||
|
case 'escape':
|
||||||
|
return 'escape';
|
||||||
|
|
||||||
|
default:
|
||||||
|
return lowerKey;
|
||||||
|
}
|
||||||
|
}
|
|
@ -102,7 +102,7 @@ export const HoverCardAccount = forwardRef<
|
||||||
<>
|
<>
|
||||||
<div className='hover-card__text-row'>
|
<div className='hover-card__text-row'>
|
||||||
<AccountBio
|
<AccountBio
|
||||||
note={account.note_emojified}
|
accountId={account.id}
|
||||||
className='hover-card__bio'
|
className='hover-card__bio'
|
||||||
/>
|
/>
|
||||||
<AccountFields fields={account.fields} limit={2} />
|
<AccountFields fields={account.fields} limit={2} />
|
||||||
|
|
|
@ -14,7 +14,6 @@ interface Props {
|
||||||
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||||
onMouseDown?: React.MouseEventHandler<HTMLButtonElement>;
|
onMouseDown?: React.MouseEventHandler<HTMLButtonElement>;
|
||||||
onKeyDown?: React.KeyboardEventHandler<HTMLButtonElement>;
|
onKeyDown?: React.KeyboardEventHandler<HTMLButtonElement>;
|
||||||
onKeyPress?: React.KeyboardEventHandler<HTMLButtonElement>;
|
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
expanded?: boolean;
|
expanded?: boolean;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
|
@ -45,7 +44,6 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(
|
||||||
activeStyle,
|
activeStyle,
|
||||||
onClick,
|
onClick,
|
||||||
onKeyDown,
|
onKeyDown,
|
||||||
onKeyPress,
|
|
||||||
onMouseDown,
|
onMouseDown,
|
||||||
active = false,
|
active = false,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
@ -85,16 +83,6 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(
|
||||||
[disabled, onClick],
|
[disabled, onClick],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleKeyPress: React.KeyboardEventHandler<HTMLButtonElement> =
|
|
||||||
useCallback(
|
|
||||||
(e) => {
|
|
||||||
if (!disabled) {
|
|
||||||
onKeyPress?.(e);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[disabled, onKeyPress],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleMouseDown: React.MouseEventHandler<HTMLButtonElement> =
|
const handleMouseDown: React.MouseEventHandler<HTMLButtonElement> =
|
||||||
useCallback(
|
useCallback(
|
||||||
(e) => {
|
(e) => {
|
||||||
|
@ -161,7 +149,6 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onKeyPress={handleKeyPress} // eslint-disable-line @typescript-eslint/no-deprecated
|
|
||||||
style={buttonStyle}
|
style={buttonStyle}
|
||||||
tabIndex={tabIndex}
|
tabIndex={tabIndex}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|
63
app/javascript/mastodon/components/learn_more_link.tsx
Normal file
63
app/javascript/mastodon/components/learn_more_link.tsx
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
import { useState, useRef, useCallback, useId } from 'react';
|
||||||
|
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import Overlay from 'react-overlays/Overlay';
|
||||||
|
|
||||||
|
export const LearnMoreLink: React.FC<{ children: React.ReactNode }> = ({
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const accessibilityId = useId();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const triggerRef = useRef(null);
|
||||||
|
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
setOpen(!open);
|
||||||
|
}, [open, setOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className='link-button'
|
||||||
|
ref={triggerRef}
|
||||||
|
onClick={handleClick}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={accessibilityId}
|
||||||
|
>
|
||||||
|
<FormattedMessage
|
||||||
|
id='learn_more_link.learn_more'
|
||||||
|
defaultMessage='Learn more'
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Overlay
|
||||||
|
show={open}
|
||||||
|
rootClose
|
||||||
|
onHide={handleClick}
|
||||||
|
offset={[5, 5]}
|
||||||
|
placement='bottom-end'
|
||||||
|
target={triggerRef}
|
||||||
|
>
|
||||||
|
{({ props }) => (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
role='region'
|
||||||
|
id={accessibilityId}
|
||||||
|
className='account__domain-pill__popout learn-more__popout dropdown-animation'
|
||||||
|
>
|
||||||
|
<div className='learn-more__popout__content'>{children}</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button className='link-button' onClick={handleClick}>
|
||||||
|
<FormattedMessage
|
||||||
|
id='learn_more_link.got_it'
|
||||||
|
defaultMessage='Got it'
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Overlay>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -8,10 +8,9 @@ import { Link } from 'react-router-dom';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
|
|
||||||
import { HotKeys } from 'react-hotkeys';
|
|
||||||
|
|
||||||
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
||||||
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
|
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
|
||||||
|
import { Hotkeys } from 'mastodon/components/hotkeys';
|
||||||
import { ContentWarning } from 'mastodon/components/content_warning';
|
import { ContentWarning } from 'mastodon/components/content_warning';
|
||||||
import { FilterWarning } from 'mastodon/components/filter_warning';
|
import { FilterWarning } from 'mastodon/components/filter_warning';
|
||||||
import { Icon } from 'mastodon/components/icon';
|
import { Icon } from 'mastodon/components/icon';
|
||||||
|
@ -35,7 +34,6 @@ import StatusActionBar from './status_action_bar';
|
||||||
import StatusContent from './status_content';
|
import StatusContent from './status_content';
|
||||||
import { StatusThreadLabel } from './status_thread_label';
|
import { StatusThreadLabel } from './status_thread_label';
|
||||||
import { VisibilityIcon } from './visibility_icon';
|
import { VisibilityIcon } from './visibility_icon';
|
||||||
|
|
||||||
const domParser = new DOMParser();
|
const domParser = new DOMParser();
|
||||||
|
|
||||||
export const textForScreenReader = (intl, status, rebloggedByText = false) => {
|
export const textForScreenReader = (intl, status, rebloggedByText = false) => {
|
||||||
|
@ -325,11 +323,11 @@ class Status extends ImmutablePureComponent {
|
||||||
};
|
};
|
||||||
|
|
||||||
handleHotkeyMoveUp = e => {
|
handleHotkeyMoveUp = e => {
|
||||||
this.props.onMoveUp(this.props.status.get('id'), e.target.getAttribute('data-featured'));
|
this.props.onMoveUp?.(this.props.status.get('id'), this.node.getAttribute('data-featured'));
|
||||||
};
|
};
|
||||||
|
|
||||||
handleHotkeyMoveDown = e => {
|
handleHotkeyMoveDown = e => {
|
||||||
this.props.onMoveDown(this.props.status.get('id'), e.target.getAttribute('data-featured'));
|
this.props.onMoveDown?.(this.props.status.get('id'), this.node.getAttribute('data-featured'));
|
||||||
};
|
};
|
||||||
|
|
||||||
handleHotkeyToggleHidden = () => {
|
handleHotkeyToggleHidden = () => {
|
||||||
|
@ -437,13 +435,13 @@ class Status extends ImmutablePureComponent {
|
||||||
|
|
||||||
if (hidden) {
|
if (hidden) {
|
||||||
return (
|
return (
|
||||||
<HotKeys handlers={handlers} tabIndex={unfocusable ? null : -1}>
|
<Hotkeys handlers={handlers} focusable={!unfocusable}>
|
||||||
<div ref={this.handleRef} className={classNames('status__wrapper', { focusable: !this.props.muted })} tabIndex={unfocusable ? null : 0}>
|
<div ref={this.handleRef} className={classNames('status__wrapper', { focusable: !this.props.muted })} tabIndex={unfocusable ? null : 0}>
|
||||||
<span>{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}</span>
|
<span>{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}</span>
|
||||||
{status.get('spoiler_text').length > 0 && (<span>{status.get('spoiler_text')}</span>)}
|
{status.get('spoiler_text').length > 0 && (<span>{status.get('spoiler_text')}</span>)}
|
||||||
{expanded && <span>{status.get('content')}</span>}
|
{expanded && <span>{status.get('content')}</span>}
|
||||||
</div>
|
</div>
|
||||||
</HotKeys>
|
</Hotkeys>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -543,7 +541,7 @@ class Status extends ImmutablePureComponent {
|
||||||
const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status);
|
const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HotKeys handlers={handlers} tabIndex={unfocusable ? null : -1}>
|
<Hotkeys handlers={handlers} focusable={!unfocusable}>
|
||||||
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted || unfocusable ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef} data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined}>
|
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted || unfocusable ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef} data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined}>
|
||||||
{!skipPrepend && prepend}
|
{!skipPrepend && prepend}
|
||||||
|
|
||||||
|
@ -604,7 +602,7 @@ class Status extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</HotKeys>
|
</Hotkeys>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -67,21 +67,28 @@ const messages = defineMessages({
|
||||||
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
|
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
|
||||||
filter: { id: 'status.filter', defaultMessage: 'Filter this post' },
|
filter: { id: 'status.filter', defaultMessage: 'Filter this post' },
|
||||||
openOriginalPage: { id: 'account.open_original_page', defaultMessage: 'Open original page' },
|
openOriginalPage: { id: 'account.open_original_page', defaultMessage: 'Open original page' },
|
||||||
|
revokeQuote: { id: 'status.revoke_quote', defaultMessage: 'Remove my post from @{name}’s post' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapStateToProps = (state, { status }) => ({
|
const mapStateToProps = (state, { status }) => {
|
||||||
relationship: state.getIn(['relationships', status.getIn(['account', 'id'])]),
|
const quotedStatusId = status.getIn(['quote', 'quoted_status']);
|
||||||
});
|
return ({
|
||||||
|
relationship: state.getIn(['relationships', status.getIn(['account', 'id'])]),
|
||||||
|
quotedAccountId: quotedStatusId ? state.getIn(['statuses', quotedStatusId, 'account']) : null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
class StatusActionBar extends ImmutablePureComponent {
|
class StatusActionBar extends ImmutablePureComponent {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
identity: identityContextPropShape,
|
identity: identityContextPropShape,
|
||||||
status: ImmutablePropTypes.map.isRequired,
|
status: ImmutablePropTypes.map.isRequired,
|
||||||
relationship: ImmutablePropTypes.record,
|
relationship: ImmutablePropTypes.record,
|
||||||
|
quotedAccountId: ImmutablePropTypes.string,
|
||||||
onReply: PropTypes.func,
|
onReply: PropTypes.func,
|
||||||
onFavourite: PropTypes.func,
|
onFavourite: PropTypes.func,
|
||||||
onReblog: PropTypes.func,
|
onReblog: PropTypes.func,
|
||||||
onDelete: PropTypes.func,
|
onDelete: PropTypes.func,
|
||||||
|
onRevokeQuote: PropTypes.func,
|
||||||
onDirect: PropTypes.func,
|
onDirect: PropTypes.func,
|
||||||
onMention: PropTypes.func,
|
onMention: PropTypes.func,
|
||||||
onMute: PropTypes.func,
|
onMute: PropTypes.func,
|
||||||
|
@ -110,6 +117,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||||
updateOnProps = [
|
updateOnProps = [
|
||||||
'status',
|
'status',
|
||||||
'relationship',
|
'relationship',
|
||||||
|
'quotedAccountId',
|
||||||
'withDismiss',
|
'withDismiss',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -190,6 +198,10 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
handleRevokeQuoteClick = () => {
|
||||||
|
this.props.onRevokeQuote(this.props.status);
|
||||||
|
}
|
||||||
|
|
||||||
handleBlockClick = () => {
|
handleBlockClick = () => {
|
||||||
const { status, relationship, onBlock, onUnblock } = this.props;
|
const { status, relationship, onBlock, onUnblock } = this.props;
|
||||||
const account = status.get('account');
|
const account = status.get('account');
|
||||||
|
@ -241,7 +253,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||||
};
|
};
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { status, relationship, intl, withDismiss, withCounters, scrollKey } = this.props;
|
const { status, relationship, quotedAccountId, intl, withDismiss, withCounters, scrollKey } = this.props;
|
||||||
const { signedIn, permissions } = this.props.identity;
|
const { signedIn, permissions } = this.props.identity;
|
||||||
|
|
||||||
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
|
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
|
||||||
|
@ -291,6 +303,10 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||||
menu.push({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.handleDirectClick });
|
menu.push({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.handleDirectClick });
|
||||||
menu.push(null);
|
menu.push(null);
|
||||||
|
|
||||||
|
if (quotedAccountId === me) {
|
||||||
|
menu.push({ text: intl.formatMessage(messages.revokeQuote, { name: account.get('username') }), action: this.handleRevokeQuoteClick, dangerous: true });
|
||||||
|
}
|
||||||
|
|
||||||
if (relationship && relationship.get('muting')) {
|
if (relationship && relationship.get('muting')) {
|
||||||
menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.handleMuteClick });
|
menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.handleMuteClick });
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -14,6 +14,8 @@ import { Icon } from 'mastodon/components/icon';
|
||||||
import { Poll } from 'mastodon/components/poll';
|
import { Poll } from 'mastodon/components/poll';
|
||||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
||||||
import { autoPlayGif, languages as preloadedLanguages } from 'mastodon/initial_state';
|
import { autoPlayGif, languages as preloadedLanguages } from 'mastodon/initial_state';
|
||||||
|
import { EmojiHTML } from '../features/emoji/emoji_html';
|
||||||
|
import { isModernEmojiEnabled } from '../utils/environment';
|
||||||
|
|
||||||
const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
|
const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
|
||||||
|
|
||||||
|
@ -23,6 +25,9 @@ const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
export function getStatusContent(status) {
|
export function getStatusContent(status) {
|
||||||
|
if (isModernEmojiEnabled()) {
|
||||||
|
return status.getIn(['translation', 'content']) || status.get('content');
|
||||||
|
}
|
||||||
return status.getIn(['translation', 'contentHtml']) || status.get('contentHtml');
|
return status.getIn(['translation', 'contentHtml']) || status.get('contentHtml');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -43,13 +48,13 @@ class TranslateButton extends PureComponent {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='translate-button'>
|
<div className='translate-button'>
|
||||||
<div className='translate-button__meta'>
|
|
||||||
<FormattedMessage id='status.translated_from_with' defaultMessage='Translated from {lang} using {provider}' values={{ lang: languageName, provider }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button className='link-button' onClick={onClick}>
|
<button className='link-button' onClick={onClick}>
|
||||||
<FormattedMessage id='status.show_original' defaultMessage='Show original' />
|
<FormattedMessage id='status.show_original' defaultMessage='Show original' />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div className='translate-button__meta'>
|
||||||
|
<FormattedMessage id='status.translated_from_with' defaultMessage='Translated from {lang} using {provider}' values={{ lang: languageName, provider }} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -133,6 +138,16 @@ class StatusContent extends PureComponent {
|
||||||
|
|
||||||
onCollapsedToggle(collapsed);
|
onCollapsedToggle(collapsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove quote fallback link from the DOM so it doesn't
|
||||||
|
// mess with paragraph margins
|
||||||
|
if (!!status.get('quote')) {
|
||||||
|
const inlineQuote = node.querySelector('.quote-inline');
|
||||||
|
|
||||||
|
if (inlineQuote) {
|
||||||
|
inlineQuote.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleMouseEnter = ({ currentTarget }) => {
|
handleMouseEnter = ({ currentTarget }) => {
|
||||||
|
@ -228,7 +243,7 @@ class StatusContent extends PureComponent {
|
||||||
const targetLanguages = this.props.languages?.get(status.get('language') || 'und');
|
const targetLanguages = this.props.languages?.get(status.get('language') || 'und');
|
||||||
const renderTranslate = this.props.onTranslate && this.props.identity.signedIn && ['public', 'unlisted'].includes(status.get('visibility')) && status.get('search_index').trim().length > 0 && targetLanguages?.includes(contentLocale);
|
const renderTranslate = this.props.onTranslate && this.props.identity.signedIn && ['public', 'unlisted'].includes(status.get('visibility')) && status.get('search_index').trim().length > 0 && targetLanguages?.includes(contentLocale);
|
||||||
|
|
||||||
const content = { __html: statusContent ?? getStatusContent(status) };
|
const content = statusContent ?? getStatusContent(status);
|
||||||
const language = status.getIn(['translation', 'language']) || status.get('language');
|
const language = status.getIn(['translation', 'language']) || status.get('language');
|
||||||
const classNames = classnames('status__content', {
|
const classNames = classnames('status__content', {
|
||||||
'status__content--with-action': this.props.onClick && this.props.history,
|
'status__content--with-action': this.props.onClick && this.props.history,
|
||||||
|
@ -253,7 +268,12 @@ class StatusContent extends PureComponent {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={classNames} ref={this.setRef} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
<div className={classNames} ref={this.setRef} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
||||||
<div className='status__content__text status__content__text--visible translate' lang={language} dangerouslySetInnerHTML={content} />
|
<EmojiHTML
|
||||||
|
className='status__content__text status__content__text--visible translate'
|
||||||
|
lang={language}
|
||||||
|
htmlString={content}
|
||||||
|
extraEmojis={status.get('emojis')}
|
||||||
|
/>
|
||||||
|
|
||||||
{poll}
|
{poll}
|
||||||
{translateButton}
|
{translateButton}
|
||||||
|
@ -265,7 +285,12 @@ class StatusContent extends PureComponent {
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<div className={classNames} ref={this.setRef} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
<div className={classNames} ref={this.setRef} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
||||||
<div className='status__content__text status__content__text--visible translate' lang={language} dangerouslySetInnerHTML={content} />
|
<EmojiHTML
|
||||||
|
className='status__content__text status__content__text--visible translate'
|
||||||
|
lang={language}
|
||||||
|
htmlString={content}
|
||||||
|
extraEmojis={status.get('emojis')}
|
||||||
|
/>
|
||||||
|
|
||||||
{poll}
|
{poll}
|
||||||
{translateButton}
|
{translateButton}
|
||||||
|
|
|
@ -40,6 +40,14 @@ export default class StatusList extends ImmutablePureComponent {
|
||||||
trackScroll: true,
|
trackScroll: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.columnHeaderHeight = this.node?.node
|
||||||
|
? parseFloat(
|
||||||
|
getComputedStyle(this.node.node).getPropertyValue('--column-header-height')
|
||||||
|
) || 0
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
getFeaturedStatusCount = () => {
|
getFeaturedStatusCount = () => {
|
||||||
return this.props.featuredStatusIds ? this.props.featuredStatusIds.size : 0;
|
return this.props.featuredStatusIds ? this.props.featuredStatusIds.size : 0;
|
||||||
};
|
};
|
||||||
|
@ -53,34 +61,68 @@ export default class StatusList extends ImmutablePureComponent {
|
||||||
};
|
};
|
||||||
|
|
||||||
handleMoveUp = (id, featured) => {
|
handleMoveUp = (id, featured) => {
|
||||||
const elementIndex = this.getCurrentStatusIndex(id, featured) - 1;
|
const index = this.getCurrentStatusIndex(id, featured);
|
||||||
this._selectChild(elementIndex, true);
|
this._selectChild(id, index, -1);
|
||||||
};
|
};
|
||||||
|
|
||||||
handleMoveDown = (id, featured) => {
|
handleMoveDown = (id, featured) => {
|
||||||
const elementIndex = this.getCurrentStatusIndex(id, featured) + 1;
|
const index = this.getCurrentStatusIndex(id, featured);
|
||||||
this._selectChild(elementIndex, false);
|
this._selectChild(id, index, 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
_selectChild = (id, index, direction) => {
|
||||||
|
const listContainer = this.node?.node;
|
||||||
|
let listItem = listContainer?.querySelector(
|
||||||
|
// :nth-child uses 1-based indexing
|
||||||
|
`.item-list > :nth-child(${index + 1 + direction})`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!listItem) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If selected container element is empty, we skip it
|
||||||
|
if (listItem.matches(':empty')) {
|
||||||
|
this._selectChild(id, index + direction, direction);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the list item is a post
|
||||||
|
let targetElement = listItem.querySelector('.focusable');
|
||||||
|
|
||||||
|
// Otherwise, check if the item contains follow suggestions or
|
||||||
|
// is a 'load more' button.
|
||||||
|
if (
|
||||||
|
!targetElement && (
|
||||||
|
listItem.querySelector('.inline-follow-suggestions') ||
|
||||||
|
listItem.matches('.load-more')
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
targetElement = listItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetElement) {
|
||||||
|
const elementRect = targetElement.getBoundingClientRect();
|
||||||
|
|
||||||
|
const isFullyVisible =
|
||||||
|
elementRect.top >= this.columnHeaderHeight &&
|
||||||
|
elementRect.bottom <= window.innerHeight;
|
||||||
|
|
||||||
|
if (!isFullyVisible) {
|
||||||
|
targetElement.scrollIntoView({
|
||||||
|
block: direction === 1 ? 'start' : 'center',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
targetElement.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handleLoadOlder = debounce(() => {
|
handleLoadOlder = debounce(() => {
|
||||||
const { statusIds, lastId, onLoadMore } = this.props;
|
const { statusIds, lastId, onLoadMore } = this.props;
|
||||||
onLoadMore(lastId || (statusIds.size > 0 ? statusIds.last() : undefined));
|
onLoadMore(lastId || (statusIds.size > 0 ? statusIds.last() : undefined));
|
||||||
}, 300, { leading: true });
|
}, 300, { leading: true });
|
||||||
|
|
||||||
_selectChild (index, align_top) {
|
|
||||||
const container = this.node.node;
|
|
||||||
const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
|
|
||||||
|
|
||||||
if (element) {
|
|
||||||
if (align_top && container.scrollTop > element.offsetTop) {
|
|
||||||
element.scrollIntoView(true);
|
|
||||||
} else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
|
|
||||||
element.scrollIntoView(false);
|
|
||||||
}
|
|
||||||
element.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setRef = c => {
|
setRef = c => {
|
||||||
this.node = c;
|
this.node = c;
|
||||||
};
|
};
|
||||||
|
|
|
@ -3,19 +3,15 @@ import { useEffect, useMemo } from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
import type { Map as ImmutableMap } from 'immutable';
|
import type { Map as ImmutableMap } from 'immutable';
|
||||||
|
|
||||||
import ArticleIcon from '@/material-icons/400-24px/article.svg?react';
|
import { LearnMoreLink } from 'mastodon/components/learn_more_link';
|
||||||
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
|
||||||
import { Icon } from 'mastodon/components/icon';
|
|
||||||
import StatusContainer from 'mastodon/containers/status_container';
|
import StatusContainer from 'mastodon/containers/status_container';
|
||||||
import type { Status } from 'mastodon/models/status';
|
import type { Status } from 'mastodon/models/status';
|
||||||
import type { RootState } from 'mastodon/store';
|
import type { RootState } from 'mastodon/store';
|
||||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||||
|
|
||||||
import QuoteIcon from '../../images/quote.svg?react';
|
|
||||||
import { fetchStatus } from '../actions/statuses';
|
import { fetchStatus } from '../actions/statuses';
|
||||||
import { makeGetStatus } from '../selectors';
|
import { makeGetStatus } from '../selectors';
|
||||||
|
|
||||||
|
@ -31,7 +27,6 @@ const QuoteWrapper: React.FC<{
|
||||||
'status__quote--error': isError,
|
'status__quote--error': isError,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Icon id='quote' icon={QuoteIcon} className='status__quote-icon' />
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -45,27 +40,20 @@ const NestedQuoteLink: React.FC<{
|
||||||
accountId ? state.accounts.get(accountId) : undefined,
|
accountId ? state.accounts.get(accountId) : undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
const quoteAuthorName = account?.display_name_html;
|
const quoteAuthorName = account?.acct;
|
||||||
|
|
||||||
if (!quoteAuthorName) {
|
if (!quoteAuthorName) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const quoteAuthorElement = (
|
|
||||||
<span dangerouslySetInnerHTML={{ __html: quoteAuthorName }} />
|
|
||||||
);
|
|
||||||
const quoteUrl = `/@${account.get('acct')}/${status.get('id') as string}`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link to={quoteUrl} className='status__quote-author-button'>
|
<div className='status__quote-author-button'>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='status.quote_post_author'
|
id='status.quote_post_author'
|
||||||
defaultMessage='Post by {name}'
|
defaultMessage='Quoted a post by @{name}'
|
||||||
values={{ name: quoteAuthorElement }}
|
values={{ name: quoteAuthorName }}
|
||||||
/>
|
/>
|
||||||
<Icon id='chevron_right' icon={ChevronRightIcon} />
|
</div>
|
||||||
<Icon id='article' icon={ArticleIcon} />
|
|
||||||
</Link>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -112,39 +100,42 @@ export const QuotedStatus: React.FC<{
|
||||||
defaultMessage='Hidden due to one of your filters'
|
defaultMessage='Hidden due to one of your filters'
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (quoteState === 'deleted') {
|
|
||||||
quoteError = (
|
|
||||||
<FormattedMessage
|
|
||||||
id='status.quote_error.removed'
|
|
||||||
defaultMessage='This post was removed by its author.'
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (quoteState === 'unauthorized') {
|
|
||||||
quoteError = (
|
|
||||||
<FormattedMessage
|
|
||||||
id='status.quote_error.unauthorized'
|
|
||||||
defaultMessage='This post cannot be displayed as you are not authorized to view it.'
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (quoteState === 'pending') {
|
} else if (quoteState === 'pending') {
|
||||||
quoteError = (
|
quoteError = (
|
||||||
<FormattedMessage
|
<>
|
||||||
id='status.quote_error.pending_approval'
|
<FormattedMessage
|
||||||
defaultMessage='This post is pending approval from the original author.'
|
id='status.quote_error.pending_approval'
|
||||||
/>
|
defaultMessage='Post pending'
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LearnMoreLink>
|
||||||
|
<h6>
|
||||||
|
<FormattedMessage
|
||||||
|
id='status.quote_error.pending_approval_popout.title'
|
||||||
|
defaultMessage='Pending quote? Remain calm'
|
||||||
|
/>
|
||||||
|
</h6>
|
||||||
|
<p>
|
||||||
|
<FormattedMessage
|
||||||
|
id='status.quote_error.pending_approval_popout.body'
|
||||||
|
defaultMessage='Quotes shared across the Fediverse may take time to display, as different servers have different protocols.'
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
</LearnMoreLink>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
} else if (quoteState === 'rejected' || quoteState === 'revoked') {
|
} else if (
|
||||||
|
!status ||
|
||||||
|
!quotedStatusId ||
|
||||||
|
quoteState === 'deleted' ||
|
||||||
|
quoteState === 'rejected' ||
|
||||||
|
quoteState === 'revoked' ||
|
||||||
|
quoteState === 'unauthorized'
|
||||||
|
) {
|
||||||
quoteError = (
|
quoteError = (
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='status.quote_error.rejected'
|
id='status.quote_error.not_available'
|
||||||
defaultMessage='This post cannot be displayed as the original author does not allow it to be quoted.'
|
defaultMessage='Post unavailable'
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (!status || !quotedStatusId) {
|
|
||||||
quoteError = (
|
|
||||||
<FormattedMessage
|
|
||||||
id='status.quote_error.not_found'
|
|
||||||
defaultMessage='This post cannot be displayed.'
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -168,7 +159,7 @@ export const QuotedStatus: React.FC<{
|
||||||
isQuotedPost
|
isQuotedPost
|
||||||
id={quotedStatusId}
|
id={quotedStatusId}
|
||||||
contextType={contextType}
|
contextType={contextType}
|
||||||
avatarSize={40}
|
avatarSize={32}
|
||||||
>
|
>
|
||||||
{canRenderChildQuote && (
|
{canRenderChildQuote && (
|
||||||
<QuotedStatus
|
<QuotedStatus
|
||||||
|
|
|
@ -111,6 +111,10 @@ const mapDispatchToProps = (dispatch, { contextType }) => ({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
onRevokeQuote (status) {
|
||||||
|
dispatch(openModal({ modalType: 'CONFIRM_REVOKE_QUOTE', modalProps: { statusId: status.get('id'), quotedStatusId: status.getIn(['quote', 'quoted_status']) }}));
|
||||||
|
},
|
||||||
|
|
||||||
onEdit (status) {
|
onEdit (status) {
|
||||||
dispatch((_, getState) => {
|
dispatch((_, getState) => {
|
||||||
let state = getState();
|
let state = getState();
|
||||||
|
|
|
@ -6,6 +6,7 @@ import classNames from 'classnames';
|
||||||
import { Helmet } from 'react-helmet';
|
import { Helmet } from 'react-helmet';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { AccountBio } from '@/mastodon/components/account_bio';
|
||||||
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||||
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
|
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
|
||||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||||
|
@ -773,7 +774,6 @@ export const AccountHeader: React.FC<{
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = { __html: account.note_emojified };
|
|
||||||
const displayNameHtml = { __html: account.display_name_html };
|
const displayNameHtml = { __html: account.display_name_html };
|
||||||
const fields = account.fields;
|
const fields = account.fields;
|
||||||
const isLocal = !account.acct.includes('@');
|
const isLocal = !account.acct.includes('@');
|
||||||
|
@ -897,12 +897,10 @@ export const AccountHeader: React.FC<{
|
||||||
<AccountNote accountId={accountId} />
|
<AccountNote accountId={accountId} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{account.note.length > 0 && account.note !== '<p></p>' && (
|
<AccountBio
|
||||||
<div
|
accountId={accountId}
|
||||||
className='account__header__content translate'
|
className='account__header__content'
|
||||||
dangerouslySetInnerHTML={content}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className='account__header__fields'>
|
<div className='account__header__fields'>
|
||||||
<dl>
|
<dl>
|
||||||
|
|
|
@ -261,7 +261,9 @@ export const AltTextModal = forwardRef<ModalRef, Props & Partial<RestoreProps>>(
|
||||||
);
|
);
|
||||||
const lang = useAppSelector(
|
const lang = useAppSelector(
|
||||||
(state) =>
|
(state) =>
|
||||||
(state.compose as ImmutableMap<string, unknown>).get('lang') as string,
|
(state.compose as ImmutableMap<string, unknown>).get(
|
||||||
|
'language',
|
||||||
|
) as string,
|
||||||
);
|
);
|
||||||
const focusX =
|
const focusX =
|
||||||
(media?.getIn(['meta', 'focus', 'x'], 0) as number | undefined) ?? 0;
|
(media?.getIn(['meta', 'focus', 'x'], 0) as number | undefined) ?? 0;
|
||||||
|
|
|
@ -92,10 +92,29 @@ class ComposeForm extends ImmutablePureComponent {
|
||||||
this.props.onChange(e.target.value);
|
this.props.onChange(e.target.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
handleKeyDown = (e) => {
|
blurOnEscape = (e) => {
|
||||||
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
|
if (['esc', 'escape'].includes(e.key.toLowerCase())) {
|
||||||
this.handleSubmit();
|
e.target.blur();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleKeyDownPost = (e) => {
|
||||||
|
if (e.key.toLowerCase() === 'enter' && (e.ctrlKey || e.metaKey)) {
|
||||||
|
this.handleSubmit();
|
||||||
|
}
|
||||||
|
this.blurOnEscape(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleKeyDownSpoiler = (e) => {
|
||||||
|
if (e.key.toLowerCase() === 'enter') {
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
this.handleSubmit();
|
||||||
|
} else {
|
||||||
|
e.preventDefault();
|
||||||
|
this.textareaRef.current?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.blurOnEscape(e);
|
||||||
};
|
};
|
||||||
|
|
||||||
getFulltextForCharacterCounting = () => {
|
getFulltextForCharacterCounting = () => {
|
||||||
|
@ -248,7 +267,7 @@ class ComposeForm extends ImmutablePureComponent {
|
||||||
value={this.props.spoilerText}
|
value={this.props.spoilerText}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
onChange={this.handleChangeSpoilerText}
|
onChange={this.handleChangeSpoilerText}
|
||||||
onKeyDown={this.handleKeyDown}
|
onKeyDown={this.handleKeyDownSpoiler}
|
||||||
ref={this.setSpoilerText}
|
ref={this.setSpoilerText}
|
||||||
suggestions={this.props.suggestions}
|
suggestions={this.props.suggestions}
|
||||||
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
||||||
|
@ -273,7 +292,7 @@ class ComposeForm extends ImmutablePureComponent {
|
||||||
onChange={this.handleChange}
|
onChange={this.handleChange}
|
||||||
suggestions={this.props.suggestions}
|
suggestions={this.props.suggestions}
|
||||||
onFocus={this.handleFocus}
|
onFocus={this.handleFocus}
|
||||||
onKeyDown={this.handleKeyDown}
|
onKeyDown={this.handleKeyDownPost}
|
||||||
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
||||||
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
|
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
|
||||||
onSuggestionSelected={this.onSuggestionSelected}
|
onSuggestionSelected={this.onSuggestionSelected}
|
||||||
|
|
|
@ -47,10 +47,6 @@ const labelForRecentSearch = (search: RecentSearch) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const unfocus = () => {
|
|
||||||
document.querySelector('.ui')?.parentElement?.focus();
|
|
||||||
};
|
|
||||||
|
|
||||||
const ClearButton: React.FC<{
|
const ClearButton: React.FC<{
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
hasValue: boolean;
|
hasValue: boolean;
|
||||||
|
@ -107,6 +103,11 @@ export const Search: React.FC<{
|
||||||
}, [initialValue]);
|
}, [initialValue]);
|
||||||
const searchOptions: SearchOption[] = [];
|
const searchOptions: SearchOption[] = [];
|
||||||
|
|
||||||
|
const unfocus = useCallback(() => {
|
||||||
|
document.querySelector('.ui')?.parentElement?.focus();
|
||||||
|
setExpanded(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (searchEnabled) {
|
if (searchEnabled) {
|
||||||
searchOptions.push(
|
searchOptions.push(
|
||||||
{
|
{
|
||||||
|
@ -282,7 +283,7 @@ export const Search: React.FC<{
|
||||||
history.push({ pathname: '/search', search: queryParams.toString() });
|
history.push({ pathname: '/search', search: queryParams.toString() });
|
||||||
unfocus();
|
unfocus();
|
||||||
},
|
},
|
||||||
[dispatch, history],
|
[dispatch, history, unfocus],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback(
|
||||||
|
@ -402,7 +403,7 @@ export const Search: React.FC<{
|
||||||
|
|
||||||
setQuickActions(newQuickActions);
|
setQuickActions(newQuickActions);
|
||||||
},
|
},
|
||||||
[dispatch, history, signedIn, setValue, setQuickActions, submit],
|
[signedIn, dispatch, unfocus, history, submit],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleClear = useCallback(() => {
|
const handleClear = useCallback(() => {
|
||||||
|
@ -410,7 +411,7 @@ export const Search: React.FC<{
|
||||||
setQuickActions([]);
|
setQuickActions([]);
|
||||||
setSelectedOption(-1);
|
setSelectedOption(-1);
|
||||||
unfocus();
|
unfocus();
|
||||||
}, [setValue, setQuickActions, setSelectedOption]);
|
}, [unfocus]);
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent) => {
|
(e: React.KeyboardEvent) => {
|
||||||
|
@ -461,7 +462,7 @@ export const Search: React.FC<{
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[navigableOptions, value, selectedOption, setSelectedOption, submit],
|
[unfocus, navigableOptions, selectedOption, submit, value],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFocus = useCallback(() => {
|
const handleFocus = useCallback(() => {
|
||||||
|
@ -481,12 +482,38 @@ export const Search: React.FC<{
|
||||||
}, [setExpanded, setSelectedOption, singleColumn]);
|
}, [setExpanded, setSelectedOption, singleColumn]);
|
||||||
|
|
||||||
const handleBlur = useCallback(() => {
|
const handleBlur = useCallback(() => {
|
||||||
setExpanded(false);
|
|
||||||
setSelectedOption(-1);
|
setSelectedOption(-1);
|
||||||
}, [setExpanded, setSelectedOption]);
|
}, [setSelectedOption]);
|
||||||
|
|
||||||
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// If the search popover is expanded, close it when tabbing or
|
||||||
|
// clicking outside of it or the search form, while allowing
|
||||||
|
// tabbing or clicking inside of the popover
|
||||||
|
if (expanded) {
|
||||||
|
function closeOnLeave(event: FocusEvent | MouseEvent) {
|
||||||
|
const form = formRef.current;
|
||||||
|
const isClickInsideForm =
|
||||||
|
form &&
|
||||||
|
(form === event.target || form.contains(event.target as Node));
|
||||||
|
if (!isClickInsideForm) {
|
||||||
|
setExpanded(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('focusin', closeOnLeave);
|
||||||
|
document.addEventListener('click', closeOnLeave);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('focusin', closeOnLeave);
|
||||||
|
document.removeEventListener('click', closeOnLeave);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return () => null;
|
||||||
|
}, [expanded]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className={classNames('search', { active: expanded })}>
|
<form ref={formRef} className={classNames('search', { active: expanded })}>
|
||||||
<input
|
<input
|
||||||
ref={searchInputRef}
|
ref={searchInputRef}
|
||||||
className='search__input'
|
className='search__input'
|
||||||
|
@ -506,7 +533,7 @@ export const Search: React.FC<{
|
||||||
|
|
||||||
<ClearButton hasValue={hasValue} onClick={handleClear} />
|
<ClearButton hasValue={hasValue} onClick={handleClear} />
|
||||||
|
|
||||||
<div className='search__popout'>
|
<div className='search__popout' tabIndex={-1}>
|
||||||
{!hasValue && (
|
{!hasValue && (
|
||||||
<>
|
<>
|
||||||
<h4>
|
<h4>
|
||||||
|
|
|
@ -10,15 +10,13 @@ import { createSelector } from '@reduxjs/toolkit';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
|
||||||
|
|
||||||
import { HotKeys } from 'react-hotkeys';
|
|
||||||
|
|
||||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||||
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
|
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
|
||||||
import { replyCompose } from 'mastodon/actions/compose';
|
import { replyCompose } from 'mastodon/actions/compose';
|
||||||
import { markConversationRead, deleteConversation } from 'mastodon/actions/conversations';
|
import { markConversationRead, deleteConversation } from 'mastodon/actions/conversations';
|
||||||
import { openModal } from 'mastodon/actions/modal';
|
import { openModal } from 'mastodon/actions/modal';
|
||||||
import { muteStatus, unmuteStatus, toggleStatusSpoilers } from 'mastodon/actions/statuses';
|
import { muteStatus, unmuteStatus, toggleStatusSpoilers } from 'mastodon/actions/statuses';
|
||||||
|
import { Hotkeys } from 'mastodon/components/hotkeys';
|
||||||
import AttachmentList from 'mastodon/components/attachment_list';
|
import AttachmentList from 'mastodon/components/attachment_list';
|
||||||
import AvatarComposite from 'mastodon/components/avatar_composite';
|
import AvatarComposite from 'mastodon/components/avatar_composite';
|
||||||
import { IconButton } from 'mastodon/components/icon_button';
|
import { IconButton } from 'mastodon/components/icon_button';
|
||||||
|
@ -169,7 +167,7 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HotKeys handlers={handlers}>
|
<Hotkeys handlers={handlers}>
|
||||||
<div className={classNames('conversation focusable muted', { unread })} tabIndex={0}>
|
<div className={classNames('conversation focusable muted', { unread })} tabIndex={0}>
|
||||||
<div className='conversation__avatar' onClick={handleClick} role='presentation'>
|
<div className='conversation__avatar' onClick={handleClick} role='presentation'>
|
||||||
<AvatarComposite accounts={accounts} size={48} />
|
<AvatarComposite accounts={accounts} size={48} />
|
||||||
|
@ -219,7 +217,7 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</HotKeys>
|
</Hotkeys>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
120
app/javascript/mastodon/features/emoji/constants.ts
Normal file
120
app/javascript/mastodon/features/emoji/constants.ts
Normal file
|
@ -0,0 +1,120 @@
|
||||||
|
// Utility codes
|
||||||
|
export const VARIATION_SELECTOR_CODE = 0xfe0f;
|
||||||
|
export const KEYCAP_CODE = 0x20e3;
|
||||||
|
|
||||||
|
// Gender codes
|
||||||
|
export const GENDER_FEMALE_CODE = 0x2640;
|
||||||
|
export const GENDER_MALE_CODE = 0x2642;
|
||||||
|
|
||||||
|
// Skin tone codes
|
||||||
|
export const SKIN_TONE_CODES = [
|
||||||
|
0x1f3fb, // Light skin tone
|
||||||
|
0x1f3fc, // Medium-light skin tone
|
||||||
|
0x1f3fd, // Medium skin tone
|
||||||
|
0x1f3fe, // Medium-dark skin tone
|
||||||
|
0x1f3ff, // Dark skin tone
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// Emoji rendering modes. A mode is what we are using to render emojis, a style is what the user has selected.
|
||||||
|
export const EMOJI_MODE_NATIVE = 'native';
|
||||||
|
export const EMOJI_MODE_NATIVE_WITH_FLAGS = 'native-flags';
|
||||||
|
export const EMOJI_MODE_TWEMOJI = 'twemoji';
|
||||||
|
|
||||||
|
export const EMOJI_TYPE_UNICODE = 'unicode';
|
||||||
|
export const EMOJI_TYPE_CUSTOM = 'custom';
|
||||||
|
|
||||||
|
export const EMOJI_STATE_MISSING = 'missing';
|
||||||
|
|
||||||
|
export const EMOJIS_WITH_DARK_BORDER = [
|
||||||
|
'🎱', // 1F3B1
|
||||||
|
'🐜', // 1F41C
|
||||||
|
'⚫', // 26AB
|
||||||
|
'🖤', // 1F5A4
|
||||||
|
'⬛', // 2B1B
|
||||||
|
'◼️', // 25FC-FE0F
|
||||||
|
'◾', // 25FE
|
||||||
|
'◼️', // 25FC-FE0F
|
||||||
|
'✒️', // 2712-FE0F
|
||||||
|
'▪️', // 25AA-FE0F
|
||||||
|
'💣', // 1F4A3
|
||||||
|
'🎳', // 1F3B3
|
||||||
|
'📷', // 1F4F7
|
||||||
|
'📸', // 1F4F8
|
||||||
|
'♣️', // 2663-FE0F
|
||||||
|
'🕶️', // 1F576-FE0F
|
||||||
|
'✴️', // 2734-FE0F
|
||||||
|
'🔌', // 1F50C
|
||||||
|
'💂♀️', // 1F482-200D-2640-FE0F
|
||||||
|
'📽️', // 1F4FD-FE0F
|
||||||
|
'🍳', // 1F373
|
||||||
|
'🦍', // 1F98D
|
||||||
|
'💂', // 1F482
|
||||||
|
'🔪', // 1F52A
|
||||||
|
'🕳️', // 1F573-FE0F
|
||||||
|
'🕹️', // 1F579-FE0F
|
||||||
|
'🕋', // 1F54B
|
||||||
|
'🖊️', // 1F58A-FE0F
|
||||||
|
'🖋️', // 1F58B-FE0F
|
||||||
|
'💂♂️', // 1F482-200D-2642-FE0F
|
||||||
|
'🎤', // 1F3A4
|
||||||
|
'🎓', // 1F393
|
||||||
|
'🎥', // 1F3A5
|
||||||
|
'🎼', // 1F3BC
|
||||||
|
'♠️', // 2660-FE0F
|
||||||
|
'🎩', // 1F3A9
|
||||||
|
'🦃', // 1F983
|
||||||
|
'📼', // 1F4FC
|
||||||
|
'📹', // 1F4F9
|
||||||
|
'🎮', // 1F3AE
|
||||||
|
'🐃', // 1F403
|
||||||
|
'🏴', // 1F3F4
|
||||||
|
'🐞', // 1F41E
|
||||||
|
'🕺', // 1F57A
|
||||||
|
'📱', // 1F4F1
|
||||||
|
'📲', // 1F4F2
|
||||||
|
'🚲', // 1F6B2
|
||||||
|
'🪮', // 1FAA6
|
||||||
|
'🐦⬛', // 1F426-200D-2B1B
|
||||||
|
];
|
||||||
|
|
||||||
|
export const EMOJIS_WITH_LIGHT_BORDER = [
|
||||||
|
'👽', // 1F47D
|
||||||
|
'⚾', // 26BE
|
||||||
|
'🐔', // 1F414
|
||||||
|
'☁️', // 2601-FE0F
|
||||||
|
'💨', // 1F4A8
|
||||||
|
'🕊️', // 1F54A-FE0F
|
||||||
|
'👀', // 1F440
|
||||||
|
'🍥', // 1F365
|
||||||
|
'👻', // 1F47B
|
||||||
|
'🐐', // 1F410
|
||||||
|
'❕', // 2755
|
||||||
|
'❔', // 2754
|
||||||
|
'⛸️', // 26F8-FE0F
|
||||||
|
'🌩️', // 1F329-FE0F
|
||||||
|
'🔊', // 1F50A
|
||||||
|
'🔇', // 1F507
|
||||||
|
'📃', // 1F4C3
|
||||||
|
'🌧️', // 1F327-FE0F
|
||||||
|
'🐏', // 1F40F
|
||||||
|
'🍚', // 1F35A
|
||||||
|
'🍙', // 1F359
|
||||||
|
'🐓', // 1F413
|
||||||
|
'🐑', // 1F411
|
||||||
|
'💀', // 1F480
|
||||||
|
'☠️', // 2620-FE0F
|
||||||
|
'🌨️', // 1F328-FE0F
|
||||||
|
'🔉', // 1F509
|
||||||
|
'🔈', // 1F508
|
||||||
|
'💬', // 1F4AC
|
||||||
|
'💭', // 1F4AD
|
||||||
|
'🏐', // 1F3D0
|
||||||
|
'🏳️', // 1F3F3-FE0F
|
||||||
|
'⚪', // 26AA
|
||||||
|
'⬜', // 2B1C
|
||||||
|
'◽', // 25FD
|
||||||
|
'◻️', // 25FB-FE0F
|
||||||
|
'▫️', // 25AB-FE0F
|
||||||
|
'🪽', // 1FAE8
|
||||||
|
'🪿', // 1FABF
|
||||||
|
];
|
139
app/javascript/mastodon/features/emoji/database.test.ts
Normal file
139
app/javascript/mastodon/features/emoji/database.test.ts
Normal file
|
@ -0,0 +1,139 @@
|
||||||
|
import { IDBFactory } from 'fake-indexeddb';
|
||||||
|
|
||||||
|
import { unicodeEmojiFactory } from '@/testing/factories';
|
||||||
|
|
||||||
|
import {
|
||||||
|
putEmojiData,
|
||||||
|
loadEmojiByHexcode,
|
||||||
|
searchEmojisByHexcodes,
|
||||||
|
searchEmojisByTag,
|
||||||
|
testClear,
|
||||||
|
testGet,
|
||||||
|
} from './database';
|
||||||
|
|
||||||
|
describe('emoji database', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
testClear();
|
||||||
|
indexedDB = new IDBFactory();
|
||||||
|
});
|
||||||
|
describe('putEmojiData', () => {
|
||||||
|
test('adds to loaded locales', async () => {
|
||||||
|
const { loadedLocales } = await testGet();
|
||||||
|
expect(loadedLocales).toHaveLength(0);
|
||||||
|
await putEmojiData([], 'en');
|
||||||
|
expect(loadedLocales).toContain('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loads emoji into indexedDB', async () => {
|
||||||
|
await putEmojiData([unicodeEmojiFactory()], 'en');
|
||||||
|
const { db } = await testGet();
|
||||||
|
await expect(db.get('en', 'test')).resolves.toEqual(
|
||||||
|
unicodeEmojiFactory(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadEmojiByHexcode', () => {
|
||||||
|
test('throws if the locale is not loaded', async () => {
|
||||||
|
await expect(loadEmojiByHexcode('en', 'test')).rejects.toThrowError(
|
||||||
|
'Locale en',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('retrieves the emoji', async () => {
|
||||||
|
await putEmojiData([unicodeEmojiFactory()], 'en');
|
||||||
|
await expect(loadEmojiByHexcode('test', 'en')).resolves.toEqual(
|
||||||
|
unicodeEmojiFactory(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns undefined if not found', async () => {
|
||||||
|
await putEmojiData([], 'en');
|
||||||
|
await expect(loadEmojiByHexcode('test', 'en')).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('searchEmojisByHexcodes', () => {
|
||||||
|
const data = [
|
||||||
|
unicodeEmojiFactory({ hexcode: 'not a number' }),
|
||||||
|
unicodeEmojiFactory({ hexcode: '1' }),
|
||||||
|
unicodeEmojiFactory({ hexcode: '2' }),
|
||||||
|
unicodeEmojiFactory({ hexcode: '3' }),
|
||||||
|
unicodeEmojiFactory({ hexcode: 'another not a number' }),
|
||||||
|
];
|
||||||
|
beforeEach(async () => {
|
||||||
|
await putEmojiData(data, 'en');
|
||||||
|
});
|
||||||
|
test('finds emoji in consecutive range', async () => {
|
||||||
|
const actual = await searchEmojisByHexcodes(['1', '2', '3'], 'en');
|
||||||
|
expect(actual).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finds emoji in split range', async () => {
|
||||||
|
const actual = await searchEmojisByHexcodes(['1', '3'], 'en');
|
||||||
|
expect(actual).toHaveLength(2);
|
||||||
|
expect(actual).toContainEqual(data.at(1));
|
||||||
|
expect(actual).toContainEqual(data.at(3));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finds emoji with non-numeric range', async () => {
|
||||||
|
const actual = await searchEmojisByHexcodes(
|
||||||
|
['3', 'not a number', '1'],
|
||||||
|
'en',
|
||||||
|
);
|
||||||
|
expect(actual).toHaveLength(3);
|
||||||
|
expect(actual).toContainEqual(data.at(0));
|
||||||
|
expect(actual).toContainEqual(data.at(1));
|
||||||
|
expect(actual).toContainEqual(data.at(3));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('not found emoji are not returned', async () => {
|
||||||
|
const actual = await searchEmojisByHexcodes(['not found'], 'en');
|
||||||
|
expect(actual).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only found emojis are returned', async () => {
|
||||||
|
const actual = await searchEmojisByHexcodes(
|
||||||
|
['another not a number', 'not found'],
|
||||||
|
'en',
|
||||||
|
);
|
||||||
|
expect(actual).toHaveLength(1);
|
||||||
|
expect(actual).toContainEqual(data.at(4));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('searchEmojisByTag', () => {
|
||||||
|
const data = [
|
||||||
|
unicodeEmojiFactory({ hexcode: 'test1', tags: ['test 1'] }),
|
||||||
|
unicodeEmojiFactory({
|
||||||
|
hexcode: 'test2',
|
||||||
|
tags: ['test 2', 'something else'],
|
||||||
|
}),
|
||||||
|
unicodeEmojiFactory({ hexcode: 'test3', tags: ['completely different'] }),
|
||||||
|
];
|
||||||
|
beforeEach(async () => {
|
||||||
|
await putEmojiData(data, 'en');
|
||||||
|
});
|
||||||
|
test('finds emojis with tag', async () => {
|
||||||
|
const actual = await searchEmojisByTag('test 1', 'en');
|
||||||
|
expect(actual).toHaveLength(1);
|
||||||
|
expect(actual).toContainEqual(data.at(0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finds emojis starting with tag', async () => {
|
||||||
|
const actual = await searchEmojisByTag('test', 'en');
|
||||||
|
expect(actual).toHaveLength(2);
|
||||||
|
expect(actual).not.toContainEqual(data.at(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not find emojis ending with tag', async () => {
|
||||||
|
const actual = await searchEmojisByTag('else', 'en');
|
||||||
|
expect(actual).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finds nothing with invalid tag', async () => {
|
||||||
|
const actual = await searchEmojisByTag('not found', 'en');
|
||||||
|
expect(actual).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
221
app/javascript/mastodon/features/emoji/database.ts
Normal file
221
app/javascript/mastodon/features/emoji/database.ts
Normal file
|
@ -0,0 +1,221 @@
|
||||||
|
import { SUPPORTED_LOCALES } from 'emojibase';
|
||||||
|
import type { Locale } from 'emojibase';
|
||||||
|
import type { DBSchema, IDBPDatabase } from 'idb';
|
||||||
|
import { openDB } from 'idb';
|
||||||
|
|
||||||
|
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale';
|
||||||
|
import type {
|
||||||
|
CustomEmojiData,
|
||||||
|
UnicodeEmojiData,
|
||||||
|
LocaleOrCustom,
|
||||||
|
} from './types';
|
||||||
|
import { emojiLogger } from './utils';
|
||||||
|
|
||||||
|
interface EmojiDB extends LocaleTables, DBSchema {
|
||||||
|
custom: {
|
||||||
|
key: string;
|
||||||
|
value: CustomEmojiData;
|
||||||
|
indexes: {
|
||||||
|
category: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
etags: {
|
||||||
|
key: LocaleOrCustom;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LocaleTable {
|
||||||
|
key: string;
|
||||||
|
value: UnicodeEmojiData;
|
||||||
|
indexes: {
|
||||||
|
group: number;
|
||||||
|
label: string;
|
||||||
|
order: number;
|
||||||
|
tags: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
type LocaleTables = Record<Locale, LocaleTable>;
|
||||||
|
|
||||||
|
type Database = IDBPDatabase<EmojiDB>;
|
||||||
|
|
||||||
|
const SCHEMA_VERSION = 1;
|
||||||
|
|
||||||
|
const loadedLocales = new Set<Locale>();
|
||||||
|
|
||||||
|
const log = emojiLogger('database');
|
||||||
|
|
||||||
|
// Loads the database in a way that ensures it's only loaded once.
|
||||||
|
const loadDB = (() => {
|
||||||
|
let dbPromise: Promise<Database> | null = null;
|
||||||
|
|
||||||
|
// Actually load the DB.
|
||||||
|
async function initDB() {
|
||||||
|
const db = await openDB<EmojiDB>('mastodon-emoji', SCHEMA_VERSION, {
|
||||||
|
upgrade(database) {
|
||||||
|
const customTable = database.createObjectStore('custom', {
|
||||||
|
keyPath: 'shortcode',
|
||||||
|
autoIncrement: false,
|
||||||
|
});
|
||||||
|
customTable.createIndex('category', 'category');
|
||||||
|
|
||||||
|
database.createObjectStore('etags');
|
||||||
|
|
||||||
|
for (const locale of SUPPORTED_LOCALES) {
|
||||||
|
const localeTable = database.createObjectStore(locale, {
|
||||||
|
keyPath: 'hexcode',
|
||||||
|
autoIncrement: false,
|
||||||
|
});
|
||||||
|
localeTable.createIndex('group', 'group');
|
||||||
|
localeTable.createIndex('label', 'label');
|
||||||
|
localeTable.createIndex('order', 'order');
|
||||||
|
localeTable.createIndex('tags', 'tags', { multiEntry: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await syncLocales(db);
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loads the database, or returns the existing promise if it hasn't resolved yet.
|
||||||
|
const loadPromise = async (): Promise<Database> => {
|
||||||
|
if (dbPromise) {
|
||||||
|
return dbPromise;
|
||||||
|
}
|
||||||
|
dbPromise = initDB();
|
||||||
|
return dbPromise;
|
||||||
|
};
|
||||||
|
// Special way to reset the database, used for unit testing.
|
||||||
|
loadPromise.reset = () => {
|
||||||
|
dbPromise = null;
|
||||||
|
};
|
||||||
|
return loadPromise;
|
||||||
|
})();
|
||||||
|
|
||||||
|
export async function putEmojiData(emojis: UnicodeEmojiData[], locale: Locale) {
|
||||||
|
loadedLocales.add(locale);
|
||||||
|
const db = await loadDB();
|
||||||
|
const trx = db.transaction(locale, 'readwrite');
|
||||||
|
await Promise.all(emojis.map((emoji) => trx.store.put(emoji)));
|
||||||
|
await trx.done;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function putCustomEmojiData(emojis: CustomEmojiData[]) {
|
||||||
|
const db = await loadDB();
|
||||||
|
const trx = db.transaction('custom', 'readwrite');
|
||||||
|
await Promise.all(emojis.map((emoji) => trx.store.put(emoji)));
|
||||||
|
await trx.done;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function putLatestEtag(etag: string, localeString: string) {
|
||||||
|
const locale = toSupportedLocaleOrCustom(localeString);
|
||||||
|
const db = await loadDB();
|
||||||
|
await db.put('etags', etag, locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadEmojiByHexcode(
|
||||||
|
hexcode: string,
|
||||||
|
localeString: string,
|
||||||
|
) {
|
||||||
|
const db = await loadDB();
|
||||||
|
const locale = toLoadedLocale(localeString);
|
||||||
|
return db.get(locale, hexcode);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchEmojisByHexcodes(
|
||||||
|
hexcodes: string[],
|
||||||
|
localeString: string,
|
||||||
|
) {
|
||||||
|
const db = await loadDB();
|
||||||
|
const locale = toLoadedLocale(localeString);
|
||||||
|
const sortedCodes = hexcodes.toSorted();
|
||||||
|
const results = await db.getAll(
|
||||||
|
locale,
|
||||||
|
IDBKeyRange.bound(sortedCodes.at(0), sortedCodes.at(-1)),
|
||||||
|
);
|
||||||
|
return results.filter((emoji) => hexcodes.includes(emoji.hexcode));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchEmojisByTag(tag: string, localeString: string) {
|
||||||
|
const db = await loadDB();
|
||||||
|
const locale = toLoadedLocale(localeString);
|
||||||
|
const range = IDBKeyRange.bound(
|
||||||
|
tag.toLowerCase(),
|
||||||
|
`${tag.toLowerCase()}\uffff`,
|
||||||
|
);
|
||||||
|
return db.getAllFromIndex(locale, 'tags', range);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadCustomEmojiByShortcode(shortcode: string) {
|
||||||
|
const db = await loadDB();
|
||||||
|
return db.get('custom', shortcode);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchCustomEmojisByShortcodes(shortcodes: string[]) {
|
||||||
|
const db = await loadDB();
|
||||||
|
const sortedCodes = shortcodes.toSorted();
|
||||||
|
const results = await db.getAll(
|
||||||
|
'custom',
|
||||||
|
IDBKeyRange.bound(sortedCodes.at(0), sortedCodes.at(-1)),
|
||||||
|
);
|
||||||
|
return results.filter((emoji) => shortcodes.includes(emoji.shortcode));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadLatestEtag(localeString: string) {
|
||||||
|
const locale = toSupportedLocaleOrCustom(localeString);
|
||||||
|
const db = await loadDB();
|
||||||
|
const rowCount = await db.count(locale);
|
||||||
|
if (!rowCount) {
|
||||||
|
return null; // No data for this locale, return null even if there is an etag.
|
||||||
|
}
|
||||||
|
const etag = await db.get('etags', locale);
|
||||||
|
return etag ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private functions
|
||||||
|
|
||||||
|
async function syncLocales(db: Database) {
|
||||||
|
const locales = await Promise.all(
|
||||||
|
SUPPORTED_LOCALES.map(
|
||||||
|
async (locale) =>
|
||||||
|
[locale, await hasLocale(locale, db)] satisfies [Locale, boolean],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const [locale, loaded] of locales) {
|
||||||
|
if (loaded) {
|
||||||
|
loadedLocales.add(locale);
|
||||||
|
} else {
|
||||||
|
loadedLocales.delete(locale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log('Loaded %d locales: %o', loadedLocales.size, loadedLocales);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLoadedLocale(localeString: string) {
|
||||||
|
const locale = toSupportedLocale(localeString);
|
||||||
|
if (localeString !== locale) {
|
||||||
|
log(`Locale ${locale} is different from provided ${localeString}`);
|
||||||
|
}
|
||||||
|
if (!loadedLocales.has(locale)) {
|
||||||
|
throw new Error(`Locale ${locale} is not loaded in emoji database`);
|
||||||
|
}
|
||||||
|
return locale;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hasLocale(locale: Locale, db: Database): Promise<boolean> {
|
||||||
|
if (loadedLocales.has(locale)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const rowCount = await db.count(locale);
|
||||||
|
return !!rowCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Testing helpers
|
||||||
|
export async function testGet() {
|
||||||
|
const db = await loadDB();
|
||||||
|
return { db, loadedLocales };
|
||||||
|
}
|
||||||
|
export function testClear() {
|
||||||
|
loadedLocales.clear();
|
||||||
|
loadDB.reset();
|
||||||
|
}
|
48
app/javascript/mastodon/features/emoji/emoji_html.tsx
Normal file
48
app/javascript/mastodon/features/emoji/emoji_html.tsx
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
import type { ComponentPropsWithoutRef, ElementType } from 'react';
|
||||||
|
|
||||||
|
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
|
||||||
|
|
||||||
|
import { useEmojify } from './hooks';
|
||||||
|
import type { CustomEmojiMapArg } from './types';
|
||||||
|
|
||||||
|
type EmojiHTMLProps<Element extends ElementType = 'div'> = Omit<
|
||||||
|
ComponentPropsWithoutRef<Element>,
|
||||||
|
'dangerouslySetInnerHTML'
|
||||||
|
> & {
|
||||||
|
htmlString: string;
|
||||||
|
extraEmojis?: CustomEmojiMapArg;
|
||||||
|
as?: Element;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ModernEmojiHTML = <Element extends ElementType>({
|
||||||
|
extraEmojis,
|
||||||
|
htmlString,
|
||||||
|
as: asElement, // Rename for syntax highlighting
|
||||||
|
...props
|
||||||
|
}: EmojiHTMLProps<Element>) => {
|
||||||
|
const Wrapper = asElement ?? 'div';
|
||||||
|
const emojifiedHtml = useEmojify(htmlString, extraEmojis);
|
||||||
|
|
||||||
|
if (emojifiedHtml === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Wrapper {...props} dangerouslySetInnerHTML={{ __html: emojifiedHtml }} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EmojiHTML = <Element extends ElementType>(
|
||||||
|
props: EmojiHTMLProps<Element>,
|
||||||
|
) => {
|
||||||
|
if (isModernEmojiEnabled()) {
|
||||||
|
return <ModernEmojiHTML {...props} />;
|
||||||
|
}
|
||||||
|
const Wrapper = props.as ?? 'div';
|
||||||
|
return (
|
||||||
|
<Wrapper
|
||||||
|
{...props}
|
||||||
|
dangerouslySetInnerHTML={{ __html: props.htmlString }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
77
app/javascript/mastodon/features/emoji/hooks.ts
Normal file
77
app/javascript/mastodon/features/emoji/hooks.ts
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { isList } from 'immutable';
|
||||||
|
|
||||||
|
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
|
||||||
|
import { useAppSelector } from '@/mastodon/store';
|
||||||
|
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
|
||||||
|
|
||||||
|
import { toSupportedLocale } from './locale';
|
||||||
|
import { determineEmojiMode } from './mode';
|
||||||
|
import type {
|
||||||
|
CustomEmojiMapArg,
|
||||||
|
EmojiAppState,
|
||||||
|
ExtraCustomEmojiMap,
|
||||||
|
} from './types';
|
||||||
|
import { stringHasAnyEmoji } from './utils';
|
||||||
|
|
||||||
|
export function useEmojify(text: string, extraEmojis?: CustomEmojiMapArg) {
|
||||||
|
const [emojifiedText, setEmojifiedText] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const appState = useEmojiAppState();
|
||||||
|
const extra: ExtraCustomEmojiMap = useMemo(() => {
|
||||||
|
if (!extraEmojis) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if (isList(extraEmojis)) {
|
||||||
|
return (
|
||||||
|
extraEmojis.toJS() as ApiCustomEmojiJSON[]
|
||||||
|
).reduce<ExtraCustomEmojiMap>(
|
||||||
|
(acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return extraEmojis;
|
||||||
|
}, [extraEmojis]);
|
||||||
|
|
||||||
|
const emojify = useCallback(
|
||||||
|
async (input: string) => {
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.innerHTML = input;
|
||||||
|
const { emojifyElement } = await import('./render');
|
||||||
|
const result = await emojifyElement(wrapper, appState, extra);
|
||||||
|
if (result) {
|
||||||
|
setEmojifiedText(result.innerHTML);
|
||||||
|
} else {
|
||||||
|
setEmojifiedText(input);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[appState, extra],
|
||||||
|
);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (isModernEmojiEnabled() && !!text.trim() && stringHasAnyEmoji(text)) {
|
||||||
|
void emojify(text);
|
||||||
|
} else {
|
||||||
|
// If no emoji or we don't want to render, fall back.
|
||||||
|
setEmojifiedText(text);
|
||||||
|
}
|
||||||
|
}, [emojify, text]);
|
||||||
|
|
||||||
|
return emojifiedText;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEmojiAppState(): EmojiAppState {
|
||||||
|
const locale = useAppSelector((state) =>
|
||||||
|
toSupportedLocale(state.meta.get('locale') as string),
|
||||||
|
);
|
||||||
|
const mode = useAppSelector((state) =>
|
||||||
|
determineEmojiMode(state.meta.get('emoji_style') as string),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentLocale: locale,
|
||||||
|
locales: [locale],
|
||||||
|
mode,
|
||||||
|
darkTheme: document.body.classList.contains('theme-default'),
|
||||||
|
};
|
||||||
|
}
|
73
app/javascript/mastodon/features/emoji/index.ts
Normal file
73
app/javascript/mastodon/features/emoji/index.ts
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
import initialState from '@/mastodon/initial_state';
|
||||||
|
import { loadWorker } from '@/mastodon/utils/workers';
|
||||||
|
|
||||||
|
import { toSupportedLocale } from './locale';
|
||||||
|
import { emojiLogger } from './utils';
|
||||||
|
|
||||||
|
const userLocale = toSupportedLocale(initialState?.meta.locale ?? 'en');
|
||||||
|
|
||||||
|
let worker: Worker | null = null;
|
||||||
|
|
||||||
|
const log = emojiLogger('index');
|
||||||
|
|
||||||
|
export function initializeEmoji() {
|
||||||
|
log('initializing emojis');
|
||||||
|
if (!worker && 'Worker' in window) {
|
||||||
|
try {
|
||||||
|
worker = loadWorker(new URL('./worker', import.meta.url), {
|
||||||
|
type: 'module',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Error creating web worker:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}, 500);
|
||||||
|
thisWorker.addEventListener('message', (event: MessageEvent<string>) => {
|
||||||
|
const { data: message } = event;
|
||||||
|
if (message === 'ready') {
|
||||||
|
log('worker ready, loading data');
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
thisWorker.postMessage('custom');
|
||||||
|
void loadEmojiLocale(userLocale);
|
||||||
|
// Load English locale as well, because people are still used to
|
||||||
|
// using it from before we supported other locales.
|
||||||
|
if (userLocale !== 'en') {
|
||||||
|
void loadEmojiLocale('en');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log('got worker message: %s', message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
void fallbackLoad();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fallbackLoad() {
|
||||||
|
log('falling back to main thread for loading');
|
||||||
|
const { importCustomEmojiData } = await import('./loader');
|
||||||
|
await importCustomEmojiData();
|
||||||
|
await loadEmojiLocale(userLocale);
|
||||||
|
if (userLocale !== 'en') {
|
||||||
|
await loadEmojiLocale('en');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadEmojiLocale(localeString: string) {
|
||||||
|
const locale = toSupportedLocale(localeString);
|
||||||
|
|
||||||
|
if (worker) {
|
||||||
|
worker.postMessage(locale);
|
||||||
|
} else {
|
||||||
|
const { importEmojiData } = await import('./loader');
|
||||||
|
await importEmojiData(locale);
|
||||||
|
}
|
||||||
|
}
|
84
app/javascript/mastodon/features/emoji/loader.ts
Normal file
84
app/javascript/mastodon/features/emoji/loader.ts
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
import { flattenEmojiData } from 'emojibase';
|
||||||
|
import type { CompactEmoji, FlatCompactEmoji } from 'emojibase';
|
||||||
|
|
||||||
|
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
|
||||||
|
|
||||||
|
import {
|
||||||
|
putEmojiData,
|
||||||
|
putCustomEmojiData,
|
||||||
|
loadLatestEtag,
|
||||||
|
putLatestEtag,
|
||||||
|
} from './database';
|
||||||
|
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale';
|
||||||
|
import type { LocaleOrCustom } from './types';
|
||||||
|
import { emojiLogger } from './utils';
|
||||||
|
|
||||||
|
const log = emojiLogger('loader');
|
||||||
|
|
||||||
|
export async function importEmojiData(localeString: string) {
|
||||||
|
const locale = toSupportedLocale(localeString);
|
||||||
|
const emojis = await fetchAndCheckEtag<CompactEmoji[]>(locale);
|
||||||
|
if (!emojis) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const flattenedEmojis: FlatCompactEmoji[] = flattenEmojiData(emojis);
|
||||||
|
log('loaded %d for %s locale', flattenedEmojis.length, locale);
|
||||||
|
await putEmojiData(flattenedEmojis, locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importCustomEmojiData() {
|
||||||
|
const emojis = await fetchAndCheckEtag<ApiCustomEmojiJSON[]>('custom');
|
||||||
|
if (!emojis) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log('loaded %d custom emojis', emojis.length);
|
||||||
|
await putCustomEmojiData(emojis);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAndCheckEtag<ResultType extends object[]>(
|
||||||
|
localeOrCustom: LocaleOrCustom,
|
||||||
|
): Promise<ResultType | null> {
|
||||||
|
const locale = toSupportedLocaleOrCustom(localeOrCustom);
|
||||||
|
|
||||||
|
// 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 oldEtag = await loadLatestEtag(locale);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'If-None-Match': oldEtag ?? '', // Send the old ETag to check for modifications
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// If not modified, return null
|
||||||
|
if (response.status === 304) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to fetch emoji data for ${localeOrCustom}: ${response.statusText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as ResultType;
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
throw new Error(
|
||||||
|
`Unexpected data format for ${localeOrCustom}: expected an array`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the ETag for future requests
|
||||||
|
const etag = response.headers.get('ETag');
|
||||||
|
if (etag) {
|
||||||
|
await putLatestEtag(etag, localeOrCustom);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
29
app/javascript/mastodon/features/emoji/locale.test.ts
Normal file
29
app/javascript/mastodon/features/emoji/locale.test.ts
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import { SUPPORTED_LOCALES } from 'emojibase';
|
||||||
|
|
||||||
|
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale';
|
||||||
|
|
||||||
|
describe('toSupportedLocale', () => {
|
||||||
|
test('returns the same locale if it is supported', () => {
|
||||||
|
for (const locale of SUPPORTED_LOCALES) {
|
||||||
|
expect(toSupportedLocale(locale)).toBe(locale);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns "en" for unsupported locales', () => {
|
||||||
|
const unsupportedLocales = ['xx', 'fr-CA'];
|
||||||
|
for (const locale of unsupportedLocales) {
|
||||||
|
expect(toSupportedLocale(locale)).toBe('en');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toSupportedLocaleOrCustom', () => {
|
||||||
|
test('returns custom for "custom" locale', () => {
|
||||||
|
expect(toSupportedLocaleOrCustom('custom')).toBe('custom');
|
||||||
|
});
|
||||||
|
test('returns supported locale for valid locales', () => {
|
||||||
|
for (const locale of SUPPORTED_LOCALES) {
|
||||||
|
expect(toSupportedLocaleOrCustom(locale)).toBe(locale);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
23
app/javascript/mastodon/features/emoji/locale.ts
Normal file
23
app/javascript/mastodon/features/emoji/locale.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import type { Locale } from 'emojibase';
|
||||||
|
import { SUPPORTED_LOCALES } from 'emojibase';
|
||||||
|
|
||||||
|
import type { LocaleOrCustom } from './types';
|
||||||
|
|
||||||
|
export function toSupportedLocale(localeBase: string): Locale {
|
||||||
|
const locale = localeBase.toLowerCase();
|
||||||
|
if (isSupportedLocale(locale)) {
|
||||||
|
return locale;
|
||||||
|
}
|
||||||
|
return 'en'; // Default to English if unsupported
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toSupportedLocaleOrCustom(locale: string): LocaleOrCustom {
|
||||||
|
if (locale.toLowerCase() === 'custom') {
|
||||||
|
return 'custom';
|
||||||
|
}
|
||||||
|
return toSupportedLocale(locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSupportedLocale(locale: string): locale is Locale {
|
||||||
|
return SUPPORTED_LOCALES.includes(locale.toLowerCase() as Locale);
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user