Compare commits

...
Sign in to create a new pull request.

204 commits

Author SHA1 Message Date
f61a5a7a02 Merge remote-tracking branch 'upstream/main' 2025-04-05 11:32:27 +02:00
renovate[bot]
5f87ae101c
chore(deps): update dependency strong_migrations to v2.3.0 ()
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-04 08:17:29 +00:00
renovate[bot]
4ed9778c85
chore(deps): update dependency brakeman to v7.0.1 ()
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-04 07:36:53 +00:00
renovate[bot]
9b596dbc78
fix(deps): update dependency sass to v1.86.3 ()
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-04 07:24:51 +00:00
Matt Jankowski
4d3758308a
Use bundler version 2.6.7 () 2025-04-04 07:24:32 +00:00
github-actions[bot]
58e3e43e06
New Crowdin Translations (automated) ()
Co-authored-by: GitHub Actions <noreply@github.com>
2025-04-04 07:24:27 +00:00
renovate[bot]
5859abf2ff
chore(deps): update dependency rubocop to v1.75.2 ()
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-03 15:49:14 +00:00
renovate[bot]
d65c3e95ad
chore(deps): update dependency irb to v1.15.2 ()
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-03 15:49:10 +00:00
github-actions[bot]
e1d6748422
New Crowdin Translations (automated) ()
Co-authored-by: GitHub Actions <noreply@github.com>
2025-04-03 08:42:19 +00:00
renovate[bot]
7b9ad2c416
fix(deps): update dependency sass to v1.86.2 ()
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-03 08:28:53 +00:00
Claire
dd23ba9c83
Refactor local-URI-to-account resolving () 2025-04-02 14:44:09 +00:00
renovate[bot]
4bbe33e0bd
fix(deps): update dependency sass to v1.86.1 ()
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-02 14:43:45 +00:00
renovate[bot]
470285d815
chore(deps): update yarn to v4.8.1 ()
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-02 14:43:40 +00:00
renovate[bot]
361a6a21ba
fix(deps): update dependency react-textarea-autosize to v8.5.9 ()
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-02 14:10:52 +00:00
Matt Jankowski
d315a90db7
Convert settings/pictures spec controller->request () 2025-04-02 12:58:47 +00:00
Matt Jankowski
501ced4239
Add coverage for extra attributes scenario in Admin::Trends::StatusesHelper#one_line_preview method () 2025-04-02 12:52:54 +00:00
Renaud Chaput
0653374c34
Fix Renovate alert with the now deprecated @types/emoji-mart package () 2025-04-02 12:39:22 +00:00
Renaud Chaput
05fc24c5f9
Fix Typescript dependency resolution () 2025-04-02 12:38:34 +00:00
Eugen Rochko
2c70c28bbb
Refactor <DomainBlocks> to TypeScript ()
Co-authored-by: Claire <claire.github-309c@sitedethib.com>
2025-04-02 12:31:39 +00:00
David Roetzel
e2ef173b82
Refactoring: Move SignatureVerificationError into Mastodon namespace () 2025-04-02 07:54:29 +00:00
github-actions[bot]
324acff572
New Crowdin Translations (automated) ()
Co-authored-by: GitHub Actions <noreply@github.com>
2025-04-02 07:14:45 +00:00
renovate[bot]
d49fcb7ff3
chore(deps): update dependency opentelemetry-instrumentation-sidekiq to v0.26.1 ()
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-02 06:45:52 +00:00
Eugen Rochko
063030df82
Refactor <CopyIconButton> to TypeScript () 2025-04-02 06:45:16 +00:00
Matt Jankowski
6e607f97a3
Extract constant for Poll last fetch duration check () 2025-04-02 06:43:46 +00:00
Echo
e8270e2807
Upgrade to ESLint v9 flat config ()
Co-authored-by: Nick Schonning <nschonni@gmail.com>
2025-04-01 16:30:18 +00:00
Claire
9686ae7060
Fix static version of animated PNG emojis not being properly extracted () 2025-04-01 08:53:49 +00:00
renovate[bot]
2283562ebd
chore(deps): update rubocop (non-major) to v1.25.0 ()
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-01 06:28:17 +00:00
github-actions[bot]
b2b532708e
New Crowdin Translations (automated) ()
Co-authored-by: GitHub Actions <noreply@github.com>
2025-04-01 06:20:23 +00:00
renovate[bot]
6211130054
chore(deps): update dependency nokogiri to v1.18.7 ()
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-01 06:09:40 +00:00
Echo
65c553ab59
Fix bugs with upload progress () 2025-03-31 21:05:09 +00:00
Matt Jankowski
b4e56822c7
Fix Style/MapToHash cop () 2025-03-31 14:08:02 +00:00
Claire
33f3a4c4c8
Fix poll refresh button being incorrectly hidden () 2025-03-31 08:21:02 +00:00
Eugen Rochko
70e14c1ed0
Fix being unable to hide controls in full screen video in web UI () 2025-03-31 08:17:57 +00:00
Claire
19346fd5f8
Fix extra space under left-indented vertical videos () 2025-03-31 08:17:39 +00:00
github-actions[bot]
758d2da887
New Crowdin Translations (automated) ()
Co-authored-by: GitHub Actions <noreply@github.com>
2025-03-31 07:21:33 +00:00
renovate[bot]
3b5540a437
chore(deps): update dependency linzer to v0.6.3 ()
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-31 07:16:54 +00:00
Renaud Chaput
1bc28709cc
Convert the polls reducer to plain JS () 2025-03-29 20:17:27 +00:00
Claire
04a9252a93
Fix incorrect locked account warning in composer () 2025-03-29 18:06:46 +00:00
Echo
74ee96505a
Upgrade Intl packages () 2025-03-28 17:30:02 +00:00
Claire
ee65f77a7e
Add server-side support for grouping account sign-up notifications () 2025-03-28 12:35:25 +00:00
Echo
902aab1245
Remove react-motion library () 2025-03-28 12:34:51 +00:00
David Roetzel
97b9994743
Basic FASP support () 2025-03-28 12:16:40 +00:00
Eugen Rochko
e5fd61a84e
Refactor <Video> to TypeScript () 2025-03-28 12:15:43 +00:00
github-actions[bot]
e28b64ac2d
New Crowdin Translations (automated) ()
Co-authored-by: GitHub Actions <noreply@github.com>
2025-03-28 09:47:53 +00:00
Matt Jankowski
dfa4a97dd8
Fix intermittent account note failure by removing disappearing content check () 2025-03-28 09:24:36 +00:00
Claire
c2defe0e4c
Change account suspensions to be federated to recently-followed accounts as well () 2025-03-28 09:20:32 +00:00
Matt Jankowski
0479efdbb6
Fix intermittent failure on account note system spec () 2025-03-27 15:09:09 +00:00
Matt Jankowski
ef879a532f
Convert activitypub/* controller specs to request specs () 2025-03-27 14:55:13 +00:00
Matt Jankowski
445aa4ac72
Convert activitypub/inboxes spec controller->request () 2025-03-27 13:52:48 +00:00
Claire
1326088110
Change AccountReachFinder to consider statuses based on suspension date () 2025-03-27 13:41:13 +00:00
Echo
8a3bed1933
Fix SASS deprecation notices () 2025-03-27 13:09:42 +00:00
renovate[bot]
aa575341c2
chore(deps): update dependency rubocop to v1.75.1 ()
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-27 08:19:17 +00:00
github-actions[bot]
99f93e675a
New Crowdin Translations (automated) ()
Co-authored-by: GitHub Actions <noreply@github.com>
2025-03-27 08:07:54 +00:00
Matt Jankowski
0e3ff680d3
Update rubocop to version 1.75.0 () 2025-03-27 07:48:19 +00:00
scarf
e9fe01e2a6
feat: use <time> tag () 2025-03-26 16:14:08 +00:00
Claire
c43508b3e0
Add registrations.reason_required attribute to /api/v2/instance response () 2025-03-26 14:12:58 +00:00
David Roetzel
02db065571
Use fixed order in flaky spec () 2025-03-26 13:26:24 +00:00
Claire
59e189ad3c
Add support for paginating partial collections in SynchronizeFollowersService () 2025-03-26 11:33:59 +00:00
github-actions[bot]
dd6c573cc3
New Crowdin Translations (automated) ()
Co-authored-by: GitHub Actions <noreply@github.com>
2025-03-26 07:42:25 +00:00
Shlee
803a8be998
Add EXTRA_MEDIA_HOSTS environment variable to add extra hosts to Content-Security-Policy ()
Co-authored-by: Claire <claire.github-309c@sitedethib.com>
2025-03-26 07:42:15 +00:00
Claire
c93b2c6809
Add new filter action to blur media () 2025-03-26 07:31:05 +00:00
Matt Jankowski
2a181f56e3
Convert settings/deletes spec controller->request/system () 2025-03-26 07:26:28 +00:00
Eugen Rochko
94d71c992e
Refactor alerts to TypeScript, remove react-notification dependency () 2025-03-25 18:25:07 +00:00
Matt Jankowski
e1dbbf6c9d
Isolate assertions in v2/notifications intermittent failure cases () 2025-03-25 16:22:41 +00:00
Claire
3edac14f02
Fix follower synchronization mechanism erroneously removing followers from multi-page collections () 2025-03-25 15:50:05 +00:00
Echo
81b88095b4
Allow devcontainer to be accessed from local network () 2025-03-25 15:20:56 +00:00
Claire
2eb6d815d6
Fix bookmarks and favourites not being filtered () 2025-03-25 15:20:36 +00:00
Claire
8c3eeb4d29
Fix filters not applying in detailed view () 2025-03-25 13:11:49 +00:00
Claire
38f5e74122
Add Deprecation headers on deprecated endpoints ()
Co-authored-by: Damien Mathieu <42@dmathieu.com>
2025-03-25 12:30:10 +00:00
renovate[bot]
40bb8ec325
chore(deps): update dependency selenium-webdriver to v4.30.1 ()
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-25 10:18:00 +00:00
renovate[bot]
ef8f62c382
chore(deps): update dependency mime-types to v3.6.2 ()
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-25 09:09:25 +00:00
Matt Jankowski
9bba2aab33
Convert intents spec controller->request () 2025-03-25 09:07:22 +00:00
renovate[bot]
2453b94198
chore(deps): update dependency nokogiri to v1.18.6 ()
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-25 09:06:51 +00:00
github-actions[bot]
90bf67f053
New Crowdin Translations (automated) ()
Co-authored-by: GitHub Actions <noreply@github.com>
2025-03-25 09:05:59 +00:00
renovate[bot]
2fc4475ea3
fix(deps): update babel monorepo to v7.27.0 ()
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-25 08:57:15 +00:00
Eugen Rochko
2e9b2df570
Add double tap to zoom and swipe to dismiss to media modal in web UI ()
Co-authored-by: ChaosExAnima <ChaosExAnima@users.noreply.github.com>
2025-03-24 17:25:30 +00:00
renovate[bot]
82acef50b0
fix(deps): update dependency sass to v1.86.0 ()
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-24 15:46:38 +00:00
Claire
a72c7f6cc1
Fix streaming server refusing unix socket path in DATABASE_URL () 2025-03-24 14:07:27 +00:00
Claire
958953a687
Refactor spoiler button logic into separate SpoilerButton component () 2025-03-24 13:58:37 +00:00
Claire
5390edc2aa
Change user archive signed URL TTL from 10 seconds to 1 hour () 2025-03-24 09:59:05 +00:00
Claire
225b18e742
Add system spec for account notes () 2025-03-24 09:30:23 +00:00
Claire
ef870007e9
Fix CacheBuster being queued for missing media attachments () 2025-03-24 08:58:15 +00:00
github-actions[bot]
b892b15ba6
New Crowdin Translations (automated) ()
Co-authored-by: GitHub Actions <noreply@github.com>
2025-03-24 08:35:40 +00:00
Matt Jankowski
9d3daa847a
Convert filters/statuses spec controller->system/request () 2025-03-24 08:11:34 +00:00
renovate[bot]
d15879312e
chore(deps): update dependency pghero to v3.6.2 ()
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-24 08:10:43 +00:00
renovate[bot]
fe4cf75ece
chore(deps): update dependency strong_migrations to v2.2.1 ()
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-24 08:10:29 +00:00
renovate[bot]
0a5bbf5ac6
chore(deps): update definitelytyped types (non-major) ()
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-24 08:10:07 +00:00
renovate[bot]
7825cd1bdb
chore(deps): update dependency tzinfo-data to v1.2025.2 ()
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-24 08:08:58 +00:00
Eugen Rochko
1960aac90b
Fix display of failed-to-load image attachments in web UI () 2025-03-21 10:23:49 +00:00
Matt Jankowski
a2981a0997
Convert admin/users/two_factor_authentications spec controller->system () 2025-03-21 07:41:38 +00:00
Matt Jankowski
469cfc5430
Convert admin/change_emails spec controller->system () 2025-03-21 07:40:29 +00:00
Matt Jankowski
0284e77e5f
Convert admin/action_logs spec controller->system () 2025-03-21 07:38:29 +00:00
github-actions[bot]
5eba86e2d1
New Crowdin Translations (automated) ()
Co-authored-by: GitHub Actions <noreply@github.com>
2025-03-21 07:31:42 +00:00
renovate[bot]
cf25d4fe4b
chore(deps): update dependency haml_lint to v0.61.1 ()
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-21 07:07:16 +00:00
renovate[bot]
c03e3129a0
fix(deps): update dependency axios to v1.8.4 ()
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-20 15:12:59 +00:00
renovate[bot]
57b9dfd53e
chore(deps): update dependency fog-openstack to v1.1.5 ()
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-20 15:12:28 +00:00
David Roetzel
d232fa5b14
Reduce number of metric labels () 2025-03-20 11:49:10 +00:00
github-actions[bot]
936d3a7de9
New Crowdin Translations (automated) ()
Co-authored-by: GitHub Actions <noreply@github.com>
2025-03-20 09:32:41 +00:00
renovate[bot]
e44e5d156d
chore(deps): update dependency csv to v3.3.3 ()
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-20 08:58:57 +00:00
renovate[bot]
8ddefbd2af
chore(deps): update dependency nokogiri to v1.18.5 ()
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-20 08:40:37 +00:00
Claire
290d57d6d9
Prevent duplicate REST API requests on submitting account personal note with ctrl+enter () 2025-03-19 23:29:26 +00:00
Matt Jankowski
f7b1769e8a
Convert admin/dashboard spec controller->system () 2025-03-19 08:32:36 +00:00
Matt Jankowski
539a06f189
Convert admin/account_actions spec controller->system () 2025-03-19 08:20:16 +00:00
github-actions[bot]
ffc568589c
New Crowdin Translations (automated) ()
Co-authored-by: GitHub Actions <noreply@github.com>
2025-03-19 08:16:25 +00:00
Eugen Rochko
0099907600
Fix error when terms of service are missing an effective date () 2025-03-18 21:39:13 +00:00
Claire
547658f086
Fix handling of malformed/unusual HTML () 2025-03-18 14:50:41 +00:00
Matt Jankowski
4ad5d8e6e5
Lock aws-sdk-core to pre-checksum-required version () 2025-03-18 14:50:28 +00:00
github-actions[bot]
dc21104c04
New Crowdin Translations (automated) ()
Co-authored-by: GitHub Actions <noreply@github.com>
2025-03-18 14:49:53 +00:00
Claire
9d5cbbbf0f
Fix account notes not being displayed () 2025-03-18 10:32:35 +00:00
renovate[bot]
6bce43cdb8
chore(deps): update dependency mime-types to v3.6.1 ()
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-18 10:32:20 +00:00
Matt Jankowski
795d465f8d
Convert disputes/strikes spec controller->request/system () 2025-03-18 08:18:36 +00:00
Matt Jankowski
8ef546fe6b
Convert oauth/tokens#revoke spec controller->request () 2025-03-18 08:16:42 +00:00
Claire
30e334b51a
Fix language detection sometimes kicking in *after* posting () 2025-03-17 16:49:09 +00:00
Claire
e30001bc80
Fix incorrect URL being used when cache busting () 2025-03-17 16:40:28 +00:00
renovate[bot]
2a5853989f
fix(deps): update dependency pg to v8.14.1 ()
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-17 16:27:45 +00:00
David Roetzel
e74774e366
Disable installation of instrumentation hooks () 2025-03-17 15:50:13 +00:00
renovate[bot]
8c59fbe41b
chore(deps): update dependency nokogiri to v1.18.4 ()
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-17 10:28:51 +00:00
Matt Jankowski
2f98134ac6
Use bundler version 2.6.6 () 2025-03-14 15:24:40 +00:00
Eugen Rochko
d213c585ff
Add age verification on sign-up () 2025-03-14 14:07:29 +00:00
David Roetzel
4a6cf67c46
Add middleware to record queue time () 2025-03-14 13:52:04 +00:00
Claire
24ec83ee52
Fix timeline banners sizing () 2025-03-14 11:10:26 +00:00
rinsuki
2d97215aad
chore: Allow yuvj420p (full color range yuv420p) movies passthrough () 2025-03-14 09:30:14 +00:00
github-actions[bot]
7a6a898ca1
New Crowdin Translations (automated) ()
Co-authored-by: GitHub Actions <noreply@github.com>
2025-03-14 09:17:00 +00:00
renovate[bot]
f4f444528a
Update dependency react-textarea-autosize to v8.5.8 ()
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-14 09:03:20 +00:00
renovate[bot]
9c2d5b534f
Update dependency rubocop to v1.74.0 ()
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-13 15:49:59 +00:00
renovate[bot]
31250a89b5
Update dependency libvips to v8.16.1 ()
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-13 15:48:48 +00:00
37d017bcf3 Merge remote-tracking branch 'upstream/main' 2025-02-28 09:35:30 +01:00
4e4d5bbc09 Merge remote-tracking branch 'upstream/main' 2025-02-24 15:18:05 +01:00
06b7ebe45c Merge remote-tracking branch 'upstream/main' 2025-01-10 10:09:45 +01:00
707fb0254d Change bio limit from 500 > 1000 2024-12-05 09:40:13 +01:00
792fbd44bd Merge remote-tracking branch 'upstream/main' 2024-12-03 15:22:44 +01:00
f9841db0be Merge remote-tracking branch 'upstream/main' 2024-11-23 16:44:47 +01:00
324e356372 Merge remote-tracking branch 'upstream/main' 2024-11-11 09:00:48 +01:00
2f5d989bab Merge remote-tracking branch 'upstream/main' 2024-10-27 13:11:29 +01:00
9df8594415 Merge remote-tracking branch 'upstream/main' 2024-10-24 11:35:29 +02:00
8ff3985e38 Merge remote-tracking branch 'upstream/main' 2024-10-08 16:53:41 +02:00
db24d060b1 Merge remote-tracking branch 'upstream/main' 2024-10-04 21:36:13 +02:00
0105d08422 Merge remote-tracking branch 'upstream/main' 2024-10-01 11:25:05 +02:00
192bc20432 Merge remote-tracking branch 'upstream/main' 2024-09-27 09:59:28 +02:00
59ef432b97 furry tab in nav panel 2024-09-26 11:39:09 +02:00
0e80d060dc Merge remote-tracking branch 'upstream/main' 2024-09-26 11:25:14 +02:00
f532af92f1 Merge remote-tracking branch 'upstream/main' 2024-09-13 13:39:47 +02:00
5c07d5c138 Merge remote-tracking branch 'upstream/main' 2024-09-13 11:20:05 +02:00
058ddd61e2 Merge remote-tracking branch 'upstream/main' 2024-09-12 22:56:19 +02:00
b8e117e713 Merge remote-tracking branch 'upstream/main' 2024-09-04 13:15:20 +02:00
59fd54020d Merge remote-tracking branch 'upstream/main' 2024-08-30 08:55:20 +02:00
b782e3b21c Merge remote-tracking branch 'upstream/main' 2024-07-22 10:30:39 +02:00
ae4aa86a12 Merge remote-tracking branch 'upstream/main' 2024-07-17 14:50:57 +02:00
33ec036619 Merge remote-tracking branch 'upstream/main' 2024-07-17 09:27:49 +02:00
46de49cc45 Merge remote-tracking branch 'upstream/main' 2024-07-07 11:07:03 +02:00
631378ee1c Merge remote-tracking branch 'upstream/main' 2024-06-24 09:05:39 +02:00
c1c0670a04 Merge remote-tracking branch 'upstream/main' 2024-06-19 10:02:05 +02:00
6f73d7eedd Merge remote-tracking branch 'upstream/main' 2024-06-15 18:21:04 +02:00
0552eda5d9 Merge remote-tracking branch 'upstream/main' 2024-06-03 09:52:08 +02:00
e19315b0cd Merge remote-tracking branch 'upstream/main' 2024-05-30 13:10:44 +02:00
eae50b2cb3 Merge remote-tracking branch 'upstream/main' 2024-05-21 11:19:43 +02:00
5beb356718 Merge remote-tracking branch 'upstream/main' 2024-05-01 13:57:16 +02:00
14db7f1a05 Merge remote-tracking branch 'upstream/main' 2024-04-29 09:50:14 +02:00
61c71f9c5b Merge remote-tracking branch 'upstream/main' 2024-04-26 13:26:00 +02:00
12ef898447 Merge remote-tracking branch 'upstream/main' 2024-04-23 13:55:48 +02:00
3b11f173b3 Merge remote-tracking branch 'upstream/main' 2024-04-22 09:59:07 +02:00
61cd0f81b5 max fields 2024-04-17 16:55:18 +02:00
32414a51e9 Merge remote-tracking branch 'upstream/main' 2024-03-25 11:42:07 +01:00
95a990fb6d Merge remote-tracking branch 'upstream/main' 2024-03-19 13:41:44 +01:00
ffae506e12 Merge remote-tracking branch 'upstream/main' 2024-03-15 11:06:28 +01:00
2ed4a45528 Merge remote-tracking branch 'upstream/main' 2024-03-12 13:34:44 +01:00
2cc89b04b4 Merge remote-tracking branch 'upstream/main' 2024-03-04 13:53:47 +01:00
6cb18320c0 Merge remote-tracking branch 'upstream/main' 2024-02-17 13:01:40 +01:00
ff5beb6a60 Merge remote-tracking branch 'upstream/main' 2024-02-12 08:46:58 +01:00
815809a6a2 Merge remote-tracking branch 'upstream/main' 2024-02-06 14:30:04 +01:00
4d23759cf7 Merge remote-tracking branch 'upstream/main' 2024-02-01 19:35:09 +01:00
ed4a0bba04 Merge remote-tracking branch 'upstream/main' 2024-01-31 10:02:26 +01:00
d5769ec3e9 Merge remote-tracking branch 'upstream/main' 2024-01-24 10:52:32 +01:00
19c61afd99 Merge remote-tracking branch 'upstream/main' 2024-01-18 10:22:32 +01:00
ee9d63a7a1 Merge remote-tracking branch 'upstream/main' 2024-01-17 11:25:25 +01:00
29523f518b Merge remote-tracking branch 'upstream/main' 2024-01-16 13:14:39 +01:00
9b8fc472b5 add pets svg 2024-01-16 10:07:00 +01:00
12bfa9781b Merge remote-tracking branch 'upstream/main' 2024-01-16 09:47:16 +01:00
373f1871b9 Merge remote-tracking branch 'upstream/main' 2023-12-28 11:26:44 +01:00
f3fefa3dea Merge remote-tracking branch 'upstream/main' 2023-12-20 10:29:58 +01:00
9c31391cb9 Merge remote-tracking branch 'upstream/main' 2023-12-19 13:10:05 +01:00
6b71734b73 Merge remote-tracking branch 'upstream/main' 2023-12-18 14:47:49 +01:00
63a70ea1f1 Merge remote-tracking branch 'upstream/main' 2023-12-17 14:40:41 +01:00
7e5ed46553 Merge remote-tracking branch 'upstream/main' 2023-12-11 17:06:55 +01:00
5082246ee5 Merge remote-tracking branch 'upstream/main' 2023-12-03 11:19:50 +01:00
3dd4a94c57 Merge remote-tracking branch 'upstream/main' 2023-12-01 10:57:58 +01:00
2656e00d74 update ci 2023-11-30 12:14:24 +01:00
91a78e0652 Merge remote-tracking branch 'upstream/main' 2023-11-30 12:12:59 +01:00
03fdbfe11a Merge remote-tracking branch 'upstream/main' 2023-11-24 13:23:08 +01:00
b3377b7883 trending score half-life to 2 hours 2023-11-19 02:53:34 +01:00
2c4f8b71cb Merge remote-tracking branch 'upstream/main' 2023-11-19 02:41:47 +01:00
7586c4c6f9 Merge remote-tracking branch 'upstream/main' 2023-11-10 22:09:54 +01:00
d2dd0d5e15 version 2023-11-10 13:30:48 +01:00
c07ddbe7b1 Merge remote-tracking branch 'upstream/main' 2023-11-10 00:29:32 +01:00
05855361cb revert ba7703207c
revert new dockerfile
2023-11-09 23:17:05 +01:00
0b20085f0e Merge pull request 'new dockerfile' () from dockerfile-rewrite into main
Reviewed-on: 
2023-11-09 22:29:42 +01:00
ba7703207c new dockerfile 2023-11-09 22:28:33 +01:00
4c6fcd3452 version 2023-11-09 21:58:00 +01:00
f39c791bbd s3 retries 2023-11-09 21:55:34 +01:00
bb10a911b5 update check cron 2023-11-09 21:54:16 +01:00
d126d231c7 footer 2023-11-09 21:53:50 +01:00
fb94cd866a drone 2023-11-09 21:47:38 +01:00
bcbdfc19fd max post chars and profile fields 2023-11-09 21:45:08 +01:00
f3309786f8 furry tab 2023-11-09 21:41:45 +01:00
484 changed files with 9785 additions and 5396 deletions
.devcontainer
.drone.yml.env.production.sample.eslintignore.eslintrc.js
.github
.prettierrc.js.rubocop_todo.ymlDockerfileGemfileGemfile.lock
app
controllers
helpers/admin/trends
inputs
javascript

View file

@ -21,12 +21,13 @@ services:
ES_HOST: es
ES_PORT: '9200'
LIBRE_TRANSLATE_ENDPOINT: http://libretranslate:5000
LOCAL_DOMAIN: ${LOCAL_DOMAIN:-localhost:3000}
# Overrides default command so things don't shut down after the process ends.
command: sleep infinity
ports:
- '127.0.0.1:3000:3000'
- '127.0.0.1:3035:3035'
- '127.0.0.1:4000:4000'
- '3000:3000'
- '3035:3035'
- '4000:4000'
networks:
- external_network
- internal_network

33
.drone.yml Normal file
View file

@ -0,0 +1,33 @@
kind: pipeline
name: mastodon
type: kubernetes
steps:
- name: build-mastodon
image: plugins/docker
environment:
DOCKER_BUILDKIT: 1
settings:
registry: git.greyfox.tech
username:
from_secret: registry_user
password:
from_secret: registry_pass
repo: git.greyfox.tech/bark/mastodon
dockerfile: Dockerfile
tags:
- latest
- name: build-streaming
image: plugins/docker
environment:
DOCKER_BUILDKIT: 1
settings:
registry: git.greyfox.tech
username:
from_secret: registry_user
password:
from_secret: registry_pass
repo: git.greyfox.tech/bark/mastodon-streaming
dockerfile: streaming/Dockerfile
tags:
- latest

View file

@ -79,6 +79,9 @@ AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
S3_ALIAS_HOST=files.example.com
# Optional list of hosts that are allowed to serve media for your instance
# EXTRA_MEDIA_HOSTS=https://data.example1.com,https://data.example2.com
# IP and session retention
# -----------------------
# Make sure to modify the scheduling of ip_cleanup_scheduler in config/sidekiq.yml

View file

@ -1,13 +0,0 @@
/build/**
/coverage/**
/db/**
/lib/**
/log/**
/node_modules/**
/nonobox/**
/public/**
!/public/embed.js
/spec/**
/tmp/**
/vendor/**
!.eslintrc.js

View file

@ -1,367 +0,0 @@
// @ts-check
const { defineConfig } = require('eslint-define-config');
module.exports = defineConfig({
root: true,
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
'plugin:jsx-a11y/recommended',
'plugin:import/recommended',
'plugin:promise/recommended',
'plugin:jsdoc/recommended',
],
env: {
browser: true,
node: true,
es6: true,
},
parser: '@typescript-eslint/parser',
plugins: [
'react',
'jsx-a11y',
'import',
'promise',
'@typescript-eslint',
'formatjs',
],
parserOptions: {
sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
ecmaVersion: 2021,
requireConfigFile: false,
babelOptions: {
configFile: false,
presets: ['@babel/react', '@babel/env'],
},
},
settings: {
react: {
version: 'detect',
},
'import/ignore': [
'node_modules',
'\\.(css|scss|json)$',
],
'import/resolver': {
typescript: {},
},
},
rules: {
'consistent-return': 'error',
'dot-notation': 'error',
eqeqeq: ['error', 'always', { 'null': 'ignore' }],
'indent': ['error', 2],
'jsx-quotes': ['error', 'prefer-single'],
'semi': ['error', 'always'],
'no-catch-shadow': 'error',
'no-console': [
'warn',
{
allow: [
'error',
'warn',
],
},
],
'no-empty': ['error', { "allowEmptyCatch": true }],
'no-restricted-properties': [
'error',
{ property: 'substring', message: 'Use .slice instead of .substring.' },
{ property: 'substr', message: 'Use .slice instead of .substr.' },
],
'no-restricted-syntax': [
'error',
{
// eslint-disable-next-line no-restricted-syntax
selector: 'Literal[value=/•/], JSXText[value=/•/]',
// eslint-disable-next-line no-restricted-syntax
message: "Use '·' (middle dot) instead of '•' (bullet)",
},
],
'no-unused-expressions': 'error',
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{
vars: 'all',
args: 'after-used',
destructuredArrayIgnorePattern: '^_',
ignoreRestSiblings: true,
},
],
'valid-typeof': 'error',
'react/jsx-filename-extension': ['error', { extensions: ['.jsx', 'tsx'] }],
'react/jsx-boolean-value': 'error',
'react/display-name': 'off',
'react/jsx-fragments': ['error', 'syntax'],
'react/jsx-equals-spacing': 'error',
'react/jsx-no-bind': 'error',
'react/jsx-no-useless-fragment': 'error',
'react/jsx-no-target-blank': ['error', { allowReferrer: true }],
'react/jsx-tag-spacing': 'error',
'react/jsx-uses-react': 'off', // not needed with new JSX transform
'react/jsx-wrap-multilines': 'error',
'react/react-in-jsx-scope': 'off', // not needed with new JSX transform
'react/self-closing-comp': 'error',
// recommended values found in https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/v6.8.0/src/index.js#L46
'jsx-a11y/click-events-have-key-events': 'off',
'jsx-a11y/label-has-associated-control': 'off',
'jsx-a11y/media-has-caption': 'off',
'jsx-a11y/no-autofocus': 'off',
// recommended rule is:
// 'jsx-a11y/no-interactive-element-to-noninteractive-role': [
// 'error',
// {
// tr: ['none', 'presentation'],
// canvas: ['img'],
// },
// ],
'jsx-a11y/no-interactive-element-to-noninteractive-role': 'off',
// recommended rule is:
// 'jsx-a11y/no-noninteractive-tabindex': [
// 'error',
// {
// tags: [],
// roles: ['tabpanel'],
// allowExpressionValues: true,
// },
// ],
'jsx-a11y/no-noninteractive-tabindex': 'off',
// recommended is full 'error'
'jsx-a11y/no-static-element-interactions': [
'warn',
{
handlers: [
'onClick',
],
},
],
// See https://github.com/import-js/eslint-plugin-import/blob/v2.29.1/config/recommended.js
'import/extensions': [
'error',
'always',
{
js: 'never',
jsx: 'never',
mjs: 'never',
ts: 'never',
tsx: 'never',
},
],
'import/first': 'error',
'import/newline-after-import': 'error',
'import/no-anonymous-default-export': 'error',
'import/no-extraneous-dependencies': [
'error',
{
devDependencies: [
'.eslintrc.js',
'config/webpack/**',
'app/javascript/mastodon/performance.js',
'app/javascript/mastodon/test_setup.js',
'app/javascript/**/__tests__/**',
],
},
],
'import/no-amd': 'error',
'import/no-commonjs': 'error',
'import/no-import-module-exports': 'error',
'import/no-relative-packages': 'error',
'import/no-self-import': 'error',
'import/no-useless-path-segments': 'error',
'import/no-webpack-loader-syntax': 'error',
'import/order': [
'error',
{
alphabetize: { order: 'asc' },
'newlines-between': 'always',
groups: [
'builtin',
'external',
'internal',
'parent',
['index', 'sibling'],
'object',
],
pathGroups: [
// React core packages
{
pattern: '{react,react-dom,react-dom/client,prop-types}',
group: 'builtin',
position: 'after',
},
// I18n
{
pattern: '{react-intl,intl-messageformat}',
group: 'builtin',
position: 'after',
},
// Common React utilities
{
pattern: '{classnames,react-helmet,react-router,react-router-dom}',
group: 'external',
position: 'before',
},
// Immutable / Redux / data store
{
pattern: '{immutable,@reduxjs/toolkit,react-redux,react-immutable-proptypes,react-immutable-pure-component}',
group: 'external',
position: 'before',
},
// Internal packages
{
pattern: '{mastodon/**}',
group: 'internal',
position: 'after',
},
],
pathGroupsExcludedImportTypes: [],
},
],
'promise/always-return': 'off',
'promise/catch-or-return': [
'error',
{
allowFinally: true,
},
],
'promise/no-callback-in-promise': 'off',
'promise/no-nesting': 'off',
'promise/no-promise-in-callback': 'off',
'formatjs/blocklist-elements': 'error',
'formatjs/enforce-default-message': ['error', 'literal'],
'formatjs/enforce-description': 'off', // description values not currently used
'formatjs/enforce-id': 'off', // Explicit IDs are used in the project
'formatjs/enforce-placeholders': 'off', // Issues in short_number.jsx
'formatjs/enforce-plural-rules': 'error',
'formatjs/no-camel-case': 'off', // disabledAccount is only non-conforming
'formatjs/no-complex-selectors': 'error',
'formatjs/no-emoji': 'error',
'formatjs/no-id': 'off', // IDs are used for translation keys
'formatjs/no-invalid-icu': 'error',
'formatjs/no-literal-string-in-jsx': 'off', // Should be looked at, but mainly flagging punctuation outside of strings
'formatjs/no-multiple-whitespaces': 'error',
'formatjs/no-offset': 'error',
'formatjs/no-useless-message': 'error',
'formatjs/prefer-formatted-message': 'error',
'formatjs/prefer-pound-in-plural': 'error',
'jsdoc/check-types': 'off',
'jsdoc/no-undefined-types': 'off',
'jsdoc/require-jsdoc': 'off',
'jsdoc/require-param-description': 'off',
'jsdoc/require-property-description': 'off',
'jsdoc/require-returns-description': 'off',
'jsdoc/require-returns': 'off',
},
overrides: [
{
files: [
'.eslintrc.js',
'*.config.js',
'.*rc.js',
'ide-helper.js',
'config/webpack/**/*',
'config/formatjs-formatter.js',
],
env: {
commonjs: true,
},
parserOptions: {
sourceType: 'script',
},
rules: {
'import/no-commonjs': 'off',
},
},
{
files: [
'**/*.ts',
'**/*.tsx',
],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/strict-type-checked',
'plugin:@typescript-eslint/stylistic-type-checked',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
'plugin:jsx-a11y/recommended',
'plugin:import/recommended',
'plugin:import/typescript',
'plugin:promise/recommended',
'plugin:jsdoc/recommended-typescript',
],
parserOptions: {
projectService: true,
tsconfigRootDir: __dirname,
},
rules: {
// Disable formatting rules that have been enabled in the base config
'indent': 'off',
// This is not needed as we use noImplicitReturns, which handles this in addition to understanding types
'consistent-return': 'off',
'import/consistent-type-specifier-style': ['error', 'prefer-top-level'],
'@typescript-eslint/consistent-type-definitions': ['warn', 'interface'],
'@typescript-eslint/consistent-type-exports': 'error',
'@typescript-eslint/consistent-type-imports': 'error',
"@typescript-eslint/prefer-nullish-coalescing": ['error', { ignorePrimitives: { boolean: true } }],
"@typescript-eslint/no-restricted-imports": [
"warn",
{
"name": "react-redux",
"importNames": ["useSelector", "useDispatch"],
"message": "Use typed hooks `useAppDispatch` and `useAppSelector` instead."
}
],
"@typescript-eslint/restrict-template-expressions": ['warn', { allowNumber: true }],
'jsdoc/require-jsdoc': 'off',
// Those rules set stricter rules for TS files
// to enforce better practices when converting from JS
'import/no-default-export': 'warn',
'react/prefer-stateless-function': 'warn',
'react/function-component-definition': ['error', { namedComponents: 'arrow-function' }],
'react/jsx-uses-react': 'off', // not needed with new JSX transform
'react/react-in-jsx-scope': 'off', // not needed with new JSX transform
'react/prop-types': 'off',
},
},
{
files: [
'**/__tests__/*.js',
'**/__tests__/*.jsx',
],
env: {
jest: true,
},
}
],
});

View file

@ -15,6 +15,8 @@
// to `null` after any other rule set it to something.
dependencyDashboardHeader: 'This issue lists Renovate updates and detected dependencies. Read the [Dependency Dashboard](https://docs.renovatebot.com/key-concepts/dashboard/) docs to learn more. Before approving any upgrade: read the description and comments in the [`renovate.json5` file](https://github.com/mastodon/mastodon/blob/main/.github/renovate.json5).',
postUpdateOptions: ['yarnDedupeHighest'],
// The types are now included in recent versions,we ignore them here until we upgrade and remove the dependency
ignoreDeps: ['@types/emoji-mart'],
packageRules: [
{
// Require Dependency Dashboard Approval for major version bumps of these node packages
@ -97,7 +99,13 @@
{
// Group all eslint-related packages with `eslint` in the same PR
matchManagers: ['npm'],
matchPackageNames: ['eslint', 'eslint-*', '@typescript-eslint/*'],
matchPackageNames: [
'eslint',
'eslint-*',
'typescript-eslint',
'@eslint/*',
'globals',
],
matchUpdateTypes: ['patch', 'minor'],
groupName: 'eslint (non-major)',
},

View file

@ -11,7 +11,7 @@ on:
- 'tsconfig.json'
- '.nvmrc'
- '.prettier*'
- '.eslint*'
- 'eslint.config.mjs'
- '**/*.js'
- '**/*.jsx'
- '**/*.ts'
@ -25,7 +25,7 @@ on:
- 'tsconfig.json'
- '.nvmrc'
- '.prettier*'
- '.eslint*'
- 'eslint.config.mjs'
- '**/*.js'
- '**/*.jsx'
- '**/*.ts'
@ -44,7 +44,7 @@ jobs:
uses: ./.github/actions/setup-javascript
- name: ESLint
run: yarn lint:js --max-warnings 0
run: yarn workspaces foreach --all --parallel run lint:js --max-warnings 0
- name: Typecheck
run: yarn typecheck

View file

@ -1,4 +1,4 @@
module.exports = {
singleQuote: true,
jsxSingleQuote: true
}
};

View file

@ -1,6 +1,6 @@
# This configuration was generated by
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp`
# using RuboCop version 1.73.2.
# using RuboCop version 1.75.1.
# The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new
@ -45,7 +45,7 @@ Style/FetchEnvVar:
- 'lib/tasks/repo.rake'
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: EnforcedStyle, MaxUnannotatedPlaceholdersAllowed, AllowedMethods, AllowedPatterns.
# Configuration parameters: EnforcedStyle, MaxUnannotatedPlaceholdersAllowed, Mode, AllowedMethods, AllowedPatterns.
# SupportedStyles: annotated, template, unannotated
# AllowedMethods: redirect
Style/FormatStringToken:
@ -64,16 +64,10 @@ Style/HashTransformValues:
- 'app/serializers/rest/web_push_subscription_serializer.rb'
- 'app/services/import_service.rb'
# This cop supports unsafe autocorrection (--autocorrect-all).
Style/MapToHash:
Exclude:
- 'app/models/status.rb'
# Configuration parameters: AllowedMethods.
# AllowedMethods: respond_to_missing?
Style/OptionalBooleanParameter:
Exclude:
- 'app/helpers/json_ld_helper.rb'
- 'app/lib/admin/system_check/message.rb'
- 'app/lib/request.rb'
- 'app/lib/webfinger.rb'

View file

@ -27,9 +27,9 @@ FROM ${BASE_REGISTRY}/ruby:${RUBY_VERSION}-slim-${DEBIAN_VERSION} AS ruby
# Resulting version string is vX.X.X-MASTODON_VERSION_PRERELEASE+MASTODON_VERSION_METADATA
# Example: v4.3.0-nightly.2023.11.09+pr-123456
# Overwrite existence of 'alpha.X' in version.rb [--build-arg MASTODON_VERSION_PRERELEASE="nightly.2023.11.09"]
ARG MASTODON_VERSION_PRERELEASE=""
ARG MASTODON_VERSION_PRERELEASE="bark"
# Append build metadata or fork information to version.rb [--build-arg MASTODON_VERSION_METADATA="pr-123456"]
ARG MASTODON_VERSION_METADATA=""
ARG MASTODON_VERSION_METADATA="dev"
# Will be available as Mastodon::Version.source_commit
ARG SOURCE_COMMIT=""
@ -186,7 +186,7 @@ FROM build AS libvips
# libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"]
# renovate: datasource=github-releases depName=libvips packageName=libvips/libvips
ARG VIPS_VERSION=8.16.0
ARG VIPS_VERSION=8.16.1
# 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

View file

@ -14,6 +14,7 @@ gem 'haml-rails', '~>2.0'
gem 'pg', '~> 1.5'
gem 'pghero'
gem 'aws-sdk-core', '< 3.216.0', require: false # TODO: https://github.com/mastodon/mastodon/pull/34173#issuecomment-2733378873
gem 'aws-sdk-s3', '~> 1.123', require: false
gem 'blurhash', '~> 0.1'
gem 'fog-core', '<= 2.6.0'
@ -61,6 +62,7 @@ gem 'inline_svg'
gem 'irb', '~> 1.8'
gem 'kaminari', '~> 1.2'
gem 'link_header', '~> 0.0'
gem 'linzer', '~> 0.6.1'
gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock'
gem 'mime-types', '~> 3.6.0', require: 'mime/types/columnar'
gem 'mutex_m'

View file

@ -91,11 +91,11 @@ GEM
aes_key_wrap (1.1.0)
android_key_attestation (0.3.0)
annotaterb (4.14.0)
ast (2.4.2)
ast (2.4.3)
attr_required (1.0.2)
aws-eventstream (1.3.0)
aws-partitions (1.1032.0)
aws-sdk-core (3.214.1)
aws-eventstream (1.3.2)
aws-partitions (1.1080.0)
aws-sdk-core (3.215.1)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@ -107,9 +107,9 @@ GEM
aws-sdk-core (~> 3, >= 3.210.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.10.1)
aws-sigv4 (1.11.0)
aws-eventstream (~> 1, >= 1.0.2)
azure-blob (0.5.4)
azure-blob (0.5.7)
rexml
base64 (0.2.0)
bcp47_spec (0.2.1)
@ -126,7 +126,7 @@ GEM
blurhash (0.1.8)
bootsnap (1.18.4)
msgpack (~> 1.2)
brakeman (7.0.0)
brakeman (7.0.1)
racc
browser (6.2.0)
brpoplpush-redis_script (0.1.3)
@ -168,9 +168,9 @@ GEM
bigdecimal
rexml
crass (1.0.6)
css_parser (1.21.0)
css_parser (1.21.1)
addressable
csv (3.3.2)
csv (3.3.3)
database_cleaner-active_record (2.2.0)
activerecord (>= 5.a)
database_cleaner-core (~> 2.0.0)
@ -194,7 +194,7 @@ GEM
devise_pam_authenticatable2 (9.2.0)
devise (>= 4.0.0)
rpam2 (~> 4.0)
diff-lcs (1.6.0)
diff-lcs (1.6.1)
discard (1.4.0)
activerecord (>= 4.2, < 9.0)
docile (1.4.1)
@ -222,7 +222,8 @@ GEM
erubi (1.13.1)
et-orbi (1.2.11)
tzinfo
excon (1.2.3)
excon (1.2.5)
logger
fabrication (2.31.0)
faker (3.5.1)
i18n (>= 1.8.11, < 2)
@ -256,7 +257,7 @@ GEM
fog-json (1.2.0)
fog-core
multi_json (~> 1.10)
fog-openstack (1.1.4)
fog-openstack (1.1.5)
fog-core (~> 2.1)
fog-json (>= 1.0)
formatador (1.1.0)
@ -265,8 +266,10 @@ GEM
raabro (~> 1.4)
globalid (1.2.1)
activesupport (>= 6.1)
google-protobuf (3.25.6)
googleapis-common-protos-types (1.18.0)
google-protobuf (4.30.2)
bigdecimal
rake (>= 13)
googleapis-common-protos-types (1.19.0)
google-protobuf (>= 3.18, < 5.a)
haml (6.3.0)
temple (>= 0.8.2)
@ -277,7 +280,7 @@ GEM
activesupport (>= 5.1)
haml (>= 4.0.6)
railties (>= 5.1)
haml_lint (0.61.0)
haml_lint (0.61.1)
haml (>= 5.0)
parallel (~> 1.10)
rainbow
@ -302,7 +305,8 @@ GEM
domain_name (~> 0.5)
http-form_data (2.3.0)
http_accept_language (2.1.1)
httpclient (2.8.3)
httpclient (2.9.0)
mutex_m
httplog (1.7.0)
rack (>= 2.0)
rainbow (>= 2.0.0)
@ -324,7 +328,7 @@ GEM
activesupport (>= 3.0)
nokogiri (>= 1.6)
io-console (0.8.0)
irb (1.15.1)
irb (1.15.2)
pp (>= 0.6.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
@ -378,7 +382,7 @@ GEM
mime-types
terrapin (>= 0.6.0, < 2.0)
language_server-protocol (3.17.0.4)
launchy (3.1.0)
launchy (3.1.1)
addressable (~> 2.8)
childprocess (~> 5.0)
logger (~> 1.6)
@ -391,10 +395,16 @@ GEM
rexml
link_header (0.0.8)
lint_roller (1.1.0)
llhttp-ffi (0.5.0)
linzer (0.6.3)
openssl (~> 3.0, >= 3.0.0)
rack (>= 2.2, < 4.0)
starry (~> 0.2)
stringio (~> 3.1, >= 3.1.2)
uri (~> 1.0, >= 1.0.2)
llhttp-ffi (0.5.1)
ffi-compiler (~> 1.0)
rake (~> 13.0)
logger (1.6.6)
logger (1.7.0)
lograge (0.14.0)
actionpack (>= 4)
activesupport (>= 4)
@ -413,13 +423,13 @@ GEM
redis (>= 3.0.5)
matrix (0.4.2)
memory_profiler (1.1.0)
mime-types (3.6.0)
mime-types (3.6.2)
logger
mime-types-data (~> 3.2015)
mime-types-data (3.2025.0220)
mime-types-data (3.2025.0402)
mini_mime (1.1.5)
mini_portile2 (2.8.8)
minitest (5.25.4)
minitest (5.25.5)
msgpack (1.8.0)
multi_json (1.15.0)
mutex_m (0.3.0)
@ -436,7 +446,7 @@ GEM
net-smtp (0.5.1)
net-protocol
nio4r (2.7.4)
nokogiri (1.18.3)
nokogiri (1.18.7)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
oj (3.16.10)
@ -559,7 +569,7 @@ GEM
opentelemetry-instrumentation-redis (0.26.1)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-sidekiq (0.26.0)
opentelemetry-instrumentation-sidekiq (0.26.1)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-registry (0.4.0)
@ -576,14 +586,14 @@ GEM
ox (2.14.22)
bigdecimal (>= 3.0)
parallel (1.26.3)
parser (3.3.7.1)
parser (3.3.7.4)
ast (~> 2.4.1)
racc
parslet (2.0.0)
pastel (0.8.0)
tty-color (~> 0.5)
pg (1.5.9)
pghero (3.6.1)
pghero (3.6.2)
activerecord (>= 6.1)
pp (0.6.2)
prettyprint
@ -596,6 +606,7 @@ GEM
net-smtp
premailer (~> 1.7, >= 1.7.9)
prettyprint (0.2.0)
prism (1.4.0)
prometheus_exporter (2.2.0)
webrick
propshaft (1.1.0)
@ -677,7 +688,7 @@ GEM
link_header (~> 0.0, >= 0.0.8)
rdf-normalize (0.7.0)
rdf (~> 3.3)
rdoc (6.12.0)
rdoc (6.13.1)
psych (>= 4.0.0)
redcarpet (3.6.1)
redis (4.8.1)
@ -729,7 +740,7 @@ GEM
rspec-mocks (~> 3.0)
sidekiq (>= 5, < 9)
rspec-support (3.13.2)
rubocop (1.73.2)
rubocop (1.75.2)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@ -737,26 +748,27 @@ GEM
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.38.0, < 2.0)
rubocop-ast (>= 1.44.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.38.1)
parser (>= 3.3.1.0)
rubocop-ast (1.44.0)
parser (>= 3.3.7.2)
prism (~> 1.4)
rubocop-capybara (2.22.1)
lint_roller (~> 1.1)
rubocop (~> 1.72, >= 1.72.1)
rubocop-i18n (3.2.3)
lint_roller (~> 1.1)
rubocop (>= 1.72.1)
rubocop-performance (1.24.0)
rubocop-performance (1.25.0)
lint_roller (~> 1.1)
rubocop (>= 1.72.1, < 2.0)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.38.0, < 2.0)
rubocop-rails (2.30.3)
rubocop-rails (2.31.0)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
rubocop (>= 1.72.1, < 2.0)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.38.0, < 2.0)
rubocop-rspec (3.5.0)
lint_roller (~> 1.1)
@ -785,7 +797,7 @@ GEM
activerecord (>= 4.0.0)
railties (>= 4.0.0)
securerandom (0.4.1)
selenium-webdriver (4.29.1)
selenium-webdriver (4.30.1)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
@ -823,10 +835,12 @@ GEM
simplecov-lcov (0.8.0)
simplecov_json_formatter (0.1.4)
stackprof (0.2.27)
starry (0.2.0)
base64
stoplight (4.1.1)
redlock (~> 1.0)
stringio (3.1.5)
strong_migrations (2.2.0)
stringio (3.1.6)
strong_migrations (2.3.0)
activerecord (>= 7)
swd (2.0.3)
activesupport (>= 3)
@ -837,7 +851,7 @@ GEM
temple (0.10.3)
terminal-table (4.0.0)
unicode-display_width (>= 1.1.1, < 4)
terrapin (1.0.1)
terrapin (1.1.0)
climate_control
test-prof (1.4.4)
thor (1.3.2)
@ -862,7 +876,7 @@ GEM
unf (~> 0.1.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
tzinfo-data (1.2025.1)
tzinfo-data (1.2025.2)
tzinfo (>= 1.0.0)
unf (0.1.4)
unf_ext
@ -917,6 +931,7 @@ DEPENDENCIES
active_model_serializers (~> 0.10)
addressable (~> 2.8)
annotaterb (~> 4.13)
aws-sdk-core (< 3.216.0)
aws-sdk-s3 (~> 1.123)
better_errors (~> 2.9)
binding_of_caller (~> 1.0)
@ -973,6 +988,7 @@ DEPENDENCIES
letter_opener (~> 1.8)
letter_opener_web (~> 3.0)
link_header (~> 0.0)
linzer (~> 0.6.1)
lograge (~> 0.12)
mail (~> 2.8)
mario-redis-lock (~> 1.2)
@ -1069,4 +1085,4 @@ RUBY VERSION
ruby 3.4.1p0
BUNDLED WITH
2.6.5
2.6.7

View file

@ -0,0 +1,20 @@
# frozen_string_literal: true
class Admin::Fasp::Debug::CallbacksController < Admin::BaseController
def index
authorize [:admin, :fasp, :provider], :update?
@callbacks = Fasp::DebugCallback
.includes(:fasp_provider)
.order(created_at: :desc)
end
def destroy
authorize [:admin, :fasp, :provider], :update?
callback = Fasp::DebugCallback.find(params[:id])
callback.destroy
redirect_to admin_fasp_debug_callbacks_path
end
end

View file

@ -0,0 +1,19 @@
# frozen_string_literal: true
class Admin::Fasp::DebugCallsController < Admin::BaseController
before_action :set_provider
def create
authorize [:admin, @provider], :update?
@provider.perform_debug_call
redirect_to admin_fasp_providers_path
end
private
def set_provider
@provider = Fasp::Provider.find(params[:provider_id])
end
end

View file

@ -0,0 +1,47 @@
# frozen_string_literal: true
class Admin::Fasp::ProvidersController < Admin::BaseController
before_action :set_provider, only: [:show, :edit, :update, :destroy]
def index
authorize [:admin, :fasp, :provider], :index?
@providers = Fasp::Provider.order(confirmed: :asc, created_at: :desc)
end
def show
authorize [:admin, @provider], :show?
end
def edit
authorize [:admin, @provider], :update?
end
def update
authorize [:admin, @provider], :update?
if @provider.update(provider_params)
redirect_to admin_fasp_providers_path
else
render :edit
end
end
def destroy
authorize [:admin, @provider], :destroy?
@provider.destroy
redirect_to admin_fasp_providers_path
end
private
def provider_params
params.expect(fasp_provider: [capabilities_attributes: {}])
end
def set_provider
@provider = Fasp::Provider.find(params[:id])
end
end

View file

@ -0,0 +1,23 @@
# frozen_string_literal: true
class Admin::Fasp::RegistrationsController < Admin::BaseController
before_action :set_provider
def new
authorize [:admin, @provider], :create?
end
def create
authorize [:admin, @provider], :create?
@provider.update_info!(confirm: true)
redirect_to edit_admin_fasp_provider_path(@provider)
end
private
def set_provider
@provider = Fasp::Provider.find(params[:provider_id])
end
end

View file

@ -0,0 +1,81 @@
# frozen_string_literal: true
class Api::Fasp::BaseController < ApplicationController
class Error < ::StandardError; end
DIGEST_PATTERN = /sha-256=:(.*?):/
KEYID_PATTERN = /keyid="(.*?)"/
attr_reader :current_provider
skip_forgery_protection
before_action :check_fasp_enabled
before_action :require_authentication
after_action :sign_response
private
def require_authentication
validate_content_digest!
validate_signature!
rescue Error, Linzer::Error, ActiveRecord::RecordNotFound => e
logger.debug("FASP Authentication error: #{e}")
authentication_error
end
def authentication_error
respond_to do |format|
format.json { head 401 }
end
end
def validate_content_digest!
content_digest_header = request.headers['content-digest']
raise Error, 'content-digest missing' if content_digest_header.blank?
digest_received = content_digest_header.match(DIGEST_PATTERN)[1]
digest_computed = OpenSSL::Digest.base64digest('sha256', request.body&.string || '')
raise Error, 'content-digest does not match' if digest_received != digest_computed
end
def validate_signature!
signature_input = request.headers['signature-input']&.encode('UTF-8')
raise Error, 'signature-input is missing' if signature_input.blank?
keyid = signature_input.match(KEYID_PATTERN)[1]
provider = Fasp::Provider.find(keyid)
linzer_request = Linzer.new_request(
request.method,
request.original_url,
{},
{
'content-digest' => request.headers['content-digest'],
'signature-input' => signature_input,
'signature' => request.headers['signature'],
}
)
message = Linzer::Message.new(linzer_request)
key = Linzer.new_ed25519_public_key(provider.provider_public_key_pem, keyid)
signature = Linzer::Signature.build(message.headers)
Linzer.verify(key, message, signature)
@current_provider = provider
end
def sign_response
response.headers['content-digest'] = "sha-256=:#{OpenSSL::Digest.base64digest('sha256', response.body || '')}:"
linzer_response = Linzer.new_response(response.body, response.status, { 'content-digest' => response.headers['content-digest'] })
message = Linzer::Message.new(linzer_response)
key = Linzer.new_ed25519_key(current_provider.server_private_key_pem)
signature = Linzer.sign(key, message, %w(@status content-digest))
response.headers.merge!(signature.to_h)
end
def check_fasp_enabled
raise ActionController::RoutingError unless Mastodon::Feature.fasp_enabled?
end
end

View file

@ -0,0 +1,15 @@
# frozen_string_literal: true
class Api::Fasp::Debug::V0::Callback::ResponsesController < Api::Fasp::BaseController
def create
Fasp::DebugCallback.create(
fasp_provider: current_provider,
ip: request.remote_ip,
request_body: request.raw_post
)
respond_to do |format|
format.json { head 201 }
end
end
end

View file

@ -0,0 +1,26 @@
# frozen_string_literal: true
class Api::Fasp::RegistrationsController < Api::Fasp::BaseController
skip_before_action :require_authentication
def create
@current_provider = Fasp::Provider.create!(
name: params[:name],
base_url: params[:baseUrl],
remote_identifier: params[:serverId],
provider_public_key_base64: params[:publicKey]
)
render json: registration_confirmation
end
private
def registration_confirmation
{
faspId: current_provider.id.to_s,
publicKey: current_provider.server_public_key_base64,
registrationCompletionUri: new_admin_fasp_provider_registration_url(current_provider),
}
end
end

View file

@ -1,6 +1,10 @@
# frozen_string_literal: true
class Api::V1::Accounts::IdentityProofsController < Api::BaseController
include DeprecationConcern
deprecate_api '2022-03-30'
before_action :require_user!
before_action :set_account

View file

@ -119,7 +119,7 @@ class Api::V1::AccountsController < Api::BaseController
end
def account_params
params.permit(:username, :email, :password, :agreement, :locale, :reason, :time_zone, :invite_code)
params.permit(:username, :email, :password, :agreement, :locale, :reason, :time_zone, :invite_code, :date_of_birth)
end
def invite

View file

@ -1,6 +1,10 @@
# frozen_string_literal: true
class Api::V1::FiltersController < Api::BaseController
include DeprecationConcern
deprecate_api '2022-11-14'
before_action -> { doorkeeper_authorize! :read, :'read:filters' }, only: [:index, :show]
before_action -> { doorkeeper_authorize! :write, :'write:filters' }, except: [:index, :show]
before_action :require_user!

View file

@ -1,15 +1,9 @@
# frozen_string_literal: true
class Api::V1::InstancesController < Api::BaseController
skip_before_action :require_authenticated_user!, unless: :limited_federation_mode?
skip_around_action :set_locale
class Api::V1::InstancesController < Api::V2::InstancesController
include DeprecationConcern
vary_by ''
# Override `current_user` to avoid reading session cookies unless in limited federation mode
def current_user
super if limited_federation_mode?
end
deprecate_api '2022-11-14'
def show
cache_even_if_authenticated!

View file

@ -2,6 +2,9 @@
class Api::V1::SuggestionsController < Api::BaseController
include Authorization
include DeprecationConcern
deprecate_api '2021-05-16'
before_action -> { doorkeeper_authorize! :read, :'read:accounts' }, only: :index
before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, except: :index

View file

@ -1,12 +1,16 @@
# frozen_string_literal: true
class Api::V1::Trends::TagsController < Api::BaseController
include DeprecationConcern
before_action :set_tags
after_action :insert_pagination_headers
DEFAULT_TAGS_LIMIT = 10
deprecate_api '2022-03-30', only: :index, if: -> { request.path == '/api/v1/trends' }
def index
cache_if_unauthenticated!
render json: @tags, each_serializer: REST::TagSerializer, relationships: TagRelationshipsPresenter.new(@tags, current_user&.account_id)

View file

@ -1,6 +1,16 @@
# frozen_string_literal: true
class Api::V2::InstancesController < Api::V1::InstancesController
class Api::V2::InstancesController < Api::BaseController
skip_before_action :require_authenticated_user!, unless: :limited_federation_mode?
skip_around_action :set_locale
vary_by ''
# Override `current_user` to avoid reading session cookies unless in limited federation mode
def current_user
super if limited_federation_mode?
end
def show
cache_even_if_authenticated!
render_with_cache json: InstancePresenter.new, serializer: REST::InstanceSerializer, root: 'instance'

View file

@ -62,7 +62,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
def configure_sign_up_params
devise_parameter_sanitizer.permit(:sign_up) do |user_params|
user_params.permit({ account_attributes: [:username, :display_name], invite_request_attributes: [:text] }, :email, :password, :password_confirmation, :invite_code, :agreement, :website, :confirm_password)
user_params.permit({ account_attributes: [:username, :display_name], invite_request_attributes: [:text] }, :email, :password, :password_confirmation, :invite_code, :agreement, :website, :confirm_password, :date_of_birth)
end
end

View file

@ -9,13 +9,15 @@ class BackupsController < ApplicationController
before_action :authenticate_user!
before_action :set_backup
BACKUP_LINK_TIMEOUT = 1.hour.freeze
def download
case Paperclip::Attachment.default_options[:storage]
when :s3, :azure
redirect_to @backup.dump.expiring_url(10), allow_other_host: true
redirect_to @backup.dump.expiring_url(BACKUP_LINK_TIMEOUT.to_i), allow_other_host: true
when :fog
if Paperclip::Attachment.default_options.dig(:fog_credentials, :openstack_temp_url_key).present?
redirect_to @backup.dump.expiring_url(Time.now.utc + 10), allow_other_host: true
redirect_to @backup.dump.expiring_url(BACKUP_LINK_TIMEOUT.from_now), allow_other_host: true
else
redirect_to full_asset_url(@backup.dump.url), allow_other_host: true
end

View file

@ -0,0 +1,17 @@
# frozen_string_literal: true
module DeprecationConcern
extend ActiveSupport::Concern
class_methods do
def deprecate_api(date, sunset: nil, **kwargs)
deprecation_timestamp = "@#{date.to_datetime.to_i}"
sunset = sunset&.to_date&.httpdate
before_action(**kwargs) do
response.headers['Deprecation'] = deprecation_timestamp
response.headers['Sunset'] = sunset if sunset
end
end
end
end

View file

@ -10,8 +10,6 @@ module SignatureVerification
EXPIRATION_WINDOW_LIMIT = 12.hours
CLOCK_SKEW_MARGIN = 1.hour
class SignatureVerificationError < StandardError; end
def require_account_signature!
render json: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_account
end
@ -34,7 +32,7 @@ module SignatureVerification
def signature_key_id
signature_params['keyId']
rescue SignatureVerificationError
rescue Mastodon::SignatureVerificationError
nil
end
@ -45,17 +43,17 @@ module SignatureVerification
def signed_request_actor
return @signed_request_actor if defined?(@signed_request_actor)
raise SignatureVerificationError, 'Request not signed' unless signed_request?
raise SignatureVerificationError, 'Incompatible request signature. keyId and signature are required' if missing_required_signature_parameters?
raise SignatureVerificationError, 'Unsupported signature algorithm (only rsa-sha256 and hs2019 are supported)' unless %w(rsa-sha256 hs2019).include?(signature_algorithm)
raise SignatureVerificationError, 'Signed request date outside acceptable time window' unless matches_time_window?
raise Mastodon::SignatureVerificationError, 'Request not signed' unless signed_request?
raise Mastodon::SignatureVerificationError, 'Incompatible request signature. keyId and signature are required' if missing_required_signature_parameters?
raise Mastodon::SignatureVerificationError, 'Unsupported signature algorithm (only rsa-sha256 and hs2019 are supported)' unless %w(rsa-sha256 hs2019).include?(signature_algorithm)
raise Mastodon::SignatureVerificationError, 'Signed request date outside acceptable time window' unless matches_time_window?
verify_signature_strength!
verify_body_digest!
actor = actor_from_key_id(signature_params['keyId'])
raise SignatureVerificationError, "Public key not found for key #{signature_params['keyId']}" if actor.nil?
raise Mastodon::SignatureVerificationError, "Public key not found for key #{signature_params['keyId']}" if actor.nil?
signature = Base64.decode64(signature_params['signature'])
compare_signed_string = build_signed_string(include_query_string: true)
@ -68,7 +66,7 @@ module SignatureVerification
actor = stoplight_wrapper.run { actor_refresh_key!(actor) }
raise SignatureVerificationError, "Could not refresh public key #{signature_params['keyId']}" if actor.nil?
raise Mastodon::SignatureVerificationError, "Could not refresh public key #{signature_params['keyId']}" if actor.nil?
compare_signed_string = build_signed_string(include_query_string: true)
return actor unless verify_signature(actor, signature, compare_signed_string).nil?
@ -78,7 +76,7 @@ module SignatureVerification
return actor unless verify_signature(actor, signature, compare_signed_string).nil?
fail_with! "Verification failed for #{actor.to_log_human_identifier} #{actor.uri} using rsa-sha256 (RSASSA-PKCS1-v1_5 with SHA-256)", signed_string: compare_signed_string, signature: signature_params['signature']
rescue SignatureVerificationError => e
rescue Mastodon::SignatureVerificationError => e
fail_with! e.message
rescue *Mastodon::HTTP_CONNECTION_ERRORS => e
fail_with! "Failed to fetch remote data: #{e.message}"
@ -104,7 +102,7 @@ module SignatureVerification
def signature_params
@signature_params ||= SignatureParser.parse(request.headers['Signature'])
rescue SignatureParser::ParsingError
raise SignatureVerificationError, 'Error parsing signature parameters'
raise Mastodon::SignatureVerificationError, 'Error parsing signature parameters'
end
def signature_algorithm
@ -116,31 +114,31 @@ module SignatureVerification
end
def verify_signature_strength!
raise SignatureVerificationError, 'Mastodon requires the Date header or (created) pseudo-header to be signed' unless signed_headers.include?('date') || signed_headers.include?('(created)')
raise SignatureVerificationError, 'Mastodon requires the Digest header or (request-target) pseudo-header to be signed' unless signed_headers.include?(HttpSignatureDraft::REQUEST_TARGET) || signed_headers.include?('digest')
raise SignatureVerificationError, 'Mastodon requires the Host header to be signed when doing a GET request' if request.get? && !signed_headers.include?('host')
raise SignatureVerificationError, 'Mastodon requires the Digest header to be signed when doing a POST request' if request.post? && !signed_headers.include?('digest')
raise Mastodon::SignatureVerificationError, 'Mastodon requires the Date header or (created) pseudo-header to be signed' unless signed_headers.include?('date') || signed_headers.include?('(created)')
raise Mastodon::SignatureVerificationError, 'Mastodon requires the Digest header or (request-target) pseudo-header to be signed' unless signed_headers.include?(HttpSignatureDraft::REQUEST_TARGET) || signed_headers.include?('digest')
raise Mastodon::SignatureVerificationError, 'Mastodon requires the Host header to be signed when doing a GET request' if request.get? && !signed_headers.include?('host')
raise Mastodon::SignatureVerificationError, 'Mastodon requires the Digest header to be signed when doing a POST request' if request.post? && !signed_headers.include?('digest')
end
def verify_body_digest!
return unless signed_headers.include?('digest')
raise SignatureVerificationError, 'Digest header missing' unless request.headers.key?('Digest')
raise Mastodon::SignatureVerificationError, 'Digest header missing' unless request.headers.key?('Digest')
digests = request.headers['Digest'].split(',').map { |digest| digest.split('=', 2) }.map { |key, value| [key.downcase, value] }
sha256 = digests.assoc('sha-256')
raise SignatureVerificationError, "Mastodon only supports SHA-256 in Digest header. Offered algorithms: #{digests.map(&:first).join(', ')}" if sha256.nil?
raise Mastodon::SignatureVerificationError, "Mastodon only supports SHA-256 in Digest header. Offered algorithms: #{digests.map(&:first).join(', ')}" if sha256.nil?
return if body_digest == sha256[1]
digest_size = begin
Base64.strict_decode64(sha256[1].strip).length
rescue ArgumentError
raise SignatureVerificationError, "Invalid Digest value. The provided Digest value is not a valid base64 string. Given digest: #{sha256[1]}"
raise Mastodon::SignatureVerificationError, "Invalid Digest value. The provided Digest value is not a valid base64 string. Given digest: #{sha256[1]}"
end
raise SignatureVerificationError, "Invalid Digest value. The provided Digest value is not a SHA-256 digest. Given digest: #{sha256[1]}" if digest_size != 32
raise Mastodon::SignatureVerificationError, "Invalid Digest value. The provided Digest value is not a SHA-256 digest. Given digest: #{sha256[1]}" if digest_size != 32
raise SignatureVerificationError, "Invalid Digest value. Computed SHA-256 digest: #{body_digest}; given: #{sha256[1]}"
raise Mastodon::SignatureVerificationError, "Invalid Digest value. Computed SHA-256 digest: #{body_digest}; given: #{sha256[1]}"
end
def verify_signature(actor, signature, compare_signed_string)
@ -165,13 +163,13 @@ module SignatureVerification
"#{HttpSignatureDraft::REQUEST_TARGET}: #{request.method.downcase} #{request.path}"
end
when '(created)'
raise SignatureVerificationError, 'Invalid pseudo-header (created) for rsa-sha256' unless signature_algorithm == 'hs2019'
raise SignatureVerificationError, 'Pseudo-header (created) used but corresponding argument missing' if signature_params['created'].blank?
raise Mastodon::SignatureVerificationError, 'Invalid pseudo-header (created) for rsa-sha256' unless signature_algorithm == 'hs2019'
raise Mastodon::SignatureVerificationError, 'Pseudo-header (created) used but corresponding argument missing' if signature_params['created'].blank?
"(created): #{signature_params['created']}"
when '(expires)'
raise SignatureVerificationError, 'Invalid pseudo-header (expires) for rsa-sha256' unless signature_algorithm == 'hs2019'
raise SignatureVerificationError, 'Pseudo-header (expires) used but corresponding argument missing' if signature_params['expires'].blank?
raise Mastodon::SignatureVerificationError, 'Invalid pseudo-header (expires) for rsa-sha256' unless signature_algorithm == 'hs2019'
raise Mastodon::SignatureVerificationError, 'Pseudo-header (expires) used but corresponding argument missing' if signature_params['expires'].blank?
"(expires): #{signature_params['expires']}"
else
@ -193,7 +191,7 @@ module SignatureVerification
expires_time = Time.at(signature_params['expires'].to_i).utc if signature_params['expires'].present?
rescue ArgumentError => e
raise SignatureVerificationError, "Invalid Date header: #{e.message}"
raise Mastodon::SignatureVerificationError, "Invalid Date header: #{e.message}"
end
expires_time ||= created_time + 5.minutes unless created_time.nil?
@ -233,9 +231,9 @@ module SignatureVerification
account
end
rescue Mastodon::PrivateNetworkAddressError => e
raise SignatureVerificationError, "Requests to private network addresses are disallowed (tried to query #{e.host})"
raise Mastodon::SignatureVerificationError, "Requests to private network addresses are disallowed (tried to query #{e.host})"
rescue Mastodon::HostValidationError, ActivityPub::FetchRemoteActorService::Error, ActivityPub::FetchRemoteKeyService::Error, Webfinger::Error => e
raise SignatureVerificationError, e.message
raise Mastodon::SignatureVerificationError, e.message
end
def stoplight_wrapper
@ -251,8 +249,8 @@ module SignatureVerification
ActivityPub::FetchRemoteActorService.new.call(actor.uri, only_key: true, suppress_errors: false)
rescue Mastodon::PrivateNetworkAddressError => e
raise SignatureVerificationError, "Requests to private network addresses are disallowed (tried to query #{e.host})"
raise Mastodon::SignatureVerificationError, "Requests to private network addresses are disallowed (tried to query #{e.host})"
rescue Mastodon::HostValidationError, ActivityPub::FetchRemoteActorService::Error, Webfinger::Error => e
raise SignatureVerificationError, e.message
raise Mastodon::SignatureVerificationError, e.message
end
end

View file

@ -2,11 +2,18 @@
module Admin::Trends::StatusesHelper
def one_line_preview(status)
text = if status.local?
status.text.split("\n").first
else
Nokogiri::HTML5(status.text).css('html > body > *').first&.text
end
text = begin
if status.local?
status.text.split("\n").first
else
Nokogiri::HTML5(status.text).css('html > body > *').first&.text
end
rescue ArgumentError
# This can happen if one of the Nokogumbo limits is encountered
# Unfortunately, it does not use a more precise error class
# nor allows more graceful handling
''
end
return '' if text.blank?

View file

@ -0,0 +1,31 @@
# frozen_string_literal: true
class DateOfBirthInput < SimpleForm::Inputs::Base
OPTIONS = [
{ autocomplete: 'bday-day', maxlength: 2, pattern: '[0-9]+', placeholder: 'DD' }.freeze,
{ autocomplete: 'bday-month', maxlength: 2, pattern: '[0-9]+', placeholder: 'MM' }.freeze,
{ autocomplete: 'bday-year', maxlength: 4, pattern: '[0-9]+', placeholder: 'YYYY' }.freeze,
].freeze
def input(wrapper_options = nil)
merged_input_options = merge_wrapper_options(input_html_options, wrapper_options)
merged_input_options[:inputmode] = 'numeric'
values = (object.public_send(attribute_name) || '').split('.')
safe_join(Array.new(3) do |index|
options = merged_input_options.merge(OPTIONS[index]).merge id: generate_id(index), 'aria-label': I18n.t("simple_form.labels.user.date_of_birth_#{index + 1}i"), value: values[index]
@builder.text_field("#{attribute_name}(#{index + 1}i)", options)
end)
end
def label_target
"#{attribute_name}_1i"
end
private
def generate_id(index)
"#{object_name}_#{attribute_name}_#{index + 1}i"
end
end

View file

@ -68,7 +68,7 @@ function loaded() {
if (id) message = localeData[id];
if (!message) message = defaultMessage as string;
message ??= defaultMessage as string;
const messageFormat = new IntlMessageFormat(message, locale);
return messageFormat.format(values) as string;

View file

@ -1,14 +1,11 @@
import { defineMessages } from 'react-intl';
import type { MessageDescriptor } from 'react-intl';
import { createAction } from '@reduxjs/toolkit';
import { AxiosError } from 'axios';
import type { AxiosResponse } from 'axios';
interface Alert {
title: string | MessageDescriptor;
message: string | MessageDescriptor;
values?: Record<string, string | number | Date>;
}
import type { Alert } from 'mastodon/models/alert';
interface ApiErrorResponse {
error?: string;
@ -30,24 +27,13 @@ const messages = defineMessages({
},
});
export const ALERT_SHOW = 'ALERT_SHOW';
export const ALERT_DISMISS = 'ALERT_DISMISS';
export const ALERT_CLEAR = 'ALERT_CLEAR';
export const ALERT_NOOP = 'ALERT_NOOP';
export const dismissAlert = createAction<{ key: number }>('alerts/dismiss');
export const dismissAlert = (alert: Alert) => ({
type: ALERT_DISMISS,
alert,
});
export const clearAlerts = createAction('alerts/clear');
export const clearAlert = () => ({
type: ALERT_CLEAR,
});
export const showAlert = createAction<Omit<Alert, 'key'>>('alerts/show');
export const showAlert = (alert: Alert) => ({
type: ALERT_SHOW,
alert,
});
const ignoreAlert = createAction('alerts/ignore');
export const showAlertForError = (error: unknown, skipNotFound = false) => {
if (error instanceof AxiosError && error.response) {
@ -56,7 +42,7 @@ export const showAlertForError = (error: unknown, skipNotFound = false) => {
// Skip these errors as they are reflected in the UI
if (skipNotFound && (status === 404 || status === 410)) {
return { type: ALERT_NOOP };
return ignoreAlert();
}
// Rate limit errors
@ -76,9 +62,9 @@ export const showAlertForError = (error: unknown, skipNotFound = false) => {
});
}
// An aborted request, e.g. due to reloading the browser window, it not really error
// An aborted request, e.g. due to reloading the browser window, is not really an error
if (error instanceof AxiosError && error.code === AxiosError.ECONNABORTED) {
return { type: ALERT_NOOP };
return ignoreAlert();
}
console.error(error);

View file

@ -12,14 +12,6 @@ export const DOMAIN_BLOCK_FAIL = 'DOMAIN_BLOCK_FAIL';
export const DOMAIN_UNBLOCK_REQUEST = 'DOMAIN_UNBLOCK_REQUEST';
export const DOMAIN_UNBLOCK_FAIL = 'DOMAIN_UNBLOCK_FAIL';
export const DOMAIN_BLOCKS_FETCH_REQUEST = 'DOMAIN_BLOCKS_FETCH_REQUEST';
export const DOMAIN_BLOCKS_FETCH_SUCCESS = 'DOMAIN_BLOCKS_FETCH_SUCCESS';
export const DOMAIN_BLOCKS_FETCH_FAIL = 'DOMAIN_BLOCKS_FETCH_FAIL';
export const DOMAIN_BLOCKS_EXPAND_REQUEST = 'DOMAIN_BLOCKS_EXPAND_REQUEST';
export const DOMAIN_BLOCKS_EXPAND_SUCCESS = 'DOMAIN_BLOCKS_EXPAND_SUCCESS';
export const DOMAIN_BLOCKS_EXPAND_FAIL = 'DOMAIN_BLOCKS_EXPAND_FAIL';
export function blockDomain(domain) {
return (dispatch, getState) => {
dispatch(blockDomainRequest(domain));
@ -79,80 +71,6 @@ export function unblockDomainFail(domain, error) {
};
}
export function fetchDomainBlocks() {
return (dispatch) => {
dispatch(fetchDomainBlocksRequest());
api().get('/api/v1/domain_blocks').then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(fetchDomainBlocksSuccess(response.data, next ? next.uri : null));
}).catch(err => {
dispatch(fetchDomainBlocksFail(err));
});
};
}
export function fetchDomainBlocksRequest() {
return {
type: DOMAIN_BLOCKS_FETCH_REQUEST,
};
}
export function fetchDomainBlocksSuccess(domains, next) {
return {
type: DOMAIN_BLOCKS_FETCH_SUCCESS,
domains,
next,
};
}
export function fetchDomainBlocksFail(error) {
return {
type: DOMAIN_BLOCKS_FETCH_FAIL,
error,
};
}
export function expandDomainBlocks() {
return (dispatch, getState) => {
const url = getState().getIn(['domain_lists', 'blocks', 'next']);
if (!url) {
return;
}
dispatch(expandDomainBlocksRequest());
api().get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(expandDomainBlocksSuccess(response.data, next ? next.uri : null));
}).catch(err => {
dispatch(expandDomainBlocksFail(err));
});
};
}
export function expandDomainBlocksRequest() {
return {
type: DOMAIN_BLOCKS_EXPAND_REQUEST,
};
}
export function expandDomainBlocksSuccess(domains, next) {
return {
type: DOMAIN_BLOCKS_EXPAND_SUCCESS,
domains,
next,
};
}
export function expandDomainBlocksFail(error) {
return {
type: DOMAIN_BLOCKS_EXPAND_FAIL,
error,
};
}
export const initDomainBlockModal = account => dispatch => dispatch(openModal({
modalType: 'DOMAIN_BLOCK',
modalProps: {

View file

@ -71,7 +71,7 @@ export function importFetchedStatuses(statuses) {
}
if (status.poll?.id) {
pushUnique(polls, createPollFromServerJSON(status.poll, getState().polls.get(status.poll.id)));
pushUnique(polls, createPollFromServerJSON(status.poll, getState().polls[status.poll.id]));
}
if (status.card) {

View file

@ -15,7 +15,7 @@ export const importFetchedPoll = createAppAsyncThunk(
dispatch(
importPolls({
polls: [createPollFromServerJSON(poll, getState().polls.get(poll.id))],
polls: [createPollFromServerJSON(poll, getState().polls[poll.id])],
}),
);
},

View file

@ -1,4 +1,9 @@
import type { AxiosResponse, Method, RawAxiosRequestHeaders } from 'axios';
import type {
AxiosError,
AxiosResponse,
Method,
RawAxiosRequestHeaders,
} from 'axios';
import axios from 'axios';
import LinkHeader from 'http-link-header';
@ -41,7 +46,7 @@ const authorizationTokenFromInitialState = (): RawAxiosRequestHeaders => {
// eslint-disable-next-line import/no-default-export
export default function api(withAuthorization = true) {
return axios.create({
const instance = axios.create({
transitional: {
clarifyTimeoutError: true,
},
@ -60,6 +65,22 @@ export default function api(withAuthorization = true) {
},
],
});
instance.interceptors.response.use(
(response: AxiosResponse) => {
if (response.headers.deprecation) {
console.warn(
`Deprecated request: ${response.config.method} ${response.config.url}`,
);
}
return response;
},
(error: AxiosError) => {
return Promise.reject(error);
},
);
return instance;
}
type RequestParamsOrData = Record<string, unknown>;

View file

@ -0,0 +1,13 @@
import api, { getLinks } from 'mastodon/api';
export const apiGetDomainBlocks = async (url?: string) => {
const response = await api().request<string[]>({
method: 'GET',
url: url ?? '/api/v1/domain_blocks',
});
return {
domains: response.data,
links: getLinks(response),
};
};

View file

@ -13,7 +13,7 @@ export interface ApiPollJSON {
expired: boolean;
multiple: boolean;
votes_count: number;
voters_count: number;
voters_count: number | null;
options: ApiPollOptionJSON[];
emojis: ApiCustomEmojiJSON[];

View file

@ -0,0 +1,105 @@
import { useState, useEffect } from 'react';
import { useIntl } from 'react-intl';
import type { IntlShape } from 'react-intl';
import classNames from 'classnames';
import { dismissAlert } from 'mastodon/actions/alerts';
import type {
Alert,
TranslatableString,
TranslatableValues,
} from 'mastodon/models/alert';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
const formatIfNeeded = (
intl: IntlShape,
message: TranslatableString,
values?: TranslatableValues,
) => {
if (typeof message === 'object') {
return intl.formatMessage(message, values);
}
return message;
};
const Alert: React.FC<{
alert: Alert;
dismissAfter: number;
}> = ({
alert: { key, title, message, values, action, onClick },
dismissAfter,
}) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const [active, setActive] = useState(false);
useEffect(() => {
const setActiveTimeout = setTimeout(() => {
setActive(true);
}, 1);
return () => {
clearTimeout(setActiveTimeout);
};
}, []);
useEffect(() => {
const dismissTimeout = setTimeout(() => {
setActive(false);
// Allow CSS transition to finish before removing from the DOM
setTimeout(() => {
dispatch(dismissAlert({ key }));
}, 500);
}, dismissAfter);
return () => {
clearTimeout(dismissTimeout);
};
}, [dispatch, setActive, key, dismissAfter]);
return (
<div
className={classNames('notification-bar', {
'notification-bar-active': active,
})}
>
<div className='notification-bar-wrapper'>
{title && (
<span className='notification-bar-title'>
{formatIfNeeded(intl, title, values)}
</span>
)}
<span className='notification-bar-message'>
{formatIfNeeded(intl, message, values)}
</span>
{action && (
<button className='notification-bar-action' onClick={onClick}>
{formatIfNeeded(intl, action, values)}
</button>
)}
</div>
</div>
);
};
export const AlertsController: React.FC = () => {
const alerts = useAppSelector((state) => state.alerts);
if (alerts.length === 0) {
return null;
}
return (
<div className='notification-list'>
{alerts.map((alert, idx) => (
<Alert key={alert.key} alert={alert} dismissAfter={5000 + idx * 1000} />
))}
</div>
);
};

View file

@ -1,6 +1,6 @@
import { useCallback, useState } from 'react';
import { useEffect, useState } from 'react';
import { TransitionMotion, spring } from 'react-motion';
import { animated, useSpring, config } from '@react-spring/web';
import { reduceMotion } from '../initial_state';
@ -11,53 +11,49 @@ interface Props {
}
export const AnimatedNumber: React.FC<Props> = ({ value }) => {
const [previousValue, setPreviousValue] = useState(value);
const [direction, setDirection] = useState<1 | -1>(1);
const direction = value > previousValue ? -1 : 1;
if (previousValue !== value) {
setPreviousValue(value);
setDirection(value > previousValue ? 1 : -1);
}
const willEnter = useCallback(() => ({ y: -1 * direction }), [direction]);
const willLeave = useCallback(
() => ({ y: spring(1 * direction, { damping: 35, stiffness: 400 }) }),
[direction],
const [styles, api] = useSpring(
() => ({
from: { transform: `translateY(${100 * direction}%)` },
to: { transform: 'translateY(0%)' },
onRest() {
setPreviousValue(value);
},
config: { ...config.gentle, duration: 200 },
immediate: true, // This ensures that the animation is not played when the component is first rendered
}),
[value, previousValue],
);
// When the value changes, start the animation
useEffect(() => {
if (value !== previousValue) {
void api.start({ reset: true });
}
}, [api, previousValue, value]);
if (reduceMotion) {
return <ShortNumber value={value} />;
}
const styles = [
{
key: `${value}`,
data: value,
style: { y: spring(0, { damping: 35, stiffness: 400 }) },
},
];
return (
<TransitionMotion
styles={styles}
willEnter={willEnter}
willLeave={willLeave}
>
{(items) => (
<span className='animated-number'>
{items.map(({ key, data, style }) => (
<span
key={key}
style={{
position:
direction * (style.y ?? 0) > 0 ? 'absolute' : 'static',
transform: `translateY(${(style.y ?? 0) * 100}%)`,
}}
>
<ShortNumber value={data as number} />
</span>
))}
</span>
<span className='animated-number'>
<animated.span style={styles}>
<ShortNumber value={value} />
</animated.span>
{value !== previousValue && (
<animated.span
style={{
...styles,
position: 'absolute',
top: `${-100 * direction}%`, // Adds extra space on top of translateY
}}
role='presentation'
>
<ShortNumber value={previousValue} />
</animated.span>
)}
</TransitionMotion>
</span>
);
};

View file

@ -1,29 +1,36 @@
import PropTypes from 'prop-types';
import { useState, useCallback } from 'react';
import { defineMessages } from 'react-intl';
import classNames from 'classnames';
import { useDispatch } from 'react-redux';
import ContentCopyIcon from '@/material-icons/400-24px/content_copy.svg?react';
import { showAlert } from 'mastodon/actions/alerts';
import { IconButton } from 'mastodon/components/icon_button';
import { useAppDispatch } from 'mastodon/store';
const messages = defineMessages({
copied: { id: 'copy_icon_button.copied', defaultMessage: 'Copied to clipboard' },
copied: {
id: 'copy_icon_button.copied',
defaultMessage: 'Copied to clipboard',
},
});
export const CopyIconButton = ({ title, value, className }) => {
export const CopyIconButton: React.FC<{
title: string;
value: string;
className: string;
}> = ({ title, value, className }) => {
const [copied, setCopied] = useState(false);
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const handleClick = useCallback(() => {
navigator.clipboard.writeText(value);
void navigator.clipboard.writeText(value);
setCopied(true);
dispatch(showAlert({ message: messages.copied }));
setTimeout(() => setCopied(false), 700);
setTimeout(() => {
setCopied(false);
}, 700);
}, [setCopied, value, dispatch]);
return (
@ -31,13 +38,8 @@ export const CopyIconButton = ({ title, value, className }) => {
className={classNames(className, copied ? 'copied' : 'copyable')}
title={title}
onClick={handleClick}
icon=''
iconComponent={ContentCopyIcon}
/>
);
};
CopyIconButton.propTypes = {
title: PropTypes.string,
value: PropTypes.string,
className: PropTypes.string,
};

View file

@ -1,4 +1,4 @@
import React from 'react';
import type React from 'react';
import { FormattedMessage } from 'react-intl';

View file

@ -1,24 +1,15 @@
import { useCallback } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { FormattedMessage } from 'react-intl';
import LockOpenIcon from '@/material-icons/400-24px/lock_open.svg?react';
import { unblockDomain } from 'mastodon/actions/domain_blocks';
import { useAppDispatch } from 'mastodon/store';
import { IconButton } from './icon_button';
const messages = defineMessages({
unblockDomain: {
id: 'account.unblock_domain',
defaultMessage: 'Unblock domain {domain}',
},
});
import { Button } from './button';
export const Domain: React.FC<{
domain: string;
}> = ({ domain }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const handleDomainUnblock = useCallback(() => {
@ -27,20 +18,17 @@ export const Domain: React.FC<{
return (
<div className='domain'>
<div className='domain__wrapper'>
<span className='domain__domain-name'>
<strong>{domain}</strong>
</span>
<div className='domain__domain-name'>
<strong>{domain}</strong>
</div>
<div className='domain__buttons'>
<IconButton
active
icon='unlock'
iconComponent={LockOpenIcon}
title={intl.formatMessage(messages.unblockDomain, { domain })}
onClick={handleDomainUnblock}
<div className='domain__buttons'>
<Button onClick={handleDomainUnblock}>
<FormattedMessage
id='account.unblock_domain_short'
defaultMessage='Unblock'
/>
</div>
</Button>
</div>
</div>
);

View file

@ -6,6 +6,7 @@ import { FormattedMessage, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { openModal } from 'mastodon/actions/modal';
import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
import InlineAccount from 'mastodon/components/inline_account';
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
@ -60,12 +61,12 @@ class EditedTimestamp extends PureComponent {
};
render () {
const { timestamp, intl, statusId } = this.props;
const { timestamp, statusId } = this.props;
return (
<DropdownMenu statusId={statusId} renderItem={this.renderItem} scrollable renderHeader={this.renderHeader} onItemClick={this.handleItemClick}>
<button className='dropdown-menu__text-button'>
<FormattedMessage id='status.edited' defaultMessage='Edited {date}' values={{ date: <span className='animated-number'>{intl.formatDate(timestamp, { month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' })}</span> }} />
<FormattedMessage id='status.edited' defaultMessage='Edited {date}' values={{ date: <FormattedDateWrapper className='animated-number' value={timestamp} month='short' day='2-digit' hour='2-digit' minute='2-digit' /> }} />
</button>
</DropdownMenu>
);

View file

@ -0,0 +1,26 @@
import type { ComponentProps } from 'react';
import { FormattedDate } from 'react-intl';
export const FormattedDateWrapper = (
props: ComponentProps<typeof FormattedDate> & { className?: string },
) => (
<FormattedDate {...props}>
{(date) => (
<time dateTime={tryIsoString(props.value)} className={props.className}>
{date}
</time>
)}
</FormattedDate>
);
const tryIsoString = (date?: string | number | Date): string => {
if (!date) {
return '';
}
try {
return new Date(date).toISOString();
} catch {
return date.toString();
}
};

View file

@ -12,6 +12,7 @@ import { debounce } from 'lodash';
import { AltTextBadge } from 'mastodon/components/alt_text_badge';
import { Blurhash } from 'mastodon/components/blurhash';
import { SpoilerButton } from 'mastodon/components/spoiler_button';
import { formatTime } from 'mastodon/features/video';
import { autoPlayGif, displayMedia, useBlurhash } from '../initial_state';
@ -38,6 +39,7 @@ class Item extends PureComponent {
state = {
loaded: false,
error: false,
};
handleMouseEnter = (e) => {
@ -81,6 +83,10 @@ class Item extends PureComponent {
this.setState({ loaded: true });
};
handleImageError = () => {
this.setState({ error: true });
};
render () {
const { attachment, lang, index, size, standalone, displayWidth, visible } = this.props;
@ -148,6 +154,7 @@ class Item extends PureComponent {
lang={lang}
style={{ objectPosition: `${x}% ${y}%` }}
onLoad={this.handleImageLoad}
onError={this.handleImageError}
/>
</a>
);
@ -183,7 +190,7 @@ class Item extends PureComponent {
}
return (
<div className={classNames('media-gallery__item', { standalone, 'media-gallery__item--tall': height === 100, 'media-gallery__item--wide': width === 100 })} key={attachment.get('id')}>
<div className={classNames('media-gallery__item', { standalone, 'media-gallery__item--error': this.state.error, 'media-gallery__item--tall': height === 100, 'media-gallery__item--wide': width === 100 })} key={attachment.get('id')}>
<Blurhash
hash={attachment.get('blurhash')}
dummy={!useBlurhash}
@ -219,6 +226,7 @@ class MediaGallery extends PureComponent {
visible: PropTypes.bool,
autoplay: PropTypes.bool,
onToggleVisibility: PropTypes.func,
matchedFilters: PropTypes.arrayOf(PropTypes.string),
};
state = {
@ -289,11 +297,11 @@ class MediaGallery extends PureComponent {
}
render () {
const { media, lang, sensitive, defaultWidth, autoplay } = this.props;
const { media, lang, sensitive, defaultWidth, autoplay, matchedFilters } = this.props;
const { visible } = this.state;
const width = this.state.width || defaultWidth;
let children, spoilerButton;
let children;
const style = {};
@ -312,35 +320,11 @@ class MediaGallery extends PureComponent {
children = media.map((attachment, i) => <Item key={attachment.get('id')} autoplay={autoplay} onClick={this.handleClick} attachment={attachment} index={i} lang={lang} size={size} displayWidth={width} visible={visible || uncached} />);
}
if (uncached) {
spoilerButton = (
<button type='button' disabled className='spoiler-button__overlay'>
<span className='spoiler-button__overlay__label'>
<FormattedMessage id='status.uncached_media_warning' defaultMessage='Preview not available' />
<span className='spoiler-button__overlay__action'><FormattedMessage id='status.media.open' defaultMessage='Click to open' /></span>
</span>
</button>
);
} else if (!visible) {
spoilerButton = (
<button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'>
<span className='spoiler-button__overlay__label'>
{sensitive ? <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /> : <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />}
<span className='spoiler-button__overlay__action'><FormattedMessage id='status.media.show' defaultMessage='Click to show' /></span>
</span>
</button>
);
}
return (
<div className={`media-gallery media-gallery--layout-${size}`} style={style} ref={this.handleRef}>
{children}
{(!visible || uncached) && (
<div className={classNames('spoiler-button', { 'spoiler-button--click-thru': uncached })}>
{spoilerButton}
</div>
)}
{(!visible || uncached) && <SpoilerButton uncached={uncached} sensitive={sensitive} onClick={this.handleOpen} matchedFilters={matchedFilters} />}
{(visible && !uncached) && (
<div className='media-gallery__actions'>

View file

@ -1,248 +0,0 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import escapeTextContentForBrowser from 'escape-html';
import spring from 'react-motion/lib/spring';
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
import { Icon } from 'mastodon/components/icon';
import emojify from 'mastodon/features/emoji/emoji';
import Motion from 'mastodon/features/ui/util/optional_motion';
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
import { RelativeTimestamp } from './relative_timestamp';
const messages = defineMessages({
closed: {
id: 'poll.closed',
defaultMessage: 'Closed',
},
voted: {
id: 'poll.voted',
defaultMessage: 'You voted for this answer',
},
votes: {
id: 'poll.votes',
defaultMessage: '{votes, plural, one {# vote} other {# votes}}',
},
});
class Poll extends ImmutablePureComponent {
static propTypes = {
identity: identityContextPropShape,
poll: ImmutablePropTypes.record.isRequired,
status: ImmutablePropTypes.map.isRequired,
lang: PropTypes.string,
intl: PropTypes.object.isRequired,
disabled: PropTypes.bool,
refresh: PropTypes.func,
onVote: PropTypes.func,
onInteractionModal: PropTypes.func,
};
state = {
selected: {},
expired: null,
};
static getDerivedStateFromProps (props, state) {
const { poll } = props;
const expires_at = poll.get('expires_at');
const expired = poll.get('expired') || expires_at !== null && (new Date(expires_at)).getTime() < Date.now();
return (expired === state.expired) ? null : { expired };
}
componentDidMount () {
this._setupTimer();
}
componentDidUpdate () {
this._setupTimer();
}
componentWillUnmount () {
clearTimeout(this._timer);
}
_setupTimer () {
const { poll } = this.props;
clearTimeout(this._timer);
if (!this.state.expired) {
const delay = (new Date(poll.get('expires_at'))).getTime() - Date.now();
this._timer = setTimeout(() => {
this.setState({ expired: true });
}, delay);
}
}
_toggleOption = value => {
if (this.props.poll.get('multiple')) {
const tmp = { ...this.state.selected };
if (tmp[value]) {
delete tmp[value];
} else {
tmp[value] = true;
}
this.setState({ selected: tmp });
} else {
const tmp = {};
tmp[value] = true;
this.setState({ selected: tmp });
}
};
handleOptionChange = ({ target: { value } }) => {
this._toggleOption(value);
};
handleOptionKeyPress = (e) => {
if (e.key === 'Enter' || e.key === ' ') {
this._toggleOption(e.target.getAttribute('data-index'));
e.stopPropagation();
e.preventDefault();
}
};
handleVote = () => {
if (this.props.disabled) {
return;
}
if (this.props.identity.signedIn) {
this.props.onVote(Object.keys(this.state.selected));
} else {
this.props.onInteractionModal('vote', this.props.status);
}
};
handleRefresh = () => {
if (this.props.disabled) {
return;
}
this.props.refresh();
};
handleReveal = () => {
this.setState({ revealed: true });
};
renderOption (option, optionIndex, showResults) {
const { poll, lang, disabled, intl } = this.props;
const pollVotesCount = poll.get('voters_count') || poll.get('votes_count');
const percent = pollVotesCount === 0 ? 0 : (option.get('votes_count') / pollVotesCount) * 100;
const leading = poll.get('options').filterNot(other => other.get('title') === option.get('title')).every(other => option.get('votes_count') >= other.get('votes_count'));
const active = !!this.state.selected[`${optionIndex}`];
const voted = option.get('voted') || (poll.get('own_votes') && poll.get('own_votes').includes(optionIndex));
const title = option.getIn(['translation', 'title']) || option.get('title');
let titleHtml = option.getIn(['translation', 'titleHtml']) || option.get('titleHtml');
if (!titleHtml) {
const emojiMap = emojiMap(poll);
titleHtml = emojify(escapeTextContentForBrowser(title), emojiMap);
}
return (
<li key={option.get('title')}>
<label className={classNames('poll__option', { selectable: !showResults })}>
<input
name='vote-options'
type={poll.get('multiple') ? 'checkbox' : 'radio'}
value={optionIndex}
checked={active}
onChange={this.handleOptionChange}
disabled={disabled}
/>
{!showResults && (
<span
className={classNames('poll__input', { checkbox: poll.get('multiple'), active })}
tabIndex={0}
role={poll.get('multiple') ? 'checkbox' : 'radio'}
onKeyPress={this.handleOptionKeyPress}
aria-checked={active}
aria-label={title}
lang={lang}
data-index={optionIndex}
/>
)}
{showResults && (
<span
className='poll__number'
title={intl.formatMessage(messages.votes, {
votes: option.get('votes_count'),
})}
>
{Math.round(percent)}%
</span>
)}
<span
className='poll__option__text translate'
lang={lang}
dangerouslySetInnerHTML={{ __html: titleHtml }}
/>
{!!voted && <span className='poll__voted'>
<Icon id='check' icon={CheckIcon} className='poll__voted__mark' title={intl.formatMessage(messages.voted)} />
</span>}
</label>
{showResults && (
<Motion defaultStyle={{ width: 0 }} style={{ width: spring(percent, { stiffness: 180, damping: 12 }) }}>
{({ width }) =>
<span className={classNames('poll__chart', { leading })} style={{ width: `${width}%` }} />
}
</Motion>
)}
</li>
);
}
render () {
const { poll, intl } = this.props;
const { revealed, expired } = this.state;
if (!poll) {
return null;
}
const timeRemaining = expired ? intl.formatMessage(messages.closed) : <RelativeTimestamp timestamp={poll.get('expires_at')} futureDate />;
const showResults = poll.get('voted') || revealed || expired;
const disabled = this.props.disabled || Object.entries(this.state.selected).every(item => !item);
let votesCount = null;
if (poll.get('voters_count') !== null && poll.get('voters_count') !== undefined) {
votesCount = <FormattedMessage id='poll.total_people' defaultMessage='{count, plural, one {# person} other {# people}}' values={{ count: poll.get('voters_count') }} />;
} else {
votesCount = <FormattedMessage id='poll.total_votes' defaultMessage='{count, plural, one {# vote} other {# votes}}' values={{ count: poll.get('votes_count') }} />;
}
return (
<div className='poll'>
<ul>
{poll.get('options').map((option, i) => this.renderOption(option, i, showResults))}
</ul>
<div className='poll__footer'>
{!showResults && <button className='button button-secondary' disabled={disabled} onClick={this.handleVote}><FormattedMessage id='poll.vote' defaultMessage='Vote' /></button>}
{!showResults && <><button className='poll__link' onClick={this.handleReveal}><FormattedMessage id='poll.reveal' defaultMessage='See results' /></button> · </>}
{showResults && !this.props.disabled && <><button className='poll__link' onClick={this.handleRefresh}><FormattedMessage id='poll.refresh' defaultMessage='Refresh' /></button> · </>}
{votesCount}
{poll.get('expires_at') && <> · {timeRemaining}</>}
</div>
</div>
);
}
}
export default injectIntl(withIdentity(Poll));

View file

@ -0,0 +1,337 @@
import type { KeyboardEventHandler } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import classNames from 'classnames';
import { animated, useSpring } from '@react-spring/web';
import escapeTextContentForBrowser from 'escape-html';
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
import { openModal } from 'mastodon/actions/modal';
import { fetchPoll, vote } from 'mastodon/actions/polls';
import { Icon } from 'mastodon/components/icon';
import emojify from 'mastodon/features/emoji/emoji';
import { useIdentity } from 'mastodon/identity_context';
import { reduceMotion } from 'mastodon/initial_state';
import { makeEmojiMap } from 'mastodon/models/custom_emoji';
import type * as Model from 'mastodon/models/poll';
import type { Status } from 'mastodon/models/status';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import { RelativeTimestamp } from './relative_timestamp';
const messages = defineMessages({
closed: {
id: 'poll.closed',
defaultMessage: 'Closed',
},
voted: {
id: 'poll.voted',
defaultMessage: 'You voted for this answer',
},
votes: {
id: 'poll.votes',
defaultMessage: '{votes, plural, one {# vote} other {# votes}}',
},
});
interface PollProps {
pollId: string;
status: Status;
lang?: string;
disabled?: boolean;
}
export const Poll: React.FC<PollProps> = ({ pollId, disabled, status }) => {
// Third party hooks
const poll = useAppSelector((state) => state.polls[pollId]);
const identity = useIdentity();
const intl = useIntl();
const dispatch = useAppDispatch();
// State
const [revealed, setRevealed] = useState(false);
const [selected, setSelected] = useState<Record<string, boolean>>({});
// Derived values
const expired = useMemo(() => {
if (!poll) {
return false;
}
const expiresAt = poll.expires_at;
return poll.expired || new Date(expiresAt).getTime() < Date.now();
}, [poll]);
const timeRemaining = useMemo(() => {
if (!poll) {
return null;
}
if (expired) {
return intl.formatMessage(messages.closed);
}
return <RelativeTimestamp timestamp={poll.expires_at} futureDate />;
}, [expired, intl, poll]);
const votesCount = useMemo(() => {
if (!poll) {
return null;
}
if (poll.voters_count) {
return (
<FormattedMessage
id='poll.total_people'
defaultMessage='{count, plural, one {# person} other {# people}}'
values={{ count: poll.voters_count }}
/>
);
}
return (
<FormattedMessage
id='poll.total_votes'
defaultMessage='{count, plural, one {# vote} other {# votes}}'
values={{ count: poll.votes_count }}
/>
);
}, [poll]);
const voteDisabled =
disabled || Object.values(selected).every((item) => !item);
// Event handlers
const handleVote = useCallback(() => {
if (voteDisabled) {
return;
}
if (identity.signedIn) {
void dispatch(vote({ pollId, choices: Object.keys(selected) }));
} else {
dispatch(
openModal({
modalType: 'INTERACTION',
modalProps: {
type: 'vote',
accountId: status.getIn(['account', 'id']),
url: status.get('uri'),
},
}),
);
}
}, [voteDisabled, dispatch, identity, pollId, selected, status]);
const handleReveal = useCallback(() => {
setRevealed(true);
}, []);
const handleRefresh = useCallback(() => {
if (disabled) {
return;
}
void dispatch(fetchPoll({ pollId }));
}, [disabled, dispatch, pollId]);
const handleOptionChange = useCallback(
(choiceIndex: number) => {
if (!poll) {
return;
}
if (poll.multiple) {
setSelected((prev) => ({
...prev,
[choiceIndex]: !prev[choiceIndex],
}));
} else {
setSelected({ [choiceIndex]: true });
}
},
[poll],
);
if (!poll) {
return null;
}
const showResults = poll.voted || revealed || expired;
return (
<div className='poll'>
<ul>
{poll.options.map((option, i) => (
<PollOption
key={option.title || i}
index={i}
poll={poll}
option={option}
showResults={showResults}
active={!!selected[i]}
onChange={handleOptionChange}
/>
))}
</ul>
<div className='poll__footer'>
{!showResults && (
<button
className='button button-secondary'
disabled={voteDisabled}
onClick={handleVote}
>
<FormattedMessage id='poll.vote' defaultMessage='Vote' />
</button>
)}
{!showResults && (
<>
<button className='poll__link' onClick={handleReveal}>
<FormattedMessage id='poll.reveal' defaultMessage='See results' />
</button>{' '}
·{' '}
</>
)}
{showResults && !disabled && (
<>
<button className='poll__link' onClick={handleRefresh}>
<FormattedMessage id='poll.refresh' defaultMessage='Refresh' />
</button>{' '}
·{' '}
</>
)}
{votesCount}
{poll.expires_at && <> · {timeRemaining}</>}
</div>
</div>
);
};
type PollOptionProps = Pick<PollProps, 'disabled' | 'lang'> & {
active: boolean;
onChange: (index: number) => void;
poll: Model.Poll;
option: Model.PollOption;
index: number;
showResults?: boolean;
};
const PollOption: React.FC<PollOptionProps> = (props) => {
const { active, lang, disabled, poll, option, index, showResults, onChange } =
props;
const voted = option.voted || poll.own_votes?.includes(index);
const title = option.translation?.title ?? option.title;
const intl = useIntl();
// Derived values
const percent = useMemo(() => {
const pollVotesCount = poll.voters_count ?? poll.votes_count;
return pollVotesCount === 0
? 0
: (option.votes_count / pollVotesCount) * 100;
}, [option, poll]);
const isLeading = useMemo(
() =>
poll.options
.filter((other) => other.title !== option.title)
.every((other) => option.votes_count >= other.votes_count),
[poll, option],
);
const titleHtml = useMemo(() => {
let titleHtml = option.translation?.titleHtml ?? option.titleHtml;
if (!titleHtml) {
const emojiMap = makeEmojiMap(poll.emojis);
titleHtml = emojify(escapeTextContentForBrowser(title), emojiMap);
}
return titleHtml;
}, [option, poll, title]);
// Handlers
const handleOptionChange = useCallback(() => {
onChange(index);
}, [index, onChange]);
const handleOptionKeyPress: KeyboardEventHandler = useCallback(
(event) => {
if (event.key === 'Enter' || event.key === ' ') {
onChange(index);
event.stopPropagation();
event.preventDefault();
}
},
[index, onChange],
);
const widthSpring = useSpring({
from: {
width: '0%',
},
to: {
width: `${percent}%`,
},
immediate: reduceMotion,
});
return (
<li>
<label
className={classNames('poll__option', { selectable: !showResults })}
>
<input
name='vote-options'
type={poll.multiple ? 'checkbox' : 'radio'}
value={index}
checked={active}
onChange={handleOptionChange}
disabled={disabled}
/>
{!showResults && (
<span
className={classNames('poll__input', {
checkbox: poll.multiple,
active,
})}
tabIndex={0}
role={poll.multiple ? 'checkbox' : 'radio'}
onKeyDown={handleOptionKeyPress}
aria-checked={active}
aria-label={title}
lang={lang}
data-index={index}
/>
)}
{showResults && (
<span
className='poll__number'
title={intl.formatMessage(messages.votes, {
votes: option.votes_count,
})}
>
{Math.round(percent)}%
</span>
)}
<span
className='poll__option__text translate'
lang={lang}
dangerouslySetInnerHTML={{ __html: titleHtml }}
/>
{!!voted && (
<span className='poll__voted'>
<Icon
id='check'
icon={CheckIcon}
className='poll__voted__mark'
title={intl.formatMessage(messages.voted)}
/>
</span>
)}
</label>
{showResults && (
<animated.span
className={classNames('poll__chart', { leading: isLeading })}
style={widthSpring}
/>
)}
</li>
);
};

View file

@ -1,5 +1,5 @@
import type { PropsWithChildren } from 'react';
import React from 'react';
import type React from 'react';
import { Router as OriginalRouter, useHistory } from 'react-router';

View file

@ -0,0 +1,89 @@
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
interface Props {
hidden?: boolean;
sensitive: boolean;
uncached?: boolean;
matchedFilters?: string[];
onClick: React.MouseEventHandler<HTMLButtonElement>;
}
export const SpoilerButton: React.FC<Props> = ({
hidden = false,
sensitive,
uncached = false,
matchedFilters,
onClick,
}) => {
let warning;
let action;
if (uncached) {
warning = (
<FormattedMessage
id='status.uncached_media_warning'
defaultMessage='Preview not available'
/>
);
action = (
<FormattedMessage id='status.media.open' defaultMessage='Click to open' />
);
} else if (matchedFilters) {
warning = (
<FormattedMessage
id='filter_warning.matches_filter'
defaultMessage='Matches filter “<span>{title}</span>”'
values={{
title: matchedFilters.join(', '),
span: (chunks) => <span className='filter-name'>{chunks}</span>,
}}
/>
);
action = (
<FormattedMessage id='status.media.show' defaultMessage='Click to show' />
);
} else if (sensitive) {
warning = (
<FormattedMessage
id='status.sensitive_warning'
defaultMessage='Sensitive content'
/>
);
action = (
<FormattedMessage id='status.media.show' defaultMessage='Click to show' />
);
} else {
warning = (
<FormattedMessage
id='status.media_hidden'
defaultMessage='Media hidden'
/>
);
action = (
<FormattedMessage id='status.media.show' defaultMessage='Click to show' />
);
}
return (
<div
className={classNames('spoiler-button', {
'spoiler-button--hidden': hidden,
'spoiler-button--click-thru': uncached,
})}
>
<button
type='button'
className='spoiler-button__overlay'
onClick={onClick}
disabled={uncached}
>
<span className='spoiler-button__overlay__label'>
{warning}
<span className='spoiler-button__overlay__action'>{action}</span>
</span>
</button>
</div>
);
};

View file

@ -70,7 +70,7 @@ export const defaultMediaVisibility = (status) => {
status = status.get('reblog');
}
return (displayMedia !== 'hide_all' && !status.get('sensitive') || displayMedia === 'show_all');
return !status.get('matched_media_filters') && (displayMedia !== 'hide_all' && !status.get('sensitive') || displayMedia === 'show_all');
};
const messages = defineMessages({
@ -470,6 +470,7 @@ class Status extends ImmutablePureComponent {
defaultWidth={this.props.cachedMediaWidth}
visible={this.state.showMedia}
onToggleVisibility={this.handleToggleMediaVisibility}
matchedFilters={status.get('matched_media_filters')}
/>
)}
</Bundle>
@ -498,6 +499,7 @@ class Status extends ImmutablePureComponent {
blurhash={attachment.get('blurhash')}
visible={this.state.showMedia}
onToggleVisibility={this.handleToggleMediaVisibility}
matchedFilters={status.get('matched_media_filters')}
/>
)}
</Bundle>
@ -522,6 +524,7 @@ class Status extends ImmutablePureComponent {
deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined}
visible={this.state.showMedia}
onToggleVisibility={this.handleToggleMediaVisibility}
matchedFilters={status.get('matched_media_filters')}
/>
)}
</Bundle>

View file

@ -11,7 +11,7 @@ import { connect } from 'react-redux';
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
import { Icon } from 'mastodon/components/icon';
import PollContainer from 'mastodon/containers/poll_container';
import { Poll } from 'mastodon/components/poll';
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
import { autoPlayGif, languages as preloadedLanguages } from 'mastodon/initial_state';
@ -245,7 +245,7 @@ class StatusContent extends PureComponent {
);
const poll = !!status.get('poll') && (
<PollContainer pollId={status.get('poll')} status={status} lang={language} />
<Poll pollId={status.get('poll')} status={status} lang={language} />
);
if (this.props.onClick) {

View file

@ -7,12 +7,13 @@ import { fromJS } from 'immutable';
import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
import MediaGallery from 'mastodon/components/media_gallery';
import ModalRoot from 'mastodon/components/modal_root';
import Poll from 'mastodon/components/poll';
import { Poll } from 'mastodon/components/poll';
import Audio from 'mastodon/features/audio';
import Card from 'mastodon/features/status/components/card';
import MediaModal from 'mastodon/features/ui/components/media_modal';
import Video from 'mastodon/features/video';
import { Video } from 'mastodon/features/video';
import { IntlProvider } from 'mastodon/locales';
import { createPollFromServerJSON } from 'mastodon/models/poll';
import { getScrollbarWidth } from 'mastodon/utils/scrollbar';
const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag, Audio };
@ -88,7 +89,7 @@ export default class MediaContainer extends PureComponent {
Object.assign(props, {
...(media ? { media: fromJS(media) } : {}),
...(card ? { card: fromJS(card) } : {}),
...(poll ? { poll: fromJS(poll) } : {}),
...(poll ? { poll: createPollFromServerJSON(poll) } : {}),
...(hashtag ? { hashtag: fromJS(hashtag) } : {}),
...(componentName === 'Video' ? {

View file

@ -1,38 +0,0 @@
import { connect } from 'react-redux';
import { debounce } from 'lodash';
import { openModal } from 'mastodon/actions/modal';
import { fetchPoll, vote } from 'mastodon/actions/polls';
import Poll from 'mastodon/components/poll';
const mapDispatchToProps = (dispatch, { pollId }) => ({
refresh: debounce(
() => {
dispatch(fetchPoll({ pollId }));
},
1000,
{ leading: true },
),
onVote (choices) {
dispatch(vote({ pollId, choices }));
},
onInteractionModal (type, status) {
dispatch(openModal({
modalType: 'INTERACTION',
modalProps: {
type,
accountId: status.getIn(['account', 'id']),
url: status.get('uri'),
},
}));
}
});
const mapStateToProps = (state, { pollId }) => ({
poll: state.polls.get(pollId),
});
export default connect(mapStateToProps, mapDispatchToProps)(Poll);

View file

@ -4,7 +4,6 @@ import { PureComponent } from 'react';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { is } from 'immutable';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Textarea from 'react-textarea-autosize';
@ -49,7 +48,7 @@ class InlineAlert extends PureComponent {
class AccountNote extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.record.isRequired,
accountId: PropTypes.string.isRequired,
value: PropTypes.string,
onSave: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
@ -66,7 +65,7 @@ class AccountNote extends ImmutablePureComponent {
}
UNSAFE_componentWillReceiveProps (nextProps) {
const accountWillChange = !is(this.props.account, nextProps.account);
const accountWillChange = !is(this.props.accountId, nextProps.accountId);
const newState = {};
if (accountWillChange && this._isDirty()) {
@ -102,10 +101,10 @@ class AccountNote extends ImmutablePureComponent {
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
this._save();
if (this.textarea) {
this.textarea.blur();
} else {
this._save();
}
} else if (e.keyCode === 27) {
e.preventDefault();
@ -141,21 +140,21 @@ class AccountNote extends ImmutablePureComponent {
}
render () {
const { account, intl } = this.props;
const { accountId, intl } = this.props;
const { value, saved } = this.state;
if (!account) {
if (!accountId) {
return null;
}
return (
<div className='account__header__account-note'>
<label htmlFor={`account-note-${account.get('id')}`}>
<label htmlFor={`account-note-${accountId}`}>
<FormattedMessage id='account.account_note_header' defaultMessage='Personal note' /> <InlineAlert show={saved} />
</label>
<Textarea
id={`account-note-${account.get('id')}`}
id={`account-note-${accountId}`}
className='account__header__account-note__content'
disabled={this.props.value === null || value === null}
placeholder={intl.formatMessage(messages.placeholder)}

View file

@ -4,14 +4,14 @@ import { submitAccountNote } from 'mastodon/actions/account_notes';
import AccountNote from '../components/account_note';
const mapStateToProps = (state, { account }) => ({
value: account.getIn(['relationship', 'note']),
const mapStateToProps = (state, { accountId }) => ({
value: state.relationships.getIn([accountId, 'note']),
});
const mapDispatchToProps = (dispatch, { account }) => ({
const mapDispatchToProps = (dispatch, { accountId }) => ({
onSave (value) {
dispatch(submitAccountNote({ accountId: account.get('id'), note: value }));
dispatch(submitAccountNote({ accountId: accountId, note: value }));
},
});

View file

@ -26,11 +26,16 @@ export const MediaItem: React.FC<{
displayMedia === 'show_all',
);
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState(false);
const handleImageLoad = useCallback(() => {
setLoaded(true);
}, [setLoaded]);
const handleImageError = useCallback(() => {
setError(true);
}, [setError]);
const handleMouseEnter = useCallback(
(e: React.MouseEvent<HTMLVideoElement>) => {
if (e.target instanceof HTMLVideoElement) {
@ -98,6 +103,7 @@ export const MediaItem: React.FC<{
alt={description}
lang={lang}
onLoad={handleImageLoad}
onError={handleImageError}
/>
<div className='media-gallery__item__overlay media-gallery__item__overlay--corner'>
@ -118,6 +124,7 @@ export const MediaItem: React.FC<{
lang={lang}
style={{ objectPosition: `${x}% ${y}%` }}
onLoad={handleImageLoad}
onError={handleImageError}
/>
);
} else if (['video', 'gifv'].includes(type)) {
@ -173,7 +180,11 @@ export const MediaItem: React.FC<{
}
return (
<div className='media-gallery__item media-gallery__item--square'>
<div
className={classNames('media-gallery__item media-gallery__item--square', {
'media-gallery__item--error': error,
})}
>
<Blurhash
hash={blurhash}
className={classNames('media-gallery__preview', {

View file

@ -37,6 +37,7 @@ import {
FollowingCounter,
StatusesCounter,
} from 'mastodon/components/counters';
import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
import { Icon } from 'mastodon/components/icon';
import { IconButton } from 'mastodon/components/icon_button';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
@ -919,7 +920,7 @@ export const AccountHeader: React.FC<{
onClickCapture={handleLinkClick}
>
{account.id !== me && signedIn && (
<AccountNoteContainer account={account} />
<AccountNoteContainer accountId={accountId} />
)}
{account.note.length > 0 && account.note !== '<p></p>' && (
@ -938,11 +939,12 @@ export const AccountHeader: React.FC<{
/>
</dt>
<dd>
{intl.formatDate(account.created_at, {
year: 'numeric',
month: 'short',
day: '2-digit',
})}
<FormattedDateWrapper
value={account.created_at}
year='numeric'
month='short'
day='2-digit'
/>
</dd>
</dl>

View file

@ -30,7 +30,7 @@ import { Skeleton } from 'mastodon/components/skeleton';
import Audio from 'mastodon/features/audio';
import { CharacterCounter } from 'mastodon/features/compose/components/character_counter';
import { Tesseract as fetchTesseract } from 'mastodon/features/ui/util/async-components';
import Video, { getPointerPosition } from 'mastodon/features/video';
import { Video, getPointerPosition } from 'mastodon/features/video';
import { me } from 'mastodon/initial_state';
import type { MediaAttachment } from 'mastodon/models/media_attachment';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
@ -134,17 +134,7 @@ const Preview: React.FC<{
return;
}
const { x, y } = getPointerPosition(nodeRef.current, e);
setDragging(true);
draggingRef.current = true;
onPositionChange([x, y]);
},
[setDragging, onPositionChange],
);
const handleTouchStart = useCallback(
(e: React.TouchEvent) => {
const { x, y } = getPointerPosition(nodeRef.current, e);
const { x, y } = getPointerPosition(nodeRef.current, e.nativeEvent);
setDragging(true);
draggingRef.current = true;
onPositionChange([x, y]);
@ -165,28 +155,12 @@ const Preview: React.FC<{
}
};
const handleTouchEnd = () => {
setDragging(false);
draggingRef.current = false;
};
const handleTouchMove = (e: TouchEvent) => {
if (draggingRef.current) {
const { x, y } = getPointerPosition(nodeRef.current, e);
onPositionChange([x, y]);
}
};
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('touchend', handleTouchEnd);
document.addEventListener('touchmove', handleTouchMove);
return () => {
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('touchend', handleTouchEnd);
document.removeEventListener('touchmove', handleTouchMove);
};
}, [setDragging, onPositionChange]);
@ -204,7 +178,6 @@ const Preview: React.FC<{
alt=''
role='presentation'
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
/>
<div
className='focal-point__reticle'
@ -220,7 +193,6 @@ const Preview: React.FC<{
src={media.get('url') as string}
alt=''
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
/>
<div
className='focal-point__reticle'
@ -233,10 +205,10 @@ const Preview: React.FC<{
<Video
preview={media.get('preview_url') as string}
frameRate={media.getIn(['meta', 'original', 'frame_rate']) as string}
aspectRatio={`${media.getIn(['meta', 'original', 'width']) as number} / ${media.getIn(['meta', 'original', 'height']) as number}`}
blurhash={media.get('blurhash') as string}
src={media.get('url') as string}
detailed
inline
editable
/>
);

View file

@ -1,7 +1,7 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
import { defineMessages, injectIntl } from 'react-intl';
import classNames from 'classnames';
@ -16,6 +16,7 @@ import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?reac
import VolumeOffIcon from '@/material-icons/400-24px/volume_off-fill.svg?react';
import VolumeUpIcon from '@/material-icons/400-24px/volume_up-fill.svg?react';
import { Icon } from 'mastodon/components/icon';
import { SpoilerButton } from 'mastodon/components/spoiler_button';
import { formatTime, getPointerPosition, fileNameFromURL } from 'mastodon/features/video';
import { Blurhash } from '../../components/blurhash';
@ -26,8 +27,8 @@ import Visualizer from './visualizer';
const messages = defineMessages({
play: { id: 'video.play', defaultMessage: 'Play' },
pause: { id: 'video.pause', defaultMessage: 'Pause' },
mute: { id: 'video.mute', defaultMessage: 'Mute sound' },
unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' },
mute: { id: 'video.mute', defaultMessage: 'Mute' },
unmute: { id: 'video.unmute', defaultMessage: 'Unmute' },
download: { id: 'video.download', defaultMessage: 'Download file' },
hide: { id: 'audio.hide', defaultMessage: 'Hide audio' },
});
@ -61,6 +62,7 @@ class Audio extends PureComponent {
volume: PropTypes.number,
muted: PropTypes.bool,
deployPictureInPicture: PropTypes.func,
matchedFilters: PropTypes.arrayOf(PropTypes.string),
};
state = {
@ -471,19 +473,11 @@ class Audio extends PureComponent {
};
render () {
const { src, intl, alt, lang, editable, autoPlay, sensitive, blurhash } = this.props;
const { src, intl, alt, lang, editable, autoPlay, sensitive, blurhash, matchedFilters } = this.props;
const { paused, volume, currentTime, duration, buffer, dragging, revealed } = this.state;
const progress = Math.min((currentTime / duration) * 100, 100);
const muted = this.state.muted || volume === 0;
let warning;
if (sensitive) {
warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
} else {
warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
}
return (
<div className={classNames('audio-player', { editable, inactive: !revealed })} ref={this.setPlayerRef} style={{ backgroundColor: this._getBackgroundColor(), color: this._getForegroundColor(), aspectRatio: '16 / 9' }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} tabIndex={0} onKeyDown={this.handleKeyDown}>
@ -521,14 +515,7 @@ class Audio extends PureComponent {
lang={lang}
/>
<div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed || editable })}>
<button type='button' className='spoiler-button__overlay' onClick={this.toggleReveal}>
<span className='spoiler-button__overlay__label'>
{warning}
<span className='spoiler-button__overlay__action'><FormattedMessage id='status.media.show' defaultMessage='Click to show' /></span>
</span>
</button>
</div>
<SpoilerButton hidden={revealed || editable} sensitive={sensitive} onClick={this.toggleReveal} matchedFilters={matchedFilters} />
{(revealed || editable) && <img
src={this.props.poster}

View file

@ -99,6 +99,7 @@ class Bookmarks extends ImmutablePureComponent {
onLoadMore={this.handleLoadMore}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
timelineId='bookmarks'
/>
<Helmet>

View file

@ -20,7 +20,6 @@ import PollButtonContainer from '../containers/poll_button_container';
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
import SpoilerButtonContainer from '../containers/spoiler_button_container';
import UploadButtonContainer from '../containers/upload_button_container';
import WarningContainer from '../containers/warning_container';
import { countableText } from '../util/counter';
import { CharacterCounter } from './character_counter';
@ -30,6 +29,7 @@ import { NavigationBar } from './navigation_bar';
import { PollForm } from "./poll_form";
import { ReplyIndicator } from './reply_indicator';
import { UploadForm } from './upload_form';
import { Warning } from './warning';
const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d';
@ -233,7 +233,7 @@ class ComposeForm extends ImmutablePureComponent {
<form className='compose-form' onSubmit={this.handleSubmit}>
<ReplyIndicator />
{!withoutNavigation && <NavigationBar />}
<WarningContainer />
<Warning />
<div className={classNames('compose-form__highlightable', { active: highlighted })} ref={this.setRef}>
<div className='compose-form__scrollable'>

View file

@ -378,6 +378,7 @@ export const LanguageDropdown: React.FC = () => {
if (text.length > 20) {
debouncedGuess(text, setGuess);
} else {
debouncedGuess.cancel();
setGuess('');
}
}, [text, setGuess]);

View file

@ -1,48 +0,0 @@
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import spring from 'react-motion/lib/spring';
import UploadFileIcon from '@/material-icons/400-24px/upload_file.svg?react';
import { Icon } from 'mastodon/components/icon';
import Motion from '../../ui/util/optional_motion';
export const UploadProgress = ({ active, progress, isProcessing }) => {
if (!active) {
return null;
}
let message;
if (isProcessing) {
message = <FormattedMessage id='upload_progress.processing' defaultMessage='Processing…' />;
} else {
message = <FormattedMessage id='upload_progress.label' defaultMessage='Uploading…' />;
}
return (
<div className='upload-progress'>
<Icon id='upload' icon={UploadFileIcon} />
<div className='upload-progress__message'>
{message}
<div className='upload-progress__backdrop'>
<Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress) }}>
{({ width }) =>
<div className='upload-progress__tracker' style={{ width: `${width}%` }} />
}
</Motion>
</div>
</div>
</div>
);
};
UploadProgress.propTypes = {
active: PropTypes.bool,
progress: PropTypes.number,
isProcessing: PropTypes.bool,
};

View file

@ -0,0 +1,52 @@
import { FormattedMessage } from 'react-intl';
import { animated, useSpring } from '@react-spring/web';
import UploadFileIcon from '@/material-icons/400-24px/upload_file.svg?react';
import { Icon } from 'mastodon/components/icon';
import { reduceMotion } from 'mastodon/initial_state';
interface UploadProgressProps {
active: boolean;
progress: number;
isProcessing?: boolean;
}
export const UploadProgress: React.FC<UploadProgressProps> = ({
active,
progress,
isProcessing = false,
}) => {
const styles = useSpring({
from: { width: '0%' },
to: { width: `${progress}%` },
immediate: reduceMotion || !active, // If this is not active, update the UI immediately.
});
if (!active) {
return null;
}
return (
<div className='upload-progress'>
<Icon id='upload' icon={UploadFileIcon} />
<div className='upload-progress__message'>
{isProcessing ? (
<FormattedMessage
id='upload_progress.processing'
defaultMessage='Processing…'
/>
) : (
<FormattedMessage
id='upload_progress.label'
defaultMessage='Uploading…'
/>
)}
<div className='upload-progress__backdrop'>
<animated.div className='upload-progress__tracker' style={styles} />
</div>
</div>
</div>
);
};

View file

@ -1,28 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import spring from 'react-motion/lib/spring';
import Motion from '../../ui/util/optional_motion';
export default class Warning extends PureComponent {
static propTypes = {
message: PropTypes.node.isRequired,
};
render () {
const { message } = this.props;
return (
<Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
{({ opacity, scaleX, scaleY }) => (
<div className='compose-form__warning' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
{message}
</div>
)}
</Motion>
);
}
}

View file

@ -0,0 +1,96 @@
import { FormattedMessage } from 'react-intl';
import { createSelector } from '@reduxjs/toolkit';
import { animated, useSpring } from '@react-spring/web';
import { me } from 'mastodon/initial_state';
import { useAppSelector } from 'mastodon/store';
import type { RootState } from 'mastodon/store';
import { HASHTAG_PATTERN_REGEX } from 'mastodon/utils/hashtags';
const selector = createSelector(
(state: RootState) => state.compose.get('privacy') as string,
(state: RootState) => !!state.accounts.getIn([me, 'locked']),
(state: RootState) => state.compose.get('text') as string,
(privacy, locked, text) => ({
needsLockWarning: privacy === 'private' && !locked,
hashtagWarning: privacy !== 'public' && HASHTAG_PATTERN_REGEX.test(text),
directMessageWarning: privacy === 'direct',
}),
);
export const Warning = () => {
const { needsLockWarning, hashtagWarning, directMessageWarning } =
useAppSelector(selector);
if (needsLockWarning) {
return (
<WarningMessage>
<FormattedMessage
id='compose_form.lock_disclaimer'
defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.'
values={{
locked: (
<a href='/settings/profile'>
<FormattedMessage
id='compose_form.lock_disclaimer.lock'
defaultMessage='locked'
/>
</a>
),
}}
/>
</WarningMessage>
);
}
if (hashtagWarning) {
return (
<WarningMessage>
<FormattedMessage
id='compose_form.hashtag_warning'
defaultMessage="This post won't be listed under any hashtag as it is unlisted. Only public posts can be searched by hashtag."
/>
</WarningMessage>
);
}
if (directMessageWarning) {
return (
<WarningMessage>
<FormattedMessage
id='compose_form.encryption_warning'
defaultMessage='Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.'
/>{' '}
<a href='/terms' target='_blank'>
<FormattedMessage
id='compose_form.direct_message_warning_learn_more'
defaultMessage='Learn more'
/>
</a>
</WarningMessage>
);
}
return null;
};
export const WarningMessage: React.FC<React.PropsWithChildren> = ({
children,
}) => {
const styles = useSpring({
from: {
opacity: 0,
transform: 'scale(0.85, 0.75)',
},
to: {
opacity: 1,
transform: 'scale(1, 1)',
},
});
return (
<animated.div className='compose-form__warning' style={styles}>
{children}
</animated.div>
);
};

View file

@ -1,46 +0,0 @@
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import { me } from 'mastodon/initial_state';
import { HASHTAG_PATTERN_REGEX } from 'mastodon/utils/hashtags';
import Warning from '../components/warning';
const mapStateToProps = state => ({
needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', me, 'locked']),
hashtagWarning: state.getIn(['compose', 'privacy']) !== 'public' && HASHTAG_PATTERN_REGEX.test(state.getIn(['compose', 'text'])),
directMessageWarning: state.getIn(['compose', 'privacy']) === 'direct',
});
const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning }) => {
if (needsLockWarning) {
return <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <a href='/settings/profile'><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />;
}
if (hashtagWarning) {
return <Warning message={<FormattedMessage id='compose_form.hashtag_warning' defaultMessage="This post won't be listed under any hashtag as it is unlisted. Only public posts can be searched by hashtag." />} />;
}
if (directMessageWarning) {
const message = (
<span>
<FormattedMessage id='compose_form.encryption_warning' defaultMessage='Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.' /> <a href='/terms' target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a>
</span>
);
return <Warning message={message} />;
}
return null;
};
WarningWrapper.propTypes = {
needsLockWarning: PropTypes.bool,
hashtagWarning: PropTypes.bool,
directMessageWarning: PropTypes.bool,
};
export default connect(mapStateToProps)(WarningWrapper);

View file

@ -1,85 +0,0 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { debounce } from 'lodash';
import BlockIcon from '@/material-icons/400-24px/block-fill.svg?react';
import { Domain } from 'mastodon/components/domain';
import { fetchDomainBlocks, expandDomainBlocks } from '../../actions/domain_blocks';
import { LoadingIndicator } from '../../components/loading_indicator';
import ScrollableList from '../../components/scrollable_list';
import Column from '../ui/components/column';
const messages = defineMessages({
heading: { id: 'column.domain_blocks', defaultMessage: 'Blocked domains' },
});
const mapStateToProps = state => ({
domains: state.getIn(['domain_lists', 'blocks', 'items']),
hasMore: !!state.getIn(['domain_lists', 'blocks', 'next']),
});
class Blocks extends ImmutablePureComponent {
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
hasMore: PropTypes.bool,
domains: ImmutablePropTypes.orderedSet,
intl: PropTypes.object.isRequired,
multiColumn: PropTypes.bool,
};
UNSAFE_componentWillMount () {
this.props.dispatch(fetchDomainBlocks());
}
handleLoadMore = debounce(() => {
this.props.dispatch(expandDomainBlocks());
}, 300, { leading: true });
render () {
const { intl, domains, hasMore, multiColumn } = this.props;
if (!domains) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
const emptyMessage = <FormattedMessage id='empty_column.domain_blocks' defaultMessage='There are no blocked domains yet.' />;
return (
<Column bindToDocument={!multiColumn} icon='ban' iconComponent={BlockIcon} heading={intl.formatMessage(messages.heading)} alwaysShowBackButton>
<ScrollableList
scrollKey='domain_blocks'
onLoadMore={this.handleLoadMore}
hasMore={hasMore}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
>
{domains.map(domain =>
<Domain key={domain} domain={domain} />,
)}
</ScrollableList>
<Helmet>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}
export default connect(mapStateToProps)(injectIntl(Blocks));

View file

@ -0,0 +1,113 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import BlockIcon from '@/material-icons/400-24px/block-fill.svg?react';
import { apiGetDomainBlocks } from 'mastodon/api/domain_blocks';
import { Column } from 'mastodon/components/column';
import type { ColumnRef } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { Domain } from 'mastodon/components/domain';
import ScrollableList from 'mastodon/components/scrollable_list';
const messages = defineMessages({
heading: { id: 'column.domain_blocks', defaultMessage: 'Blocked domains' },
});
const Blocks: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
const intl = useIntl();
const [domains, setDomains] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [next, setNext] = useState<string | undefined>();
const hasMore = !!next;
const columnRef = useRef<ColumnRef>(null);
useEffect(() => {
setLoading(true);
void apiGetDomainBlocks()
.then(({ domains, links }) => {
const next = links.refs.find((link) => link.rel === 'next');
setLoading(false);
setDomains(domains);
setNext(next?.uri);
return '';
})
.catch(() => {
setLoading(false);
});
}, [setLoading, setDomains, setNext]);
const handleLoadMore = useCallback(() => {
setLoading(true);
void apiGetDomainBlocks(next)
.then(({ domains, links }) => {
const next = links.refs.find((link) => link.rel === 'next');
setLoading(false);
setDomains((previousDomains) => [...previousDomains, ...domains]);
setNext(next?.uri);
return '';
})
.catch(() => {
setLoading(false);
});
}, [setLoading, setDomains, setNext, next]);
const handleHeaderClick = useCallback(() => {
columnRef.current?.scrollTop();
}, []);
const emptyMessage = (
<FormattedMessage
id='empty_column.domain_blocks'
defaultMessage='There are no blocked domains yet.'
/>
);
return (
<Column
bindToDocument={!multiColumn}
ref={columnRef}
label={intl.formatMessage(messages.heading)}
>
<ColumnHeader
icon='ban'
iconComponent={BlockIcon}
title={intl.formatMessage(messages.heading)}
onClick={handleHeaderClick}
multiColumn={multiColumn}
showBackButton
/>
<ScrollableList
scrollKey='domain_blocks'
onLoadMore={handleLoadMore}
hasMore={hasMore}
isLoading={loading}
showLoading={loading && domains.length === 0}
emptyMessage={emptyMessage}
trackScroll={!multiColumn}
bindToDocument={!multiColumn}
>
{domains.map((domain) => (
<Domain key={domain} domain={domain} />
))}
</ScrollableList>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default Blocks;

View file

@ -1,5 +1,3 @@
/* eslint-disable import/no-commonjs --
We need to use CommonJS here due to preval */
// @preval
// http://www.unicode.org/Public/emoji/5.0/emoji-test.txt
// This file contains the compressed version of the emoji data from

View file

@ -33,11 +33,8 @@ function processEmojiMapData(
shortCode?: ShortCodesToEmojiDataKey,
) {
const [native, _filename] = emojiMapData;
let filename = emojiMapData[1];
if (!filename) {
// filename name can be derived from unicodeToFilename
filename = unicodeToFilename(native);
}
// filename name can be derived from unicodeToFilename
const filename = emojiMapData[1] ?? unicodeToFilename(native);
unicodeMapping[native] = {
shortCode,
filename,

View file

@ -1,6 +1,3 @@
/* eslint-disable import/no-commonjs --
We need to use CommonJS here as its imported into a preval file (`emoji_compressed.js`) */
// taken from:
// https://github.com/twitter/twemoji/blob/47732c7/twemoji-generator.js#L848-L866
exports.unicodeToFilename = (str) => {

View file

@ -1,6 +1,3 @@
/* eslint-disable import/no-commonjs --
We need to use CommonJS here as its imported into a preval file (`emoji_compressed.js`) */
function padLeft(str, num) {
while (str.length < num) {
str = '0' + str;

View file

@ -99,6 +99,7 @@ class Favourites extends ImmutablePureComponent {
onLoadMore={this.handleLoadMore}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
timelineId='favourites'
/>
<Helmet>

View file

@ -1,5 +1,5 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { PureComponent, useCallback, useMemo } from 'react';
import { defineMessages, injectIntl, FormattedMessage, FormattedDate } from 'react-intl';
@ -9,8 +9,7 @@ import { withRouter } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import TransitionMotion from 'react-motion/lib/TransitionMotion';
import spring from 'react-motion/lib/spring';
import { animated, useTransition } from '@react-spring/web';
import ReactSwipeableViews from 'react-swipeable-views';
import elephantUIPlane from '@/images/elephant_ui_plane.svg';
@ -239,72 +238,76 @@ class Reaction extends ImmutablePureComponent {
}
return (
<button className={classNames('reactions-bar__item', { active: reaction.get('me') })} onClick={this.handleClick} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} title={`:${shortCode}:`} style={this.props.style}>
<animated.button className={classNames('reactions-bar__item', { active: reaction.get('me') })} onClick={this.handleClick} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} title={`:${shortCode}:`} style={this.props.style}>
<span className='reactions-bar__item__emoji'><Emoji hovered={this.state.hovered} emoji={reaction.get('name')} emojiMap={this.props.emojiMap} /></span>
<span className='reactions-bar__item__count'><AnimatedNumber value={reaction.get('count')} /></span>
</button>
</animated.button>
);
}
}
class ReactionsBar extends ImmutablePureComponent {
const ReactionsBar = ({
announcementId,
reactions,
emojiMap,
addReaction,
removeReaction,
}) => {
const visibleReactions = useMemo(() => reactions.filter(x => x.get('count') > 0).toArray(), [reactions]);
static propTypes = {
announcementId: PropTypes.string.isRequired,
reactions: ImmutablePropTypes.list.isRequired,
addReaction: PropTypes.func.isRequired,
removeReaction: PropTypes.func.isRequired,
emojiMap: ImmutablePropTypes.map.isRequired,
};
const handleEmojiPick = useCallback((emoji) => {
addReaction(announcementId, emoji.native.replaceAll(/:/g, ''));
}, [addReaction, announcementId]);
handleEmojiPick = data => {
const { addReaction, announcementId } = this.props;
addReaction(announcementId, data.native.replace(/:/g, ''));
};
const transitions = useTransition(visibleReactions, {
from: {
scale: 0,
},
enter: {
scale: 1,
},
leave: {
scale: 0,
},
immediate: reduceMotion,
keys: visibleReactions.map(x => x.get('name')),
});
willEnter () {
return { scale: reduceMotion ? 1 : 0 };
}
return (
<div
className={classNames('reactions-bar', {
'reactions-bar--empty': visibleReactions.length === 0
})}
>
{transitions(({ scale }, reaction) => (
<Reaction
key={reaction.get('name')}
reaction={reaction}
style={{ transform: scale.to((s) => `scale(${s})`) }}
addReaction={addReaction}
removeReaction={removeReaction}
announcementId={announcementId}
emojiMap={emojiMap}
/>
))}
willLeave () {
return { scale: reduceMotion ? 0 : spring(0, { stiffness: 170, damping: 26 }) };
}
render () {
const { reactions } = this.props;
const visibleReactions = reactions.filter(x => x.get('count') > 0);
const styles = visibleReactions.map(reaction => ({
key: reaction.get('name'),
data: reaction,
style: { scale: reduceMotion ? 1 : spring(1, { stiffness: 150, damping: 13 }) },
})).toArray();
return (
<TransitionMotion styles={styles} willEnter={this.willEnter} willLeave={this.willLeave}>
{items => (
<div className={classNames('reactions-bar', { 'reactions-bar--empty': visibleReactions.isEmpty() })}>
{items.map(({ key, data, style }) => (
<Reaction
key={key}
reaction={data}
style={{ transform: `scale(${style.scale})`, position: style.scale < 0.5 ? 'absolute' : 'static' }}
announcementId={this.props.announcementId}
addReaction={this.props.addReaction}
removeReaction={this.props.removeReaction}
emojiMap={this.props.emojiMap}
/>
))}
{visibleReactions.size < 8 && <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} button={<Icon id='plus' icon={AddIcon} />} />}
</div>
)}
</TransitionMotion>
);
}
}
{visibleReactions.length < 8 && (
<EmojiPickerDropdown
onPickEmoji={handleEmojiPick}
button={<Icon id='plus' icon={AddIcon} />}
/>
)}
</div>
);
};
ReactionsBar.propTypes = {
announcementId: PropTypes.string.isRequired,
reactions: ImmutablePropTypes.list.isRequired,
addReaction: PropTypes.func.isRequired,
removeReaction: PropTypes.func.isRequired,
emojiMap: ImmutablePropTypes.map.isRequired,
};
class Announcement extends ImmutablePureComponent {

View file

@ -2,7 +2,7 @@ import { useCallback } from 'react';
import { removePictureInPicture } from 'mastodon/actions/picture_in_picture';
import Audio from 'mastodon/features/audio';
import Video from 'mastodon/features/video';
import { Video } from 'mastodon/features/video';
import { useAppDispatch, useAppSelector } from 'mastodon/store/typed_functions';
import Footer from './components/footer';
@ -35,6 +35,10 @@ export const PictureInPicture: React.FC = () => {
accentColor,
} = pipState;
if (!src) {
return null;
}
let player;
switch (type) {
@ -42,11 +46,10 @@ export const PictureInPicture: React.FC = () => {
player = (
<Video
src={src}
currentTime={currentTime}
volume={volume}
muted={muted}
autoPlay
inline
startTime={currentTime}
startVolume={volume}
startMuted={muted}
startPlaying
alwaysVisible
/>
);

View file

@ -1,17 +1,13 @@
import { useState, useEffect } from 'react';
import {
FormattedMessage,
FormattedDate,
useIntl,
defineMessages,
} from 'react-intl';
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
import { Helmet } from 'react-helmet';
import { apiGetPrivacyPolicy } from 'mastodon/api/instance';
import type { ApiPrivacyPolicyJSON } from 'mastodon/api_types/instance';
import { Column } from 'mastodon/components/column';
import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
import { Skeleton } from 'mastodon/components/skeleton';
const messages = defineMessages({
@ -58,7 +54,7 @@ const PrivacyPolicy: React.FC<{
date: loading ? (
<Skeleton width='10ch' />
) : (
<FormattedDate
<FormattedDateWrapper
value={response?.updated_at}
year='numeric'
month='short'

View file

@ -1,12 +1,12 @@
import { AlertsController } from 'mastodon/components/alerts_controller';
import ComposeFormContainer from 'mastodon/features/compose/containers/compose_form_container';
import LoadingBarContainer from 'mastodon/features/ui/containers/loading_bar_container';
import ModalContainer from 'mastodon/features/ui/containers/modal_container';
import NotificationsContainer from 'mastodon/features/ui/containers/notifications_container';
const Compose = () => (
<>
<ComposeFormContainer autoFocus withoutNavigation />
<NotificationsContainer />
<AlertsController />
<ModalContainer />
<LoadingBarContainer className='loading-bar' />
</>

View file

@ -6,7 +6,7 @@
import type { CSSProperties } from 'react';
import { useState, useRef, useCallback } from 'react';
import { FormattedDate, FormattedMessage } from 'react-intl';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
@ -15,12 +15,15 @@ import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?re
import { AnimatedNumber } from 'mastodon/components/animated_number';
import { ContentWarning } from 'mastodon/components/content_warning';
import EditedTimestamp from 'mastodon/components/edited_timestamp';
import { FilterWarning } from 'mastodon/components/filter_warning';
import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
import type { StatusLike } from 'mastodon/components/hashtag_bar';
import { getHashtagBarForStatus } from 'mastodon/components/hashtag_bar';
import { Icon } from 'mastodon/components/icon';
import { IconLogo } from 'mastodon/components/logo';
import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
import { VisibilityIcon } from 'mastodon/components/visibility_icon';
import { Video } from 'mastodon/features/video';
import { Avatar } from '../../../components/avatar';
import { DisplayName } from '../../../components/display_name';
@ -28,7 +31,6 @@ import MediaGallery from '../../../components/media_gallery';
import StatusContent from '../../../components/status_content';
import Audio from '../../audio';
import scheduleIdleTask from '../../ui/util/schedule_idle_task';
import Video from '../../video';
import Card from './card';
@ -36,7 +38,6 @@ interface VideoModalOptions {
startTime: number;
autoPlay?: boolean;
defaultVolume: number;
componentIndex: number;
}
export const DetailedStatus: React.FC<{
@ -70,6 +71,7 @@ export const DetailedStatus: React.FC<{
}) => {
const properStatus = status?.get('reblog') ?? status;
const [height, setHeight] = useState(0);
const [showDespiteFilter, setShowDespiteFilter] = useState(false);
const nodeRef = useRef<HTMLDivElement>();
const handleOpenVideo = useCallback(
@ -82,6 +84,10 @@ export const DetailedStatus: React.FC<{
[onOpenVideo, status],
);
const handleFilterToggle = useCallback(() => {
setShowDespiteFilter(!showDespiteFilter);
}, [showDespiteFilter, setShowDespiteFilter]);
const handleExpandedToggle = useCallback(() => {
if (onToggleHidden) onToggleHidden(status);
}, [onToggleHidden, status]);
@ -169,6 +175,7 @@ export const DetailedStatus: React.FC<{
onOpenMedia={onOpenMedia}
visible={showMedia}
onToggleVisibility={onToggleMediaVisibility}
matchedFilters={status.get('matched_media_filters')}
/>
);
} else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
@ -195,6 +202,7 @@ export const DetailedStatus: React.FC<{
blurhash={attachment.get('blurhash')}
height={150}
onToggleVisibility={onToggleMediaVisibility}
matchedFilters={status.get('matched_media_filters')}
/>
);
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
@ -212,12 +220,11 @@ export const DetailedStatus: React.FC<{
src={attachment.get('url')}
alt={description}
lang={language}
width={300}
height={150}
onOpenVideo={handleOpenVideo}
sensitive={status.get('sensitive')}
visible={showMedia}
onToggleVisibility={onToggleMediaVisibility}
matchedFilters={status.get('matched_media_filters')}
/>
);
}
@ -292,8 +299,12 @@ export const DetailedStatus: React.FC<{
const { statusContentProps, hashtagBar } = getHashtagBarForStatus(
status as StatusLike,
);
const matchedFilters = status.get('matched_filters');
const expanded =
!status.get('hidden') || status.get('spoiler_text').length === 0;
(!matchedFilters || showDespiteFilter) &&
(!status.get('hidden') || status.get('spoiler_text').length === 0);
return (
<div style={outerStyle}>
@ -334,17 +345,26 @@ export const DetailedStatus: React.FC<{
)}
</Link>
{status.get('spoiler_text').length > 0 && (
<ContentWarning
text={
status.getIn(['translation', 'spoilerHtml']) ||
status.get('spoilerHtml')
}
expanded={expanded}
onClick={handleExpandedToggle}
{matchedFilters && (
<FilterWarning
title={matchedFilters.join(', ')}
expanded={showDespiteFilter}
onClick={handleFilterToggle}
/>
)}
{status.get('spoiler_text').length > 0 &&
(!matchedFilters || showDespiteFilter) && (
<ContentWarning
text={
status.getIn(['translation', 'spoilerHtml']) ||
status.get('spoilerHtml')
}
expanded={expanded}
onClick={handleExpandedToggle}
/>
)}
{expanded && (
<>
<StatusContent
@ -366,7 +386,7 @@ export const DetailedStatus: React.FC<{
target='_blank'
rel='noopener noreferrer'
>
<FormattedDate
<FormattedDateWrapper
value={new Date(status.get('created_at') as string)}
year='numeric'
month='short'

View file

@ -138,7 +138,7 @@ const makeMapStateToProps = () => {
});
const mapStateToProps = (state, props) => {
const status = getStatus(state, { id: props.params.statusId });
const status = getStatus(state, { id: props.params.statusId, contextType: 'detailed' });
let ancestorsIds = ImmutableList();
let descendantsIds = ImmutableList();

View file

@ -1,175 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import classNames from 'classnames';
import { LoadingBar } from 'react-redux-loading-bar';
import ZoomableImage from './zoomable_image';
export default class ImageLoader extends PureComponent {
static propTypes = {
alt: PropTypes.string,
lang: PropTypes.string,
src: PropTypes.string.isRequired,
previewSrc: PropTypes.string,
width: PropTypes.number,
height: PropTypes.number,
onClick: PropTypes.func,
zoomedIn: PropTypes.bool,
};
static defaultProps = {
alt: '',
lang: '',
width: null,
height: null,
};
state = {
loading: true,
error: false,
width: null,
};
removers = [];
canvas = null;
get canvasContext() {
if (!this.canvas) {
return null;
}
this._canvasContext = this._canvasContext || this.canvas.getContext('2d');
return this._canvasContext;
}
componentDidMount () {
this.loadImage(this.props);
}
UNSAFE_componentWillReceiveProps (nextProps) {
if (this.props.src !== nextProps.src) {
this.loadImage(nextProps);
}
}
componentWillUnmount () {
this.removeEventListeners();
}
loadImage (props) {
this.removeEventListeners();
this.setState({ loading: true, error: false });
Promise.all([
props.previewSrc && this.loadPreviewCanvas(props),
this.hasSize() && this.loadOriginalImage(props),
].filter(Boolean))
.then(() => {
this.setState({ loading: false, error: false });
this.clearPreviewCanvas();
})
.catch(() => this.setState({ loading: false, error: true }));
}
loadPreviewCanvas = ({ previewSrc, width, height }) => new Promise((resolve, reject) => {
const image = new Image();
const removeEventListeners = () => {
image.removeEventListener('error', handleError);
image.removeEventListener('load', handleLoad);
};
const handleError = () => {
removeEventListeners();
reject();
};
const handleLoad = () => {
removeEventListeners();
this.canvasContext.drawImage(image, 0, 0, width, height);
resolve();
};
image.addEventListener('error', handleError);
image.addEventListener('load', handleLoad);
image.src = previewSrc;
this.removers.push(removeEventListeners);
});
clearPreviewCanvas () {
const { width, height } = this.canvas;
this.canvasContext.clearRect(0, 0, width, height);
}
loadOriginalImage = ({ src }) => new Promise((resolve, reject) => {
const image = new Image();
const removeEventListeners = () => {
image.removeEventListener('error', handleError);
image.removeEventListener('load', handleLoad);
};
const handleError = () => {
removeEventListeners();
reject();
};
const handleLoad = () => {
removeEventListeners();
resolve();
};
image.addEventListener('error', handleError);
image.addEventListener('load', handleLoad);
image.src = src;
this.removers.push(removeEventListeners);
});
removeEventListeners () {
this.removers.forEach(listeners => listeners());
this.removers = [];
}
hasSize () {
const { width, height } = this.props;
return typeof width === 'number' && typeof height === 'number';
}
setCanvasRef = c => {
this.canvas = c;
if (c) this.setState({ width: c.offsetWidth });
};
render () {
const { alt, lang, src, width, height, onClick, zoomedIn } = this.props;
const { loading } = this.state;
const className = classNames('image-loader', {
'image-loader--loading': loading,
'image-loader--amorphous': !this.hasSize(),
});
return (
<div className={className}>
{loading ? (
<>
<div className='loading-bar__container' style={{ width: this.state.width || width }}>
<LoadingBar className='loading-bar' loading={1} />
</div>
<canvas
className='image-loader__preview-canvas'
ref={this.setCanvasRef}
width={width}
height={height}
/>
</>
) : (
<ZoomableImage
alt={alt}
lang={lang}
src={src}
onClick={onClick}
width={width}
height={height}
zoomedIn={zoomedIn}
/>
)}
</div>
);
}
}

View file

@ -1,65 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import classNames from 'classnames';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { IconButton } from 'mastodon/components/icon_button';
import ImageLoader from './image_loader';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
});
class ImageModal extends PureComponent {
static propTypes = {
src: PropTypes.string.isRequired,
alt: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
state = {
navigationHidden: false,
};
toggleNavigation = () => {
this.setState(prevState => ({
navigationHidden: !prevState.navigationHidden,
}));
};
render () {
const { intl, src, alt, onClose } = this.props;
const { navigationHidden } = this.state;
const navigationClassName = classNames('media-modal__navigation', {
'media-modal__navigation--hidden': navigationHidden,
});
return (
<div className='modal-root__modal media-modal'>
<div className='media-modal__closer' role='presentation' onClick={onClose} >
<ImageLoader
src={src}
width={400}
height={400}
alt={alt}
onClick={this.toggleNavigation}
/>
</div>
<div className={navigationClassName}>
<IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' iconComponent={CloseIcon} onClick={onClose} size={40} />
</div>
</div>
);
}
}
export default injectIntl(ImageModal);

View file

@ -0,0 +1,61 @@
import { useCallback, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import classNames from 'classnames';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { IconButton } from 'mastodon/components/icon_button';
import { ZoomableImage } from './zoomable_image';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
});
export const ImageModal: React.FC<{
src: string;
alt: string;
onClose: () => void;
}> = ({ src, alt, onClose }) => {
const intl = useIntl();
const [navigationHidden, setNavigationHidden] = useState(false);
const toggleNavigation = useCallback(() => {
setNavigationHidden((prevState) => !prevState);
}, [setNavigationHidden]);
const navigationClassName = classNames('media-modal__navigation', {
'media-modal__navigation--hidden': navigationHidden,
});
return (
<div className='modal-root__modal media-modal'>
<div
className='media-modal__closer'
role='presentation'
onClick={onClose}
>
<ZoomableImage
src={src}
width={400}
height={400}
alt={alt}
onClick={toggleNavigation}
/>
</div>
<div className={navigationClassName}>
<div className='media-modal__buttons'>
<IconButton
className='media-modal__close'
title={intl.formatMessage(messages.close)}
icon='times'
iconComponent={CloseIcon}
onClick={onClose}
/>
</div>
</div>
</div>
);
};

View file

@ -19,10 +19,10 @@ import { GIFV } from 'mastodon/components/gifv';
import { Icon } from 'mastodon/components/icon';
import { IconButton } from 'mastodon/components/icon_button';
import Footer from 'mastodon/features/picture_in_picture/components/footer';
import Video from 'mastodon/features/video';
import { Video } from 'mastodon/features/video';
import { disableSwiping } from 'mastodon/initial_state';
import ImageLoader from './image_loader';
import { ZoomableImage } from './zoomable_image';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
@ -59,6 +59,12 @@ class MediaModal extends ImmutablePureComponent {
}));
};
handleZoomChange = (zoomedIn) => {
this.setState({
zoomedIn,
});
};
handleSwipe = (index) => {
this.setState({
index: index % this.props.media.size,
@ -165,23 +171,26 @@ class MediaModal extends ImmutablePureComponent {
const leftNav = media.size > 1 && <button tabIndex={0} className='media-modal__nav media-modal__nav--left' onClick={this.handlePrevClick} aria-label={intl.formatMessage(messages.previous)}><Icon id='chevron-left' icon={ChevronLeftIcon} /></button>;
const rightNav = media.size > 1 && <button tabIndex={0} className='media-modal__nav media-modal__nav--right' onClick={this.handleNextClick} aria-label={intl.formatMessage(messages.next)}><Icon id='chevron-right' icon={ChevronRightIcon} /></button>;
const content = media.map((image) => {
const content = media.map((image, idx) => {
const width = image.getIn(['meta', 'original', 'width']) || null;
const height = image.getIn(['meta', 'original', 'height']) || null;
const description = image.getIn(['translation', 'description']) || image.get('description');
if (image.get('type') === 'image') {
return (
<ImageLoader
previewSrc={image.get('preview_url')}
<ZoomableImage
src={image.get('url')}
blurhash={image.get('blurhash')}
width={width}
height={height}
alt={description}
lang={lang}
key={image.get('url')}
onClick={this.handleToggleNavigation}
zoomedIn={zoomedIn}
onDoubleClick={this.handleZoomClick}
onClose={onClose}
onZoomChange={this.handleZoomChange}
zoomedIn={zoomedIn && idx === index}
/>
);
} else if (image.get('type') === 'video') {
@ -196,9 +205,9 @@ class MediaModal extends ImmutablePureComponent {
height={image.get('height')}
frameRate={image.getIn(['meta', 'original', 'frame_rate'])}
aspectRatio={`${image.getIn(['meta', 'original', 'width'])} / ${image.getIn(['meta', 'original', 'height'])}`}
currentTime={currentTime || 0}
autoPlay={autoPlay || false}
volume={volume || 1}
startTime={currentTime || 0}
startPlaying={autoPlay || false}
startVolume={volume || 1}
onCloseVideo={onClose}
detailed
alt={description}
@ -262,7 +271,7 @@ class MediaModal extends ImmutablePureComponent {
onChangeIndex={this.handleSwipe}
onTransitionEnd={this.handleTransitionEnd}
index={index}
disabled={disableSwiping}
disabled={disableSwiping || zoomedIn}
>
{content}
</ReactSwipeableViews>

View file

@ -39,7 +39,7 @@ import {
ConfirmFollowToListModal,
ConfirmMissingAltTextModal,
} from './confirmation_modals';
import ImageModal from './image_modal';
import { ImageModal } from './image_modal';
import MediaModal from './media_modal';
import { ModalPlaceholder } from './modal_placeholder';
import VideoModal from './video_modal';

View file

@ -28,6 +28,7 @@ import SearchIcon from '@/material-icons/400-24px/search.svg?react';
import SettingsIcon from '@/material-icons/400-24px/settings.svg?react';
import StarActiveIcon from '@/material-icons/400-24px/star-fill.svg?react';
import StarIcon from '@/material-icons/400-24px/star.svg?react';
import FurryIcon from '@/material-icons/400-24px/pets.svg?react';
import { fetchFollowRequests } from 'mastodon/actions/accounts';
import { IconWithBadge } from 'mastodon/components/icon_with_badge';
import { WordmarkLogo } from 'mastodon/components/logo';
@ -48,6 +49,7 @@ const messages = defineMessages({
notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' },
explore: { id: 'explore.title', defaultMessage: 'Explore' },
firehose: { id: 'column.firehose', defaultMessage: 'Live feeds' },
furry: { id: 'tabs_bar.furry', defaultMessage: 'Furry' },
direct: { id: 'navigation_bar.direct', defaultMessage: 'Private mentions' },
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' },
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
@ -160,7 +162,10 @@ class NavigationPanel extends Component {
)}
{(signedIn || timelinePreview) && (
<>
<ColumnLink transparent to='/public/local' isActive={this.isFirehoseActive} icon='globe' iconComponent={PublicIcon} text={intl.formatMessage(messages.firehose)} />
<ColumnLink transparent to='/tags/furry' icon='pets' iconComponent={FurryIcon} text={intl.formatMessage(messages.furry)} />
</>
)}
{!signedIn && (

View file

@ -1,55 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { FormattedMessage } from 'react-intl';
import spring from 'react-motion/lib/spring';
import Motion from '../util/optional_motion';
export default class UploadArea extends PureComponent {
static propTypes = {
active: PropTypes.bool,
onClose: PropTypes.func,
};
handleKeyUp = (e) => {
const keyCode = e.keyCode;
if (this.props.active) {
switch(keyCode) {
case 27:
e.preventDefault();
e.stopPropagation();
this.props.onClose();
break;
}
}
};
componentDidMount () {
window.addEventListener('keyup', this.handleKeyUp, false);
}
componentWillUnmount () {
window.removeEventListener('keyup', this.handleKeyUp);
}
render () {
const { active } = this.props;
return (
<Motion defaultStyle={{ backgroundOpacity: 0, backgroundScale: 0.95 }} style={{ backgroundOpacity: spring(active ? 1 : 0, { stiffness: 150, damping: 15 }), backgroundScale: spring(active ? 1 : 0.95, { stiffness: 200, damping: 3 }) }}>
{({ backgroundOpacity, backgroundScale }) => (
<div className='upload-area' style={{ visibility: active ? 'visible' : 'hidden', opacity: backgroundOpacity }}>
<div className='upload-area__drop'>
<div className='upload-area__background' style={{ transform: `scale(${backgroundScale})` }} />
<div className='upload-area__content'><FormattedMessage id='upload_area.title' defaultMessage='Drag & drop to upload' /></div>
</div>
</div>
)}
</Motion>
);
}
}

View file

@ -0,0 +1,78 @@
import { useCallback, useEffect } from 'react';
import { FormattedMessage } from 'react-intl';
import { animated, config, useSpring } from '@react-spring/web';
import { reduceMotion } from 'mastodon/initial_state';
interface UploadAreaProps {
active?: boolean;
onClose: () => void;
}
export const UploadArea: React.FC<UploadAreaProps> = ({ active, onClose }) => {
const handleKeyUp = useCallback(
(e: KeyboardEvent) => {
if (active && e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
onClose();
}
},
[active, onClose],
);
useEffect(() => {
window.addEventListener('keyup', handleKeyUp, false);
return () => {
window.removeEventListener('keyup', handleKeyUp);
};
}, [handleKeyUp]);
const wrapperAnimStyles = useSpring({
from: {
opacity: 0,
},
to: {
opacity: 1,
},
reverse: !active,
immediate: reduceMotion,
});
const backgroundAnimStyles = useSpring({
from: {
transform: 'scale(0.95)',
},
to: {
transform: 'scale(1)',
},
reverse: !active,
config: config.wobbly,
immediate: reduceMotion,
});
return (
<animated.div
className='upload-area'
style={{
...wrapperAnimStyles,
visibility: active ? 'visible' : 'hidden',
}}
>
<div className='upload-area__drop'>
<animated.div
className='upload-area__background'
style={backgroundAnimStyles}
/>
<div className='upload-area__content'>
<FormattedMessage
id='upload_area.title'
defaultMessage='Drag & drop to upload'
/>
</div>
</div>
</animated.div>
);
};

View file

@ -6,7 +6,7 @@ import { connect } from 'react-redux';
import { getAverageFromBlurhash } from 'mastodon/blurhash';
import Footer from 'mastodon/features/picture_in_picture/components/footer';
import Video from 'mastodon/features/video';
import { Video } from 'mastodon/features/video';
const mapStateToProps = (state, { statusId }) => ({
status: state.getIn(['statuses', statusId]),
@ -56,9 +56,9 @@ class VideoModal extends ImmutablePureComponent {
aspectRatio={`${media.getIn(['meta', 'original', 'width'])} / ${media.getIn(['meta', 'original', 'height'])}`}
blurhash={media.get('blurhash')}
src={media.get('url')}
currentTime={options.startTime}
autoPlay={options.autoPlay}
volume={options.defaultVolume}
startTime={options.startTime}
startPlaying={options.autoPlay}
startVolume={options.defaultVolume}
onCloseVideo={onClose}
autoFocus
detailed

View file

@ -1,402 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
const MIN_SCALE = 1;
const MAX_SCALE = 4;
const NAV_BAR_HEIGHT = 66;
const getMidpoint = (p1, p2) => ({
x: (p1.clientX + p2.clientX) / 2,
y: (p1.clientY + p2.clientY) / 2,
});
const getDistance = (p1, p2) =>
Math.sqrt(Math.pow(p1.clientX - p2.clientX, 2) + Math.pow(p1.clientY - p2.clientY, 2));
const clamp = (min, max, value) => Math.min(max, Math.max(min, value));
// Normalizing mousewheel speed across browsers
// copy from: https://github.com/facebookarchive/fixed-data-table/blob/master/src/vendor_upstream/dom/normalizeWheel.js
const normalizeWheel = event => {
// Reasonable defaults
const PIXEL_STEP = 10;
const LINE_HEIGHT = 40;
const PAGE_HEIGHT = 800;
let sX = 0,
sY = 0, // spinX, spinY
pX = 0,
pY = 0; // pixelX, pixelY
// Legacy
if ('detail' in event) {
sY = event.detail;
}
if ('wheelDelta' in event) {
sY = -event.wheelDelta / 120;
}
if ('wheelDeltaY' in event) {
sY = -event.wheelDeltaY / 120;
}
if ('wheelDeltaX' in event) {
sX = -event.wheelDeltaX / 120;
}
// side scrolling on FF with DOMMouseScroll
if ('axis' in event && event.axis === event.HORIZONTAL_AXIS) {
sX = sY;
sY = 0;
}
pX = sX * PIXEL_STEP;
pY = sY * PIXEL_STEP;
if ('deltaY' in event) {
pY = event.deltaY;
}
if ('deltaX' in event) {
pX = event.deltaX;
}
if ((pX || pY) && event.deltaMode) {
if (event.deltaMode === 1) { // delta in LINE units
pX *= LINE_HEIGHT;
pY *= LINE_HEIGHT;
} else { // delta in PAGE units
pX *= PAGE_HEIGHT;
pY *= PAGE_HEIGHT;
}
}
// Fall-back if spin cannot be determined
if (pX && !sX) {
sX = (pX < 1) ? -1 : 1;
}
if (pY && !sY) {
sY = (pY < 1) ? -1 : 1;
}
return {
spinX: sX,
spinY: sY,
pixelX: pX,
pixelY: pY,
};
};
class ZoomableImage extends PureComponent {
static propTypes = {
alt: PropTypes.string,
lang: PropTypes.string,
src: PropTypes.string.isRequired,
width: PropTypes.number,
height: PropTypes.number,
onClick: PropTypes.func,
zoomedIn: PropTypes.bool,
};
static defaultProps = {
alt: '',
lang: '',
width: null,
height: null,
};
state = {
scale: MIN_SCALE,
zoomMatrix: {
type: null, // 'width' 'height'
fullScreen: null, // bool
rate: null, // full screen scale rate
clientWidth: null,
clientHeight: null,
offsetWidth: null,
offsetHeight: null,
clientHeightFixed: null,
scrollTop: null,
scrollLeft: null,
translateX: null,
translateY: null,
},
dragPosition: { top: 0, left: 0, x: 0, y: 0 },
dragged: false,
lockScroll: { x: 0, y: 0 },
lockTranslate: { x: 0, y: 0 },
};
removers = [];
container = null;
image = null;
lastTouchEndTime = 0;
lastDistance = 0;
componentDidMount () {
let handler = this.handleTouchStart;
this.container.addEventListener('touchstart', handler);
this.removers.push(() => this.container.removeEventListener('touchstart', handler));
handler = this.handleTouchMove;
// on Chrome 56+, touch event listeners will default to passive
// https://www.chromestatus.com/features/5093566007214080
this.container.addEventListener('touchmove', handler, { passive: false });
this.removers.push(() => this.container.removeEventListener('touchend', handler));
handler = this.mouseDownHandler;
this.container.addEventListener('mousedown', handler);
this.removers.push(() => this.container.removeEventListener('mousedown', handler));
handler = this.mouseWheelHandler;
this.container.addEventListener('wheel', handler);
this.removers.push(() => this.container.removeEventListener('wheel', handler));
// Old Chrome
this.container.addEventListener('mousewheel', handler);
this.removers.push(() => this.container.removeEventListener('mousewheel', handler));
// Old Firefox
this.container.addEventListener('DOMMouseScroll', handler);
this.removers.push(() => this.container.removeEventListener('DOMMouseScroll', handler));
this._initZoomMatrix();
}
componentWillUnmount () {
this._removeEventListeners();
}
componentDidUpdate (prevProps) {
if (prevProps.zoomedIn !== this.props.zoomedIn) {
this._toggleZoom();
}
}
_removeEventListeners () {
this.removers.forEach(listeners => listeners());
this.removers = [];
}
mouseWheelHandler = e => {
e.preventDefault();
const event = normalizeWheel(e);
if (this.state.zoomMatrix.type === 'width') {
// full width, scroll vertical
this.container.scrollTop = Math.max(this.container.scrollTop + event.pixelY, this.state.lockScroll.y);
} else {
// full height, scroll horizontal
this.container.scrollLeft = Math.max(this.container.scrollLeft + event.pixelY, this.state.lockScroll.x);
}
// lock horizontal scroll
this.container.scrollLeft = Math.max(this.container.scrollLeft + event.pixelX, this.state.lockScroll.x);
};
mouseDownHandler = e => {
this.setState({ dragPosition: {
left: this.container.scrollLeft,
top: this.container.scrollTop,
// Get the current mouse position
x: e.clientX,
y: e.clientY,
} });
this.image.addEventListener('mousemove', this.mouseMoveHandler);
this.image.addEventListener('mouseup', this.mouseUpHandler);
};
mouseMoveHandler = e => {
const dx = e.clientX - this.state.dragPosition.x;
const dy = e.clientY - this.state.dragPosition.y;
this.container.scrollLeft = Math.max(this.state.dragPosition.left - dx, this.state.lockScroll.x);
this.container.scrollTop = Math.max(this.state.dragPosition.top - dy, this.state.lockScroll.y);
this.setState({ dragged: true });
};
mouseUpHandler = () => {
this.image.removeEventListener('mousemove', this.mouseMoveHandler);
this.image.removeEventListener('mouseup', this.mouseUpHandler);
};
handleTouchStart = e => {
if (e.touches.length !== 2) return;
this.lastDistance = getDistance(...e.touches);
};
handleTouchMove = e => {
const { scrollTop, scrollHeight, clientHeight } = this.container;
if (e.touches.length === 1 && scrollTop !== scrollHeight - clientHeight) {
// prevent propagating event to MediaModal
e.stopPropagation();
return;
}
if (e.touches.length !== 2) return;
e.preventDefault();
e.stopPropagation();
const distance = getDistance(...e.touches);
const midpoint = getMidpoint(...e.touches);
const _MAX_SCALE = Math.max(MAX_SCALE, this.state.zoomMatrix.rate);
const scale = clamp(MIN_SCALE, _MAX_SCALE, this.state.scale * distance / this.lastDistance);
this._zoom(scale, midpoint);
this.lastMidpoint = midpoint;
this.lastDistance = distance;
};
_zoom(nextScale, midpoint) {
const { scale, zoomMatrix } = this.state;
const { scrollLeft, scrollTop } = this.container;
// math memo:
// x = (scrollLeft + midpoint.x) / scrollWidth
// x' = (nextScrollLeft + midpoint.x) / nextScrollWidth
// scrollWidth = clientWidth * scale
// scrollWidth' = clientWidth * nextScale
// Solve x = x' for nextScrollLeft
const nextScrollLeft = (scrollLeft + midpoint.x) * nextScale / scale - midpoint.x;
const nextScrollTop = (scrollTop + midpoint.y) * nextScale / scale - midpoint.y;
this.setState({ scale: nextScale }, () => {
this.container.scrollLeft = nextScrollLeft;
this.container.scrollTop = nextScrollTop;
// reset the translateX/Y constantly
if (nextScale < zoomMatrix.rate) {
this.setState({
lockTranslate: {
x: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateX * ((nextScale - MIN_SCALE) / (zoomMatrix.rate - MIN_SCALE)),
y: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateY * ((nextScale - MIN_SCALE) / (zoomMatrix.rate - MIN_SCALE)),
},
});
}
});
}
handleClick = e => {
// don't propagate event to MediaModal
e.stopPropagation();
const dragged = this.state.dragged;
this.setState({ dragged: false });
if (dragged) return;
const handler = this.props.onClick;
if (handler) handler();
};
handleMouseDown = e => {
e.preventDefault();
};
_initZoomMatrix = () => {
const { width, height } = this.props;
const { clientWidth, clientHeight } = this.container;
const { offsetWidth, offsetHeight } = this.image;
const clientHeightFixed = clientHeight - NAV_BAR_HEIGHT;
const type = width / height < clientWidth / clientHeightFixed ? 'width' : 'height';
const fullScreen = type === 'width' ? width > clientWidth : height > clientHeightFixed;
const rate = type === 'width' ? Math.min(clientWidth, width) / offsetWidth : Math.min(clientHeightFixed, height) / offsetHeight;
const scrollTop = type === 'width' ? (clientHeight - offsetHeight) / 2 - NAV_BAR_HEIGHT : (clientHeightFixed - offsetHeight) / 2;
const scrollLeft = (clientWidth - offsetWidth) / 2;
const translateX = type === 'width' ? (width - offsetWidth) / (2 * rate) : 0;
const translateY = type === 'height' ? (height - offsetHeight) / (2 * rate) : 0;
this.setState({
zoomMatrix: {
type: type,
fullScreen: fullScreen,
rate: rate,
clientWidth: clientWidth,
clientHeight: clientHeight,
offsetWidth: offsetWidth,
offsetHeight: offsetHeight,
clientHeightFixed: clientHeightFixed,
scrollTop: scrollTop,
scrollLeft: scrollLeft,
translateX: translateX,
translateY: translateY,
},
});
};
_toggleZoom () {
const { scale, zoomMatrix } = this.state;
if ( scale >= zoomMatrix.rate ) {
this.setState({
scale: MIN_SCALE,
lockScroll: {
x: 0,
y: 0,
},
lockTranslate: {
x: 0,
y: 0,
},
}, () => {
this.container.scrollLeft = 0;
this.container.scrollTop = 0;
});
} else {
this.setState({
scale: zoomMatrix.rate,
lockScroll: {
x: zoomMatrix.scrollLeft,
y: zoomMatrix.scrollTop,
},
lockTranslate: {
x: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateX,
y: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateY,
},
}, () => {
this.container.scrollLeft = zoomMatrix.scrollLeft;
this.container.scrollTop = zoomMatrix.scrollTop;
});
}
}
setContainerRef = c => {
this.container = c;
};
setImageRef = c => {
this.image = c;
};
render () {
const { alt, lang, src, width, height } = this.props;
const { scale, lockTranslate, dragged } = this.state;
const overflow = scale === MIN_SCALE ? 'hidden' : 'scroll';
const cursor = scale === MIN_SCALE ? null : (dragged ? 'grabbing' : 'grab');
return (
<div
className='zoomable-image'
ref={this.setContainerRef}
style={{ overflow, cursor, userSelect: 'none' }}
>
<img
role='presentation'
ref={this.setImageRef}
alt={alt}
title={alt}
lang={lang}
src={src}
width={width}
height={height}
style={{
transform: `scale(${scale}) translate(-${lockTranslate.x}px, -${lockTranslate.y}px)`,
transformOrigin: '0 0',
}}
draggable={false}
onClick={this.handleClick}
onMouseDown={this.handleMouseDown}
/>
</div>
);
}
}
export default ZoomableImage;

View file

@ -0,0 +1,319 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import classNames from 'classnames';
import { useSpring, animated, config } from '@react-spring/web';
import { createUseGesture, dragAction, pinchAction } from '@use-gesture/react';
import { Blurhash } from 'mastodon/components/blurhash';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
const MIN_SCALE = 1;
const MAX_SCALE = 4;
const DOUBLE_CLICK_THRESHOLD = 250;
interface ZoomMatrix {
containerWidth: number;
containerHeight: number;
imageWidth: number;
imageHeight: number;
initialScale: number;
}
const createZoomMatrix = (
container: HTMLElement,
image: HTMLImageElement,
fullWidth: number,
fullHeight: number,
): ZoomMatrix => {
const { clientWidth, clientHeight } = container;
const { offsetWidth, offsetHeight } = image;
const type =
fullWidth / fullHeight < clientWidth / clientHeight ? 'width' : 'height';
const initialScale =
type === 'width'
? Math.min(clientWidth, fullWidth) / offsetWidth
: Math.min(clientHeight, fullHeight) / offsetHeight;
return {
containerWidth: clientWidth,
containerHeight: clientHeight,
imageWidth: offsetWidth,
imageHeight: offsetHeight,
initialScale,
};
};
const useGesture = createUseGesture([dragAction, pinchAction]);
const getBounds = (zoomMatrix: ZoomMatrix | null, scale: number) => {
if (!zoomMatrix || scale === MIN_SCALE) {
return {
left: -Infinity,
right: Infinity,
top: -Infinity,
bottom: Infinity,
};
}
const { containerWidth, containerHeight, imageWidth, imageHeight } =
zoomMatrix;
const bounds = {
left: -Math.max(imageWidth * scale - containerWidth, 0) / 2,
right: Math.max(imageWidth * scale - containerWidth, 0) / 2,
top: -Math.max(imageHeight * scale - containerHeight, 0) / 2,
bottom: Math.max(imageHeight * scale - containerHeight, 0) / 2,
};
return bounds;
};
interface ZoomableImageProps {
alt?: string;
lang?: string;
src: string;
width: number;
height: number;
onClick?: () => void;
onDoubleClick?: () => void;
onClose?: () => void;
onZoomChange?: (zoomedIn: boolean) => void;
zoomedIn?: boolean;
blurhash?: string;
}
export const ZoomableImage: React.FC<ZoomableImageProps> = ({
alt = '',
lang = '',
src,
width,
height,
onClick,
onDoubleClick,
onClose,
onZoomChange,
zoomedIn,
blurhash,
}) => {
useEffect(() => {
const handler = (e: Event) => {
e.preventDefault();
};
document.addEventListener('gesturestart', handler);
document.addEventListener('gesturechange', handler);
document.addEventListener('gestureend', handler);
return () => {
document.removeEventListener('gesturestart', handler);
document.removeEventListener('gesturechange', handler);
document.removeEventListener('gestureend', handler);
};
}, []);
const [dragging, setDragging] = useState(false);
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const imageRef = useRef<HTMLImageElement>(null);
const doubleClickTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>();
const zoomMatrixRef = useRef<ZoomMatrix | null>(null);
const [style, api] = useSpring(() => ({
x: 0,
y: 0,
scale: 1,
onRest: {
scale({ value }) {
if (!onZoomChange) {
return;
}
if (value === MIN_SCALE) {
onZoomChange(false);
} else {
onZoomChange(true);
}
},
},
}));
useGesture(
{
onDrag({
pinching,
cancel,
active,
last,
offset: [x, y],
velocity: [, vy],
direction: [, dy],
tap,
}) {
if (tap) {
if (!doubleClickTimeoutRef.current) {
doubleClickTimeoutRef.current = setTimeout(() => {
onClick?.();
doubleClickTimeoutRef.current = null;
}, DOUBLE_CLICK_THRESHOLD);
} else {
clearTimeout(doubleClickTimeoutRef.current);
doubleClickTimeoutRef.current = null;
onDoubleClick?.();
}
return;
}
if (!zoomedIn) {
// Swipe up/down to dismiss parent
if (last) {
if ((vy > 0.5 && dy !== 0) || Math.abs(y) > 150) {
onClose?.();
}
void api.start({ y: 0, config: config.wobbly });
return;
} else if (dy !== 0) {
void api.start({ y, immediate: true });
return;
}
cancel();
return;
}
if (pinching) {
cancel();
return;
}
if (active) {
setDragging(true);
} else {
setDragging(false);
}
void api.start({ x, y });
},
onPinch({ origin: [ox, oy], first, movement: [ms], offset: [s], memo }) {
if (!imageRef.current) {
return;
}
if (first) {
const { width, height, x, y } =
imageRef.current.getBoundingClientRect();
const tx = ox - (x + width / 2);
const ty = oy - (y + height / 2);
memo = [style.x.get(), style.y.get(), tx, ty];
}
const x = memo[0] - (ms - 1) * memo[2]; // eslint-disable-line @typescript-eslint/no-unsafe-member-access
const y = memo[1] - (ms - 1) * memo[3]; // eslint-disable-line @typescript-eslint/no-unsafe-member-access
void api.start({ scale: s, x, y });
return memo as [number, number, number, number];
},
},
{
target: imageRef,
drag: {
from: () => [style.x.get(), style.y.get()],
filterTaps: true,
bounds: () => getBounds(zoomMatrixRef.current, style.scale.get()),
rubberband: true,
},
pinch: {
scaleBounds: {
min: MIN_SCALE,
max: MAX_SCALE,
},
rubberband: true,
},
},
);
useEffect(() => {
if (!loaded || !containerRef.current || !imageRef.current) {
return;
}
zoomMatrixRef.current = createZoomMatrix(
containerRef.current,
imageRef.current,
width,
height,
);
if (!zoomedIn) {
void api.start({ scale: MIN_SCALE, x: 0, y: 0 });
} else if (style.scale.get() === MIN_SCALE) {
void api.start({ scale: zoomMatrixRef.current.initialScale, x: 0, y: 0 });
}
}, [api, style.scale, zoomedIn, width, height, loaded]);
const handleClick = useCallback((e: React.MouseEvent) => {
// This handler exists to cancel the onClick handler on the media modal which would
// otherwise close the modal. It cannot be used for actual click handling because
// we don't know if the user is about to pan the image or not.
e.preventDefault();
e.stopPropagation();
}, []);
const handleLoad = useCallback(() => {
setLoaded(true);
}, [setLoaded]);
const handleError = useCallback(() => {
setError(true);
}, [setError]);
return (
<div
className={classNames('zoomable-image', {
'zoomable-image--zoomed-in': zoomedIn,
'zoomable-image--error': error,
'zoomable-image--dragging': dragging,
})}
ref={containerRef}
>
{!loaded && blurhash && (
<div
className='zoomable-image__preview'
style={{
aspectRatio: `${width}/${height}`,
height: `min(${height}px, 100%)`,
}}
>
<Blurhash hash={blurhash} />
</div>
)}
<animated.img
style={style}
role='presentation'
ref={imageRef}
alt={alt}
title={alt}
lang={lang}
src={src}
width={width}
height={height}
draggable={false}
onLoad={handleLoad}
onError={handleError}
onClickCapture={handleClick}
/>
{!loaded && !error && <LoadingIndicator />}
</div>
);
};

View file

@ -1,20 +0,0 @@
import { injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { NotificationStack } from 'react-notification';
import { dismissAlert } from 'mastodon/actions/alerts';
import { getAlerts } from 'mastodon/selectors';
const mapStateToProps = (state, { intl }) => ({
notifications: getAlerts(state, { intl }),
});
const mapDispatchToProps = (dispatch) => ({
onDismiss (alert) {
dispatch(dismissAlert(alert));
},
});
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NotificationStack));

View file

@ -15,6 +15,7 @@ import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app';
import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers';
import { fetchNotifications } from 'mastodon/actions/notification_groups';
import { INTRODUCTION_VERSION } from 'mastodon/actions/onboarding';
import { AlertsController } from 'mastodon/components/alerts_controller';
import { HoverCardController } from 'mastodon/components/hover_card_controller';
import { PictureInPicture } from 'mastodon/features/picture_in_picture';
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
@ -29,11 +30,10 @@ import initialState, { me, owner, singleUserMode, trendsEnabled, trendsAsLanding
import BundleColumnError from './components/bundle_column_error';
import Header from './components/header';
import UploadArea from './components/upload_area';
import { UploadArea } from './components/upload_area';
import ColumnsAreaContainer from './containers/columns_area_container';
import LoadingBarContainer from './containers/loading_bar_container';
import ModalContainer from './containers/modal_container';
import NotificationsContainer from './containers/notifications_container';
import {
Compose,
Status,
@ -607,7 +607,7 @@ class UI extends PureComponent {
</SwitchingColumnsArea>
{layout !== 'mobile' && <PictureInPicture />}
<NotificationsContainer />
<AlertsController />
{!disableHoverCards && <HoverCardController />}
<LoadingBarContainer className='loading-bar' />
<ModalContainer />

View file

@ -1,46 +0,0 @@
// APIs for normalizing fullscreen operations. Note that Edge uses
// the WebKit-prefixed APIs currently (as of Edge 16).
export const isFullscreen = () => document.fullscreenElement ||
document.webkitFullscreenElement ||
document.mozFullScreenElement;
export const exitFullscreen = () => {
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen();
} else if (document.mozCancelFullScreen) {
document.mozCancelFullScreen();
}
};
export const requestFullscreen = el => {
if (el.requestFullscreen) {
el.requestFullscreen();
} else if (el.webkitRequestFullscreen) {
el.webkitRequestFullscreen();
} else if (el.mozRequestFullScreen) {
el.mozRequestFullScreen();
}
};
export const attachFullscreenListener = (listener) => {
if ('onfullscreenchange' in document) {
document.addEventListener('fullscreenchange', listener);
} else if ('onwebkitfullscreenchange' in document) {
document.addEventListener('webkitfullscreenchange', listener);
} else if ('onmozfullscreenchange' in document) {
document.addEventListener('mozfullscreenchange', listener);
}
};
export const detachFullscreenListener = (listener) => {
if ('onfullscreenchange' in document) {
document.removeEventListener('fullscreenchange', listener);
} else if ('onwebkitfullscreenchange' in document) {
document.removeEventListener('webkitfullscreenchange', listener);
} else if ('onmozfullscreenchange' in document) {
document.removeEventListener('mozfullscreenchange', listener);
}
};

View file

@ -0,0 +1,80 @@
// APIs for normalizing fullscreen operations. Note that Edge uses
// the WebKit-prefixed APIs currently (as of Edge 16).
interface DocumentWithFullscreen extends Document {
mozFullScreenElement?: Element;
webkitFullscreenElement?: Element;
mozCancelFullScreen?: () => void;
webkitExitFullscreen?: () => void;
}
interface HTMLElementWithFullscreen extends HTMLElement {
mozRequestFullScreen?: () => void;
webkitRequestFullscreen?: () => void;
}
export const isFullscreen = () => {
const d = document as DocumentWithFullscreen;
return !!(
d.fullscreenElement ??
d.webkitFullscreenElement ??
d.mozFullScreenElement
);
};
export const exitFullscreen = () => {
const d = document as DocumentWithFullscreen;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (d.exitFullscreen) {
void d.exitFullscreen();
} else if (d.webkitExitFullscreen) {
d.webkitExitFullscreen();
} else if (d.mozCancelFullScreen) {
d.mozCancelFullScreen();
}
};
export const requestFullscreen = (el: HTMLElementWithFullscreen | null) => {
if (!el) {
return;
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (el.requestFullscreen) {
void el.requestFullscreen();
} else if (el.webkitRequestFullscreen) {
el.webkitRequestFullscreen();
} else if (el.mozRequestFullScreen) {
el.mozRequestFullScreen();
}
};
export const attachFullscreenListener = (listener: () => void) => {
const d = document as DocumentWithFullscreen;
if ('onfullscreenchange' in d) {
d.addEventListener('fullscreenchange', listener);
} else if ('onwebkitfullscreenchange' in d) {
// @ts-expect-error This is valid on some browsers
d.addEventListener('webkitfullscreenchange', listener); // eslint-disable-line @typescript-eslint/no-unsafe-call
} else if ('onmozfullscreenchange' in d) {
// @ts-expect-error This is valid on some browsers
d.addEventListener('mozfullscreenchange', listener); // eslint-disable-line @typescript-eslint/no-unsafe-call
}
};
export const detachFullscreenListener = (listener: () => void) => {
const d = document as DocumentWithFullscreen;
if ('onfullscreenchange' in d) {
d.removeEventListener('fullscreenchange', listener);
} else if ('onwebkitfullscreenchange' in d) {
// @ts-expect-error This is valid on some browsers
d.removeEventListener('webkitfullscreenchange', listener); // eslint-disable-line @typescript-eslint/no-unsafe-call
} else if ('onmozfullscreenchange' in d) {
// @ts-expect-error This is valid on some browsers
d.removeEventListener('mozfullscreenchange', listener); // eslint-disable-line @typescript-eslint/no-unsafe-call
}
};

View file

@ -1,7 +0,0 @@
import Motion from 'react-motion/lib/Motion';
import { reduceMotion } from '../../../initial_state';
import ReducedMotion from './reduced_motion';
export default reduceMotion ? ReducedMotion : Motion;

View file

@ -1,45 +0,0 @@
// Like react-motion's Motion, but reduces all animations to cross-fades
// for the benefit of users with motion sickness.
import PropTypes from 'prop-types';
import { Component } from 'react';
import Motion from 'react-motion/lib/Motion';
const stylesToKeep = ['opacity', 'backgroundOpacity'];
const extractValue = (value) => {
// This is either an object with a "val" property or it's a number
return (typeof value === 'object' && value && 'val' in value) ? value.val : value;
};
class ReducedMotion extends Component {
static propTypes = {
defaultStyle: PropTypes.object,
style: PropTypes.object,
children: PropTypes.func,
};
render() {
const { style, defaultStyle, children } = this.props;
Object.keys(style).forEach(key => {
if (stylesToKeep.includes(key)) {
return;
}
// If it's setting an x or height or scale or some other value, we need
// to preserve the end-state value without actually animating it
style[key] = defaultStyle[key] = extractValue(style[key]);
});
return (
<Motion style={style} defaultStyle={defaultStyle}>
{children}
</Motion>
);
}
}
export default ReducedMotion;

Some files were not shown because too many files have changed in this diff Show more