mirror of
https://github.com/glitch-soc/mastodon.git
synced 2026-03-29 11:11:11 +02:00
Compare commits
417 Commits
v4.4.0-bet
...
v4.4.15
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3bbf47574 | ||
|
|
f55e3b14b3 | ||
|
|
7c28ff90fc | ||
|
|
b02a2abdf8 | ||
|
|
6567ceb8de | ||
|
|
f0ddc208a8 | ||
|
|
91ea49b7ff | ||
|
|
6c6f042643 | ||
|
|
c4f69f4823 | ||
|
|
844c5d45f8 | ||
|
|
04e390654e | ||
|
|
3b517fc8c5 | ||
|
|
9f14bac22a | ||
|
|
9f358a40c3 | ||
|
|
8d25d3b9c2 | ||
|
|
7df317be1d | ||
|
|
32825cc39a | ||
|
|
0cd84e46e6 | ||
|
|
33d6e6acef | ||
|
|
c1f809bfa7 | ||
|
|
d018d9db57 | ||
|
|
d31b2df458 | ||
|
|
6d30e68295 | ||
|
|
bfe56212b6 | ||
|
|
397c42d4af | ||
|
|
d7d2f1c2d7 | ||
|
|
f0b875f9c3 | ||
|
|
f926bf9a42 | ||
|
|
1e5ac0f491 | ||
|
|
e4e5d0cd2d | ||
|
|
19edc6a264 | ||
|
|
5bcbc77e75 | ||
|
|
bae1da6f73 | ||
|
|
b89d6e256b | ||
|
|
7de301922b | ||
|
|
418370d561 | ||
|
|
eb453afe8d | ||
|
|
3871f3a399 | ||
|
|
9eeeb1b31d | ||
|
|
fa51ec5364 | ||
|
|
5419594d3d | ||
|
|
8a46c747db | ||
|
|
f6ac245a84 | ||
|
|
1b0be1a725 | ||
|
|
68c08114b9 | ||
|
|
04903a45ce | ||
|
|
d9bad1c407 | ||
|
|
cbb1085855 | ||
|
|
3920feb8bd | ||
|
|
4dbe15654a | ||
|
|
27e06cdf20 | ||
|
|
6ac8b52ccc | ||
|
|
7ee99bbe81 | ||
|
|
6c1e77ff1f | ||
|
|
2588d1ab47 | ||
|
|
c0f8640252 | ||
|
|
92be0fd12e | ||
|
|
8450ebc7e8 | ||
|
|
3d27ec34ac | ||
|
|
d3551e1ab6 | ||
|
|
0b9c741dac | ||
|
|
a8c9923df9 | ||
|
|
f32067dc56 | ||
|
|
36981fadf0 | ||
|
|
b60edf59d8 | ||
|
|
3a87a63abf | ||
|
|
ef4d722d6a | ||
|
|
68e30985ca | ||
|
|
1702290786 | ||
|
|
abb3a02ce2 | ||
|
|
25c79f526a | ||
|
|
f5890040e1 | ||
|
|
740f262e38 | ||
|
|
4d2b6795a4 | ||
|
|
6558a1e07a | ||
|
|
b64101ee64 | ||
|
|
0bde273b3d | ||
|
|
22fe977ffe | ||
|
|
8e945bef2a | ||
|
|
d5f12debe0 | ||
|
|
5d0ec718fd | ||
|
|
c7aa312307 | ||
|
|
dc1d4eda7c | ||
|
|
931a29b4f3 | ||
|
|
99b2307350 | ||
|
|
375f2e6ebf | ||
|
|
f0a1da78ba | ||
|
|
b554ecfcb4 | ||
|
|
50244ba682 | ||
|
|
01cf5c103d | ||
|
|
5bda54d15a | ||
|
|
07f5573cd6 | ||
|
|
2b0b537152 | ||
|
|
c49e261ad0 | ||
|
|
915bcb267f | ||
|
|
ff37011057 | ||
|
|
8f5e95a159 | ||
|
|
16ee628d24 | ||
|
|
64a0b060a8 | ||
|
|
fa52f4361a | ||
|
|
7f7d6697c1 | ||
|
|
c2fb12d22d | ||
|
|
2dc4552229 | ||
|
|
95868643a2 | ||
|
|
7c46fdfbf1 | ||
|
|
8965e1bfa9 | ||
|
|
1e27ab0885 | ||
|
|
cef2c50a71 | ||
|
|
a54af6b06c | ||
|
|
81cf6715de | ||
|
|
d7f4eca801 | ||
|
|
adf291631e | ||
|
|
cbef4c9e65 | ||
|
|
1631fb80e8 | ||
|
|
8477bec2f2 | ||
|
|
6796765363 | ||
|
|
044a20f12d | ||
|
|
15d05121df | ||
|
|
07dea4e140 | ||
|
|
3c2570c88a | ||
|
|
81955c10b1 | ||
|
|
e4bdbccba8 | ||
|
|
958d4df6cf | ||
|
|
21d4abf7cc | ||
|
|
d7d6407d41 | ||
|
|
a186bad399 | ||
|
|
67575e59e6 | ||
|
|
d9113976c8 | ||
|
|
3386f225e4 | ||
|
|
97bc82c710 | ||
|
|
0e7cb713d1 | ||
|
|
670316499f | ||
|
|
3c725240fd | ||
|
|
d8ddf95485 | ||
|
|
4c12c2ed60 | ||
|
|
636ecd1d03 | ||
|
|
cb0065cfe9 | ||
|
|
6ae1b4fae9 | ||
|
|
fd7adcc9d4 | ||
|
|
3c5f07c28e | ||
|
|
048a42b8a7 | ||
|
|
c475623418 | ||
|
|
dc6d8f8825 | ||
|
|
dd0647ca45 | ||
|
|
70e2eb49df | ||
|
|
bef28b2e51 | ||
|
|
0b66bd591f | ||
|
|
a94d7bf520 | ||
|
|
c8551a3eca | ||
|
|
df322e50c0 | ||
|
|
1f3588a5a7 | ||
|
|
06c2393805 | ||
|
|
4e85b9073b | ||
|
|
cd573a346d | ||
|
|
793296b5eb | ||
|
|
661dbede1c | ||
|
|
89f423aa00 | ||
|
|
19c89f1bbd | ||
|
|
c966d75600 | ||
|
|
6f1fd0c2a7 | ||
|
|
68c219e753 | ||
|
|
a795743c3f | ||
|
|
1ab5ea9bfb | ||
|
|
48f55e3224 | ||
|
|
6044270d69 | ||
|
|
be1bd91e6d | ||
|
|
34cd5a716f | ||
|
|
ec5128bc1f | ||
|
|
28a79eb239 | ||
|
|
1e2a9167bc | ||
|
|
1947f3a18b | ||
|
|
5a46e3a234 | ||
|
|
75ba0f757a | ||
|
|
62ed8d633e | ||
|
|
02d92d3b68 | ||
|
|
2a87fe5860 | ||
|
|
00c61317d3 | ||
|
|
c0f9e7f4c3 | ||
|
|
1137a0ca3a | ||
|
|
1faf520ce4 | ||
|
|
8777443c9b | ||
|
|
bd6d1f0e3f | ||
|
|
1a1a23f6f0 | ||
|
|
a48567784c | ||
|
|
b71216a08a | ||
|
|
36974aaa99 | ||
|
|
567f337db3 | ||
|
|
97f118013a | ||
|
|
ea5d1f0297 | ||
|
|
7a862d3308 | ||
|
|
1675eab561 | ||
|
|
5f4116a311 | ||
|
|
0741381670 | ||
|
|
e61900cadc | ||
|
|
cbb9a4dbe3 | ||
|
|
4ef0ce033e | ||
|
|
48315a719d | ||
|
|
45932983fc | ||
|
|
1ed3e4cc77 | ||
|
|
5478ef9b32 | ||
|
|
e2592419d9 | ||
|
|
e330447b0e | ||
|
|
b15861528c | ||
|
|
83dc7dc16e | ||
|
|
7d3cc51148 | ||
|
|
cabb33bc49 | ||
|
|
ba6f4de3e4 | ||
|
|
6af9646bbd | ||
|
|
fcb7917344 | ||
|
|
208cb8276a | ||
|
|
4ae47f4263 | ||
|
|
08b2f255fc | ||
|
|
8242f06eca | ||
|
|
5429351889 | ||
|
|
6ff4e83937 | ||
|
|
5e639d7384 | ||
|
|
26e524836d | ||
|
|
cfc4bb1dc0 | ||
|
|
d7099b1b38 | ||
|
|
69fb382424 | ||
|
|
47d469ec5e | ||
|
|
4d3e2efb69 | ||
|
|
92fc7a30dc | ||
|
|
4311369ab8 | ||
|
|
18653ce15d | ||
|
|
71a35d3953 | ||
|
|
e69c1479a8 | ||
|
|
77d2cdb302 | ||
|
|
c727197760 | ||
|
|
d6859c9658 | ||
|
|
7a9e98f4d6 | ||
|
|
7924a27ae7 | ||
|
|
d664b9d8ff | ||
|
|
4558cfadd8 | ||
|
|
713965467d | ||
|
|
aec6d0f807 | ||
|
|
e103815d2d | ||
|
|
d73b9fba90 | ||
|
|
a89d11bc08 | ||
|
|
a250928934 | ||
|
|
1d1b17b04b | ||
|
|
2aff51013c | ||
|
|
8c3c1faaec | ||
|
|
a2888f1bb2 | ||
|
|
77fe044f03 | ||
|
|
da0cc0f5b9 | ||
|
|
ee83f3a8b9 | ||
|
|
7ae78b1032 | ||
|
|
c4b7c3bdda | ||
|
|
a79dbf8334 | ||
|
|
ef6f5f9357 | ||
|
|
f65f6ad6f1 | ||
|
|
c0e242cb73 | ||
|
|
6c1cc5b25a | ||
|
|
ec6f93ae45 | ||
|
|
609a40181e | ||
|
|
93ce44d21d | ||
|
|
fb3ff194b5 | ||
|
|
81b363b338 | ||
|
|
1151b05c2d | ||
|
|
f96743fcfb | ||
|
|
7a51ad7ebd | ||
|
|
06a46e77b8 | ||
|
|
69e14246b8 | ||
|
|
174370dec2 | ||
|
|
18e08bf493 | ||
|
|
c1794fb948 | ||
|
|
061c966ab3 | ||
|
|
326f6bc12a | ||
|
|
bd442485d0 | ||
|
|
333a17a478 | ||
|
|
388e09e1a3 | ||
|
|
2dcededcf0 | ||
|
|
2db8a328cd | ||
|
|
b4a950c2fc | ||
|
|
194645aada | ||
|
|
48aaecec7b | ||
|
|
6cac651ff2 | ||
|
|
385dd5ea37 | ||
|
|
0c5ce23ae4 | ||
|
|
cb937a920e | ||
|
|
7051458467 | ||
|
|
025abf7325 | ||
|
|
28373a9c88 | ||
|
|
42884d8727 | ||
|
|
000ff9c05f | ||
|
|
921af5d27d | ||
|
|
878e1e65eb | ||
|
|
06f5f270cc | ||
|
|
961c22a6fd | ||
|
|
07b4fa55c8 | ||
|
|
041bce9ed6 | ||
|
|
d7a08d81b6 | ||
|
|
8bb81f9496 | ||
|
|
605df74f06 | ||
|
|
98e90b7c1f | ||
|
|
a203a05eb1 | ||
|
|
68090cd8be | ||
|
|
dd064aaa36 | ||
|
|
e6e8974785 | ||
|
|
5e95c63b5b | ||
|
|
498af63b85 | ||
|
|
0bb2dc9d26 | ||
|
|
2eec4da8fc | ||
|
|
c357a7f8d6 | ||
|
|
bae258925c | ||
|
|
e8a603b18f | ||
|
|
f00c8e3245 | ||
|
|
153af19f55 | ||
|
|
964916c71b | ||
|
|
8782e860b6 | ||
|
|
641c0c6393 | ||
|
|
0383100b0e | ||
|
|
87db28cebc | ||
|
|
ac4b735c67 | ||
|
|
bc3dacc371 | ||
|
|
6d017dbf10 | ||
|
|
f21c92bb45 | ||
|
|
7b98298f85 | ||
|
|
1f8378c12d | ||
|
|
f7b4580b49 | ||
|
|
0d650780e2 | ||
|
|
1804a87193 | ||
|
|
9576434d47 | ||
|
|
b804ed0cba | ||
|
|
48451b782d | ||
|
|
2e0a00ab46 | ||
|
|
e4618a6ba5 | ||
|
|
a9f2ec45da | ||
|
|
c1ef1f62d5 | ||
|
|
d285b07774 | ||
|
|
0156ed6641 | ||
|
|
9e5b9433f8 | ||
|
|
34b8ff8267 | ||
|
|
c9a1e27a49 | ||
|
|
dbb20f76a7 | ||
|
|
91741214e1 | ||
|
|
8fa32ca8ba | ||
|
|
8285194451 | ||
|
|
392eaf1010 | ||
|
|
fa9318083e | ||
|
|
c6dddbb66e | ||
|
|
c52848b444 | ||
|
|
0a7418e6d8 | ||
|
|
72f2f35bfb | ||
|
|
0f9f27972d | ||
|
|
9f16f41678 | ||
|
|
47fda2df2c | ||
|
|
377289c961 | ||
|
|
f852da50f6 | ||
|
|
e44143db8c | ||
|
|
73f77edf40 | ||
|
|
eb1674ec50 | ||
|
|
c9f17899a6 | ||
|
|
97d3dac4b6 | ||
|
|
e44b333660 | ||
|
|
26ee915d0b | ||
|
|
93bdb16817 | ||
|
|
f723718576 | ||
|
|
f7c36f44a4 | ||
|
|
c6a99eaf5b | ||
|
|
d8b0beb70d | ||
|
|
eb3823f0cf | ||
|
|
7fff0d24c8 | ||
|
|
9fccf0a8c6 | ||
|
|
8ba1487f30 | ||
|
|
644da36336 | ||
|
|
fb5b8ae0a5 | ||
|
|
fd902c04f7 | ||
|
|
8ee8231a43 | ||
|
|
c4128d89c9 | ||
|
|
9954acf61d | ||
|
|
0276354775 | ||
|
|
dba636da7a | ||
|
|
43e9186e5d | ||
|
|
0338733531 | ||
|
|
1be48d0cab | ||
|
|
e60014ed9c | ||
|
|
0d7f1584bc | ||
|
|
36f01af6c4 | ||
|
|
16057f550d | ||
|
|
e79ecabd0a | ||
|
|
c023ebc87a | ||
|
|
ebc6897afb | ||
|
|
b08ccaa5b3 | ||
|
|
b9b1500fc5 | ||
|
|
d28a4428b5 | ||
|
|
6166e61638 | ||
|
|
e5aa8c1ff3 | ||
|
|
8837fd8c54 | ||
|
|
94c644983e | ||
|
|
13a07e44f1 | ||
|
|
f41981e772 | ||
|
|
9cb5a77c3e | ||
|
|
ac039d5f13 | ||
|
|
adf812efb3 | ||
|
|
3f743b1a07 | ||
|
|
2b360c479c | ||
|
|
204ff46f7e | ||
|
|
54f9a1b43b | ||
|
|
e9b1c1edfe | ||
|
|
455df074fe | ||
|
|
68fb65b08d | ||
|
|
c9d3b8e3a5 | ||
|
|
f8f458e5e6 | ||
|
|
0ec6c26af3 | ||
|
|
08597a1819 | ||
|
|
102a7635d6 | ||
|
|
b1fe35d7d2 | ||
|
|
adf01b021c | ||
|
|
aac51707d1 | ||
|
|
aa345c4630 | ||
|
|
70c6e09e0f | ||
|
|
1a7fd2f446 | ||
|
|
33402722f3 | ||
|
|
cc6a16e62c | ||
|
|
474464ffff |
1
.github/.well-known/funding-manifest-urls
vendored
Normal file
1
.github/.well-known/funding-manifest-urls
vendored
Normal file
@@ -0,0 +1 @@
|
||||
https://joinmastodon.org/funding.json
|
||||
4
.github/workflows/build-releases.yml
vendored
4
.github/workflows/build-releases.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
||||
# Only tag with latest when ran against the latest stable branch
|
||||
# This needs to be updated after each minor version release
|
||||
flavor: |
|
||||
latest=${{ startsWith(github.ref, 'refs/tags/v4.3.') }}
|
||||
latest=false
|
||||
tags: |
|
||||
type=pep440,pattern={{raw}}
|
||||
type=pep440,pattern=v{{major}}.{{minor}}
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
# Only tag with latest when ran against the latest stable branch
|
||||
# This needs to be updated after each minor version release
|
||||
flavor: |
|
||||
latest=${{ startsWith(github.ref, 'refs/tags/v4.3.') }}
|
||||
latest=false
|
||||
tags: |
|
||||
type=pep440,pattern={{raw}}
|
||||
type=pep440,pattern=v{{major}}.{{minor}}
|
||||
|
||||
@@ -9,7 +9,7 @@ permissions:
|
||||
jobs:
|
||||
download-translations-stable:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'mastodon/mastodon'
|
||||
if: github.repository == 'glitch-soc/mastodon'
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
1
.github/workflows/crowdin-upload.yml
vendored
1
.github/workflows/crowdin-upload.yml
vendored
@@ -14,6 +14,7 @@ on:
|
||||
- config/locales-glitch/devise.en.yml
|
||||
- config/locales-glitch/doorkeeper.en.yml
|
||||
- .github/workflows/crowdin-upload.yml
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
upload-translations:
|
||||
|
||||
@@ -82,6 +82,9 @@ AUTHORS.md
|
||||
# Process a few selected JS files
|
||||
!lint-staged.config.js
|
||||
|
||||
# Ignore config YAML files that include ERB/ruby code prettier does not understand
|
||||
/config/email.yml
|
||||
|
||||
# Ignore glitch-soc emoji map file
|
||||
/app/javascript/flavours/glitch/features/emoji/emoji_map.json
|
||||
/app/javascript/flavours/glitch/features/emoji/emoji_data.json
|
||||
@@ -94,4 +97,4 @@ AUTHORS.md
|
||||
app/javascript/flavours/glitch/styles/reset.scss
|
||||
|
||||
# Ignore win95 theme
|
||||
app/javascript/styles/win95.scss
|
||||
app/javascript/styles/win95.scss
|
||||
@@ -23,5 +23,6 @@ RSpec/SpecFilePathFormat:
|
||||
ActivityPub: activitypub
|
||||
DeepL: deepl
|
||||
FetchOEmbedService: fetch_oembed_service
|
||||
OAuth: oauth
|
||||
OEmbedController: oembed_controller
|
||||
OStatus: ostatus
|
||||
|
||||
@@ -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.76.1.
|
||||
# using RuboCop version 1.77.0.
|
||||
# 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
|
||||
@@ -21,14 +21,14 @@ Metrics/BlockNesting:
|
||||
|
||||
# Configuration parameters: AllowedMethods, AllowedPatterns.
|
||||
Metrics/CyclomaticComplexity:
|
||||
Max: 25
|
||||
Enabled: false
|
||||
|
||||
# Configuration parameters: AllowedMethods, AllowedPatterns.
|
||||
Metrics/PerceivedComplexity:
|
||||
Max: 27
|
||||
Enabled: false
|
||||
|
||||
# This cop supports safe autocorrection (--autocorrect).
|
||||
# Configuration parameters: AllowedVars.
|
||||
# Configuration parameters: AllowedVars, DefaultToNil.
|
||||
Style/FetchEnvVar:
|
||||
Exclude:
|
||||
- 'config/initializers/paperclip.rb'
|
||||
|
||||
@@ -11,6 +11,21 @@ const config: StorybookConfig = {
|
||||
name: '@storybook/react-vite',
|
||||
options: {},
|
||||
},
|
||||
staticDirs: [
|
||||
'./static',
|
||||
// We need to manually specify the assets because of the symlink in public/sw.js
|
||||
...[
|
||||
'avatars',
|
||||
'emoji',
|
||||
'headers',
|
||||
'sounds',
|
||||
'badge.png',
|
||||
'loading.gif',
|
||||
'loading.png',
|
||||
'oops.gif',
|
||||
'oops.png',
|
||||
].map((path) => ({ from: `../public/${path}`, to: `/${path}` })),
|
||||
],
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { Preview } from '@storybook/react-vite';
|
||||
|
||||
// If you want to run the dark theme during development,
|
||||
// you can change the below to `/application.scss`
|
||||
import '../app/javascript/styles/mastodon-light.scss';
|
||||
|
||||
const preview: Preview = {
|
||||
// Auto-generate docs: https://storybook.js.org/docs/writing-docs/autodocs
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
|
||||
a11y: {
|
||||
// 'todo' - show a11y violations in the test UI only
|
||||
// 'error' - fail CI on a11y violations
|
||||
// 'off' - skip a11y checks entirely
|
||||
test: 'todo',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
||||
146
.storybook/preview.tsx
Normal file
146
.storybook/preview.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { IntlProvider } from 'react-intl';
|
||||
|
||||
import { MemoryRouter, Route } from 'react-router';
|
||||
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import type { Preview } from '@storybook/react-vite';
|
||||
import { initialize, mswLoader } from 'msw-storybook-addon';
|
||||
import { action } from 'storybook/actions';
|
||||
|
||||
import type { LocaleData } from '@/mastodon/locales';
|
||||
import { reducerWithInitialState, rootReducer } from '@/mastodon/reducers';
|
||||
import { defaultMiddleware } from '@/mastodon/store/store';
|
||||
import { mockHandlers, unhandledRequestHandler } from '@/testing/api';
|
||||
|
||||
// If you want to run the dark theme during development,
|
||||
// you can change the below to `/application.scss`
|
||||
import '../app/javascript/styles/mastodon-light.scss';
|
||||
|
||||
const localeFiles = import.meta.glob('@/mastodon/locales/*.json', {
|
||||
query: { as: 'json' },
|
||||
});
|
||||
|
||||
// Initialize MSW
|
||||
initialize({
|
||||
onUnhandledRequest: unhandledRequestHandler,
|
||||
});
|
||||
|
||||
const preview: Preview = {
|
||||
// Auto-generate docs: https://storybook.js.org/docs/writing-docs/autodocs
|
||||
tags: ['autodocs'],
|
||||
globalTypes: {
|
||||
locale: {
|
||||
description: 'Locale for the story',
|
||||
toolbar: {
|
||||
title: 'Locale',
|
||||
icon: 'globe',
|
||||
items: Object.keys(localeFiles).map((path) =>
|
||||
path.replace('/mastodon/locales/', '').replace('.json', ''),
|
||||
),
|
||||
dynamicTitle: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
initialGlobals: {
|
||||
locale: 'en',
|
||||
},
|
||||
decorators: [
|
||||
(Story, { parameters }) => {
|
||||
const { state = {} } = parameters;
|
||||
let reducer = rootReducer;
|
||||
if (typeof state === 'object' && state) {
|
||||
reducer = reducerWithInitialState(state as Record<string, unknown>);
|
||||
}
|
||||
const store = configureStore({
|
||||
reducer,
|
||||
middleware(getDefaultMiddleware) {
|
||||
return getDefaultMiddleware(defaultMiddleware);
|
||||
},
|
||||
});
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<Story />
|
||||
</Provider>
|
||||
);
|
||||
},
|
||||
(Story, { globals }) => {
|
||||
const currentLocale = (globals.locale as string) || 'en';
|
||||
const [messages, setMessages] = useState<
|
||||
Record<string, Record<string, string>>
|
||||
>({});
|
||||
const currentLocaleData = messages[currentLocale];
|
||||
|
||||
useEffect(() => {
|
||||
async function loadLocaleData() {
|
||||
const { default: localeFile } = (await import(
|
||||
`@/mastodon/locales/${currentLocale}.json`
|
||||
)) as { default: LocaleData['messages'] };
|
||||
setMessages((prevLocales) => ({
|
||||
...prevLocales,
|
||||
[currentLocale]: localeFile,
|
||||
}));
|
||||
}
|
||||
if (!currentLocaleData) {
|
||||
void loadLocaleData();
|
||||
}
|
||||
}, [currentLocale, currentLocaleData]);
|
||||
|
||||
return (
|
||||
<IntlProvider
|
||||
locale={currentLocale}
|
||||
messages={currentLocaleData}
|
||||
textComponent='span'
|
||||
>
|
||||
<Story />
|
||||
</IntlProvider>
|
||||
);
|
||||
},
|
||||
(Story) => (
|
||||
<MemoryRouter>
|
||||
<Story />
|
||||
<Route
|
||||
path='*'
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
render={({ location }) => {
|
||||
if (location.pathname !== '/') {
|
||||
action(`route change to ${location.pathname}`)(location);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
),
|
||||
],
|
||||
loaders: [mswLoader],
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
|
||||
a11y: {
|
||||
// 'todo' - show a11y violations in the test UI only
|
||||
// 'error' - fail CI on a11y violations
|
||||
// 'off' - skip a11y checks entirely
|
||||
test: 'todo',
|
||||
},
|
||||
|
||||
state: {},
|
||||
|
||||
docs: {},
|
||||
|
||||
msw: {
|
||||
handlers: mockHandlers,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
||||
344
.storybook/static/mockServiceWorker.js
Normal file
344
.storybook/static/mockServiceWorker.js
Normal file
@@ -0,0 +1,344 @@
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
|
||||
/**
|
||||
* Mock Service Worker.
|
||||
* @see https://github.com/mswjs/msw
|
||||
* - Please do NOT modify this file.
|
||||
*/
|
||||
|
||||
const PACKAGE_VERSION = '2.10.2'
|
||||
const INTEGRITY_CHECKSUM = 'f5825c521429caf22a4dd13b66e243af'
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||
const activeClientIds = new Set()
|
||||
|
||||
addEventListener('install', function () {
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
addEventListener('activate', function (event) {
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
addEventListener('message', async function (event) {
|
||||
const clientId = Reflect.get(event.source || {}, 'id')
|
||||
|
||||
if (!clientId || !self.clients) {
|
||||
return
|
||||
}
|
||||
|
||||
const client = await self.clients.get(clientId)
|
||||
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
switch (event.data) {
|
||||
case 'KEEPALIVE_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'KEEPALIVE_RESPONSE',
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'INTEGRITY_CHECK_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'INTEGRITY_CHECK_RESPONSE',
|
||||
payload: {
|
||||
packageVersion: PACKAGE_VERSION,
|
||||
checksum: INTEGRITY_CHECKSUM,
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'MOCK_ACTIVATE': {
|
||||
activeClientIds.add(clientId)
|
||||
|
||||
sendToClient(client, {
|
||||
type: 'MOCKING_ENABLED',
|
||||
payload: {
|
||||
client: {
|
||||
id: client.id,
|
||||
frameType: client.frameType,
|
||||
},
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'MOCK_DEACTIVATE': {
|
||||
activeClientIds.delete(clientId)
|
||||
break
|
||||
}
|
||||
|
||||
case 'CLIENT_CLOSED': {
|
||||
activeClientIds.delete(clientId)
|
||||
|
||||
const remainingClients = allClients.filter((client) => {
|
||||
return client.id !== clientId
|
||||
})
|
||||
|
||||
// Unregister itself when there are no more clients
|
||||
if (remainingClients.length === 0) {
|
||||
self.registration.unregister()
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
addEventListener('fetch', function (event) {
|
||||
// Bypass navigation requests.
|
||||
if (event.request.mode === 'navigate') {
|
||||
return
|
||||
}
|
||||
|
||||
// Opening the DevTools triggers the "only-if-cached" request
|
||||
// that cannot be handled by the worker. Bypass such requests.
|
||||
if (
|
||||
event.request.cache === 'only-if-cached' &&
|
||||
event.request.mode !== 'same-origin'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Bypass all requests when there are no active clients.
|
||||
// Prevents the self-unregistered worked from handling requests
|
||||
// after it's been deleted (still remains active until the next reload).
|
||||
if (activeClientIds.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = crypto.randomUUID()
|
||||
event.respondWith(handleRequest(event, requestId))
|
||||
})
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {string} requestId
|
||||
*/
|
||||
async function handleRequest(event, requestId) {
|
||||
const client = await resolveMainClient(event)
|
||||
const requestCloneForEvents = event.request.clone()
|
||||
const response = await getResponse(event, client, requestId)
|
||||
|
||||
// Send back the response clone for the "response:*" life-cycle events.
|
||||
// Ensure MSW is active and ready to handle the message, otherwise
|
||||
// this message will pend indefinitely.
|
||||
if (client && activeClientIds.has(client.id)) {
|
||||
const serializedRequest = await serializeRequest(requestCloneForEvents)
|
||||
|
||||
// Clone the response so both the client and the library could consume it.
|
||||
const responseClone = response.clone()
|
||||
|
||||
sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'RESPONSE',
|
||||
payload: {
|
||||
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
||||
request: {
|
||||
id: requestId,
|
||||
...serializedRequest,
|
||||
},
|
||||
response: {
|
||||
type: responseClone.type,
|
||||
status: responseClone.status,
|
||||
statusText: responseClone.statusText,
|
||||
headers: Object.fromEntries(responseClone.headers.entries()),
|
||||
body: responseClone.body,
|
||||
},
|
||||
},
|
||||
},
|
||||
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
|
||||
)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the main client for the given event.
|
||||
* Client that issues a request doesn't necessarily equal the client
|
||||
* that registered the worker. It's with the latter the worker should
|
||||
* communicate with during the response resolving phase.
|
||||
* @param {FetchEvent} event
|
||||
* @returns {Promise<Client | undefined>}
|
||||
*/
|
||||
async function resolveMainClient(event) {
|
||||
const client = await self.clients.get(event.clientId)
|
||||
|
||||
if (activeClientIds.has(event.clientId)) {
|
||||
return client
|
||||
}
|
||||
|
||||
if (client?.frameType === 'top-level') {
|
||||
return client
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
return allClients
|
||||
.filter((client) => {
|
||||
// Get only those clients that are currently visible.
|
||||
return client.visibilityState === 'visible'
|
||||
})
|
||||
.find((client) => {
|
||||
// Find the client ID that's recorded in the
|
||||
// set of clients that have registered the worker.
|
||||
return activeClientIds.has(client.id)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {Client | undefined} client
|
||||
* @param {string} requestId
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
async function getResponse(event, client, requestId) {
|
||||
// Clone the request because it might've been already used
|
||||
// (i.e. its body has been read and sent to the client).
|
||||
const requestClone = event.request.clone()
|
||||
|
||||
function passthrough() {
|
||||
// Cast the request headers to a new Headers instance
|
||||
// so the headers can be manipulated with.
|
||||
const headers = new Headers(requestClone.headers)
|
||||
|
||||
// Remove the "accept" header value that marked this request as passthrough.
|
||||
// This prevents request alteration and also keeps it compliant with the
|
||||
// user-defined CORS policies.
|
||||
const acceptHeader = headers.get('accept')
|
||||
if (acceptHeader) {
|
||||
const values = acceptHeader.split(',').map((value) => value.trim())
|
||||
const filteredValues = values.filter(
|
||||
(value) => value !== 'msw/passthrough',
|
||||
)
|
||||
|
||||
if (filteredValues.length > 0) {
|
||||
headers.set('accept', filteredValues.join(', '))
|
||||
} else {
|
||||
headers.delete('accept')
|
||||
}
|
||||
}
|
||||
|
||||
return fetch(requestClone, { headers })
|
||||
}
|
||||
|
||||
// Bypass mocking when the client is not active.
|
||||
if (!client) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Bypass initial page load requests (i.e. static assets).
|
||||
// The absence of the immediate/parent client in the map of the active clients
|
||||
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||
// and is not ready to handle requests.
|
||||
if (!activeClientIds.has(client.id)) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Notify the client that a request has been intercepted.
|
||||
const serializedRequest = await serializeRequest(event.request)
|
||||
const clientMessage = await sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'REQUEST',
|
||||
payload: {
|
||||
id: requestId,
|
||||
...serializedRequest,
|
||||
},
|
||||
},
|
||||
[serializedRequest.body],
|
||||
)
|
||||
|
||||
switch (clientMessage.type) {
|
||||
case 'MOCK_RESPONSE': {
|
||||
return respondWithMock(clientMessage.data)
|
||||
}
|
||||
|
||||
case 'PASSTHROUGH': {
|
||||
return passthrough()
|
||||
}
|
||||
}
|
||||
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Client} client
|
||||
* @param {any} message
|
||||
* @param {Array<Transferable>} transferrables
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
function sendToClient(client, message, transferrables = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel()
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
if (event.data && event.data.error) {
|
||||
return reject(event.data.error)
|
||||
}
|
||||
|
||||
resolve(event.data)
|
||||
}
|
||||
|
||||
client.postMessage(message, [
|
||||
channel.port2,
|
||||
...transferrables.filter(Boolean),
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Response} response
|
||||
* @returns {Response}
|
||||
*/
|
||||
function respondWithMock(response) {
|
||||
// Setting response status code to 0 is a no-op.
|
||||
// However, when responding with a "Response.error()", the produced Response
|
||||
// instance will have status code set to 0. Since it's not possible to create
|
||||
// a Response instance with status code 0, handle that use-case separately.
|
||||
if (response.status === 0) {
|
||||
return Response.error()
|
||||
}
|
||||
|
||||
const mockedResponse = new Response(response.body, response)
|
||||
|
||||
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
|
||||
value: true,
|
||||
enumerable: true,
|
||||
})
|
||||
|
||||
return mockedResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Request} request
|
||||
*/
|
||||
async function serializeRequest(request) {
|
||||
return {
|
||||
url: request.url,
|
||||
mode: request.mode,
|
||||
method: request.method,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
destination: request.destination,
|
||||
integrity: request.integrity,
|
||||
redirect: request.redirect,
|
||||
referrer: request.referrer,
|
||||
referrerPolicy: request.referrerPolicy,
|
||||
body: await request.arrayBuffer(),
|
||||
keepalive: request.keepalive,
|
||||
}
|
||||
}
|
||||
282
CHANGELOG.md
282
CHANGELOG.md
@@ -2,7 +2,258 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [4.4.0] - UNRELEASED
|
||||
## [4.4.15] - 2026-03-24
|
||||
|
||||
### Security
|
||||
|
||||
- Fix insufficient checks on quote authorizations ([GHSA-q4g8-82c5-9h33](https://github.com/mastodon/mastodon/security/advisories/GHSA-q4g8-82c5-9h33))
|
||||
- Fix open redirect in legacy path handler ([GHSA-xqw8-4j56-5hj6](https://github.com/mastodon/mastodon/security/advisories/GHSA-xqw8-4j56-5hj6))
|
||||
- Updated dependencies
|
||||
|
||||
### Added
|
||||
|
||||
- Add for searching already-known private GtS posts (#38057 by @ClearlyClaire)
|
||||
|
||||
### Changed
|
||||
|
||||
- Change media description length limit for remote media attachments from 1500 to 10000 characters (#37921 by @ClearlyClaire)
|
||||
- Change HTTP signatures to skip the `Accept` header (#38132 by @ClearlyClaire)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix some model definitions in `tootctl maintenance fix-duplicates` (#38214 by @ClearlyClaire)
|
||||
- Fix overly strict checks for current username on account migration page (#38183 by @mjankowski)
|
||||
- Fix OpenStack Swift Keystone token rate limiting (#38145 by @hugogameiro)
|
||||
- Fix poll expiration notification being re-triggered on implicit updates (#38078 by @ClearlyClaire)
|
||||
- Fix incorrect translation string in webauthn mailers (#38062 by @mjankowski)
|
||||
- Fix username availability check being wrongly applied on race conditions (#37975 by @ClearlyClaire)
|
||||
- Fix hover card unintentionally being shown in some cases (#38039 and #38112 by @diondiondion)
|
||||
- Fix existing posts not being removed from lists when a list member is unfollowed (#38048 by @ClearlyClaire)
|
||||
|
||||
## [4.4.14] - 2026-02-24
|
||||
|
||||
### Security
|
||||
|
||||
- Reject unconfirmed FASPs (#37926 by @oneiros, [GHSA-qgmm-vr4c-ggjg](https://github.com/mastodon/mastodon/security/advisories/GHSA-qgmm-vr4c-ggjg))
|
||||
- Re-use custom socket class for FASP requests (#37925 by @oneiros, [GHSA-46w6-g98f-wxqm](https://github.com/mastodon/mastodon/security/advisories/GHSA-46w6-g98f-wxqm))
|
||||
|
||||
### Added
|
||||
|
||||
- Add `--suspended-only` option to `tootctl emoji purge` (#37828 and #37861 by @ClearlyClaire and @mjankowski)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix custom emojis not being purged on domain suspension (#37808 by @ClearlyClaire)
|
||||
- Fix processing of object updates with duplicate hashtags (#37756 by @ClearlyClaire)
|
||||
|
||||
## [4.4.13] - 2026-02-03
|
||||
|
||||
### Security
|
||||
|
||||
- Fix ActivityPub collection caching logic for pinned posts and featured tags not checking blocked accounts ([GHSA-ccpr-m53r-mfwr](https://github.com/mastodon/mastodon/security/advisories/GHSA-ccpr-m53r-mfwr))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix relationship cache not being cleared when handling account migrations (#37664 by @ClearlyClaire)
|
||||
- Fix error when encountering invalid tag in updated object (#37635 by @ClearlyClaire)
|
||||
- Fix recycled connections not being immediately closed (#37335 and #37674 by @ClearlyClaire and @shleeable)
|
||||
|
||||
## [4.4.12] - 2026-01-20
|
||||
|
||||
### Security
|
||||
|
||||
- Fix missing limits on various federated properties [GHSA-gg8q-rcg7-p79g](https://github.com/mastodon/mastodon/security/advisories/GHSA-gg8q-rcg7-p79g)
|
||||
- Fix remote user suspension bypass [GHSA-5h2f-wg8j-xqwp](https://github.com/mastodon/mastodon/security/advisories/GHSA-5h2f-wg8j-xqwp)
|
||||
- Fix missing length limits on some user-provided fields [GHSA-6x3w-9g92-gvf3](https://github.com/mastodon/mastodon/security/advisories/GHSA-6x3w-9g92-gvf3)
|
||||
- Fix missing access check for push notification settings update [GHSA-f3q8-7vw3-69v4](https://github.com/mastodon/mastodon/security/advisories/GHSA-f3q8-7vw3-69v4)
|
||||
|
||||
### Changed
|
||||
|
||||
- Skip tombstone creation on deleting from 404 (#37533 by @ClearlyClaire)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix potential duplicate handling of quote accept/reject/delete (#37537 by @ClearlyClaire)
|
||||
- Fix `FeedManager#filter_from_home` error when handling a reblog of a deleted status (#37486 by @ClearlyClaire)
|
||||
- Fix needlessly complicated SQL query in status batch removal (#37469 by @ClearlyClaire)
|
||||
- Fix `Vary` parsing in cache control enforcement (#37426 by @MegaManSec)
|
||||
- Fix thread-unsafe ActivityPub activity dispatch (#37423 by @MegaManSec)
|
||||
- Fix SignatureParser accepting duplicate parameters in HTTP Signature header (#37375 by @shleeable)
|
||||
|
||||
## [4.4.11] - 2026-01-07
|
||||
|
||||
### Security
|
||||
|
||||
- Fix SSRF protection bypass ([GHSA](https://github.com/mastodon/mastodon/security/advisories/GHSA-xfrj-c749-jxxq))
|
||||
- Fix missing ownership check in severed relationships controller ([GHSA](https://github.com/mastodon/mastodon/security/advisories/GHSA-ww85-x9cp-5v24))
|
||||
|
||||
### Changed
|
||||
|
||||
- Change HTTP Signature verification status from 401 to 503 on temporary failure to get remote actor (#37221 by @ClearlyClaire)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix mentions of domain-blocked users being processed (#37257 by @ClearlyClaire)
|
||||
|
||||
## [4.4.10] - 2025-12-08
|
||||
|
||||
### Security
|
||||
|
||||
- Fix inconsistent error handling leaking information on existence of private posts ([GHSA-gwhw-gcjx-72v8](https://github.com/mastodon/mastodon/security/advisories/GHSA-gwhw-gcjx-72v8))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix YouTube embeds by sending referer (#37126 by @ChaosExAnima)
|
||||
- Fix YouTube iframe not being able to start at a defined time (#26584 by @BrunoViveiros)
|
||||
- Fix streamed quoted polls not being hydrated correctly (#37118 by @ClearlyClaire)
|
||||
- Fix error handling when re-fetching already-known statuses (#37077 by @ClearlyClaire)
|
||||
- Fix known expensive S3 batch delete operation failing because of short timeouts (#37004 by @ClearlyClaire)
|
||||
|
||||
## [4.4.9] - 2025-11-20
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix `tootctl upgrade storage-schema` failing with `ArgumentError` (#36914 by @shugo)
|
||||
- Fix old previously-undiscovered posts being treated as new when receiving an `Update` (#36848 by @ClearlyClaire)
|
||||
- Fix filters not being applied to quotes in detailed view (#36843 by @ClearlyClaire)
|
||||
|
||||
## [4.4.8] - 2025-10-21
|
||||
|
||||
### Security
|
||||
|
||||
- Fix quote control bypass ([GHSA-8h43-rcqj-wpc6](https://github.com/mastodon/mastodon/security/advisories/GHSA-8h43-rcqj-wpc6))
|
||||
|
||||
## [4.4.7] - 2025-10-15
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix forwarder being called with `nil` status when quote post is soft-deleted (#36463 by @ClearlyClaire)
|
||||
- Fix moderation warning e-mails that include posts (#36462 by @ClearlyClaire)
|
||||
- Fix allow_referrer_origin typo (#36460 by @ShadowJonathan)
|
||||
|
||||
## [4.4.6] - 2025-10-13
|
||||
|
||||
### Security
|
||||
|
||||
- Update dependencies `rack` and `uri`
|
||||
- Fix streaming server connection not being closed on user suspension (by @ThisIsMissEm, [GHSA-r2fh-jr9c-9pxh](https://github.com/mastodon/mastodon/security/advisories/GHSA-r2fh-jr9c-9pxh))
|
||||
- Fix password change through admin CLI not invalidating existing sessions and access tokens (by @ThisIsMissEm, [GHSA-f3q3-rmf7-9655](https://github.com/mastodon/mastodon/security/advisories/GHSA-f3q3-rmf7-9655))
|
||||
- Fix streaming server allowing access to public timelines even without the `read` or `read:statuses` OAuth scopes (by @ThisIsMissEm, [GHSA-7gwh-mw97-qjgp](https://github.com/mastodon/mastodon/security/advisories/GHSA-7gwh-mw97-qjgp))
|
||||
|
||||
### Added
|
||||
|
||||
- Add support for processing quotes of deleted posts signaled through a `Tombstone` (#36381 by @ClearlyClaire)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix quote post state sometimes not being updated through streaming server (#36408 by @ClearlyClaire)
|
||||
- Fix inconsistent “pending tags” count on admin dashboard (#36404 by @mjankowski)
|
||||
- Fix JSON payload being potentially mutated when processing interaction policies (#36392 by @ClearlyClaire)
|
||||
- Fix quotes not being displayed in email notifications (#36379 by @diondiondion)
|
||||
- Fix redirect to external object when URL is missing or malformed (#36347 by @ClearlyClaire)
|
||||
- Fix quotes not being displayed in the featured carousel (#36335 by @diondiondion)
|
||||
|
||||
## [4.4.5] - 2025-09-23
|
||||
|
||||
### Security
|
||||
|
||||
- Update dependencies
|
||||
|
||||
### Added
|
||||
|
||||
- Add support for `has:quote` in search (#36217 by @ClearlyClaire)
|
||||
|
||||
### Changed
|
||||
|
||||
- Change quoted posts from silenced accounts to use a click-through rather than being hidden (#36166 and #36167 by @ClearlyClaire)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix processing of out-of-order `Update` as implicit updates (#36190 by @ClearlyClaire)
|
||||
- Fix getting `Create` and `Update` out of order (#36176 by @ClearlyClaire)
|
||||
- Fix quotes with Content Warnings but no text being shown without Content Warnings (#36150 by @ClearlyClaire)
|
||||
|
||||
## [4.4.4] - 2025-09-16
|
||||
|
||||
### Security
|
||||
|
||||
- Update dependencies
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix missing memoization in `Web::PushNotificationWorker` (#36085 by @ClearlyClaire)
|
||||
- Fix unresponsive areas around GIFV modals in some cases (#36059 by @ClearlyClaire)
|
||||
- Fix missing `beforeUnload` confirmation when a poll is being authored (#36030 by @ClearlyClaire)
|
||||
- Fix processing of remote edited statuses with new media and no text (#35970 by @unfokus)
|
||||
- Fix polls not being displayed in moderation interface (#35644 and #35933 by @ThisIsMissEm)
|
||||
- Fix WebUI handling of deleted quoted posts (#35909 and #35918 by @ClearlyClaire and @diondiondion)
|
||||
- Fix “Edit” and “Delete & Redraft” on a poll not inserting empty option (#35892 by @ClearlyClaire)
|
||||
- Fix loading of some compatibility CSS on some configurations (#35876 by @shleeable)
|
||||
- Fix HttpLog not being enabled with `RAILS_LOG_LEVEL=debug` (#35833 by @mjankowski)
|
||||
- Fix self-destruct scheduler behavior on some Redis setups (#35823 by @ClearlyClaire)
|
||||
- Fix `tootctl admin create` not bypassing reserved username checks (#35779 by @ClearlyClaire)
|
||||
- Fix interaction policy changes in implicit updates not being saved (#35751 by @ClearlyClaire)
|
||||
- Fix quote revocation not being streamed (#35710 by @ClearlyClaire)
|
||||
- Fix export of large user archives by enabling Zip64 (#35850 by @ClearlyClaire)
|
||||
|
||||
### Changed
|
||||
|
||||
- Change labels for quote policy settings (#35893 by @ClearlyClaire)
|
||||
- Change standalone “Share” page to redirect to web interface after posting (#35763 by @ChaosExAnima)
|
||||
|
||||
## [4.4.3] - 2025-08-05
|
||||
|
||||
### Security
|
||||
|
||||
- Update dependencies
|
||||
- Fix incorrect rate-limit handling [GHSA-84ch-6436-c7mg](https://github.com/mastodon/mastodon/security/advisories/GHSA-84ch-6436-c7mg)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix race condition caused by ActiveRecord query cache in `Create` critical path (#35662 by @ClearlyClaire)
|
||||
- Fix race condition caused by quote post processing (#35657 by @ClearlyClaire)
|
||||
- Fix WebUI crashing for accounts with `null` URL (#35651 by @ClearlyClaire)
|
||||
- Fix friends-of-friends recommendations suggesting already-requested accounts (#35604 by @ClearlyClaire)
|
||||
- Fix synchronous recursive fetching of deeply-nested quoted posts (#35600 by @ClearlyClaire)
|
||||
- Fix “Expand this post” link including user `@undefined` (#35478 by @ClearlyClaire)
|
||||
|
||||
### Changed
|
||||
|
||||
- Change `StatusReachFinder` to consider quotes as well as reblogs (#35601 by @ClearlyClaire)
|
||||
- Add restrictions on which quote posts can trend (#35507 by @ClearlyClaire)
|
||||
- Change quote verification to not bypass authorization flow for mentions (#35528 by @ClearlyClaire)
|
||||
|
||||
## [4.4.2] - 2025-07-23
|
||||
|
||||
### Security
|
||||
|
||||
- Update dependencies
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix menu not clickable in Firefox (#35390 and #35414 by @diondiondion)
|
||||
- Add `lang` attribute to current composer language in alt text modal (#35412 by @diondiondion)
|
||||
- Fix quote posts styling on notifications page (#35411 by @diondiondion)
|
||||
- Improve a11y of custom select menus in notifications settings (#35403 by @diondiondion)
|
||||
- Fix selected item in poll select menus is unreadable in Firefox (#35402 by @diondiondion)
|
||||
- Update age limit wording (#35387 by @diondiondion)
|
||||
- Fix support for quote verification in implicit status updates (#35384 by @ClearlyClaire)
|
||||
- Improve `Dropdown` component accessibility (#35373 by @diondiondion)
|
||||
- Fix processing some incoming quotes failing because of missing JSON-LD context (#35354 and #35380 by @ClearlyClaire)
|
||||
- Make bio hashtags open the local page instead of the remote instance (#35349 by @ChaosExAnima)
|
||||
- Fix styling of external log-in button (#35320 by @ClearlyClaire)
|
||||
|
||||
## [4.4.1] - 2025-07-09
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix nearly every sub-directory being crawled as part of Vite build (#35323 by @ClearlyClaire)
|
||||
- Fix assets not building when Redis is unavailable (#35321 by @oneiros)
|
||||
- Fix replying from media modal or pop-in-player tagging user `@undefined` (#35317 by @ClearlyClaire)
|
||||
- Fix support for special characters in various environment variables (#35314 by @mjankowski and @ClearlyClaire)
|
||||
- Fix some database migrations failing for indexes manually removed by admins (#35309 by @mjankowski)
|
||||
|
||||
## [4.4.0] - 2025-07-08
|
||||
|
||||
### Added
|
||||
|
||||
@@ -23,7 +274,7 @@ All notable changes to this project will be documented in this file.
|
||||
Support for verifying remote quotes according to [FEP-044f](https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md) and displaying them in the Web UI has been implemented.\
|
||||
Quoting other people is not implemented yet, and it is currently not possible to mark your own posts as allowing quotes. However, a new “Who can quote” setting has been added to the “Posting defaults” section of the user settings. This setting allows you to set a default that will be used for new posts made on Mastodon 4.5 and newer, when quote posts will be fully implemented.\
|
||||
In the REST API, quote posts are represented by a new `quote` attribute on `Status` and `StatusEdit` entities: https://docs.joinmastodon.org/entities/StatusEdit/#quote https://docs.joinmastodon.org/entities/Status/#quote
|
||||
- Add ability to reorder and translate server rules (#34637, #34737, #34494, #34756, #34820 and #34997 by @ChaosExAnima and @ClearlyClaire)\
|
||||
- Add ability to reorder and translate server rules (#34637, #34737, #34494, #34756, #34820, #34997, #35170, #35174 and #35174 by @ChaosExAnima and @ClearlyClaire)\
|
||||
Rules are now shown in the user’s language, if a translation has been set.\
|
||||
In the REST API, `Rule` entities now have a new `translations` attribute: https://docs.joinmastodon.org/entities/Rule/#translations
|
||||
- Add emoji from Twemoji 15.1.0, including in the emoji picker/completion (#33395, #34321, #34620, and #34677 by @ChaosExAnima, @ClearlyClaire, @TheEssem, and @eramdam)
|
||||
@@ -38,8 +289,11 @@ All notable changes to this project will be documented in this file.
|
||||
Server administrators can now chose to opt in to transmit referrer information when following an external link. Only the domain name is transmitted, not the referrer path.
|
||||
- Add double tap to zoom and swipe to dismiss to media modal in web UI (#34210 by @Gargron)
|
||||
- Add link from Web UI for Hashtags to the Moderation UI (#31448 by @ThisIsMissEm)
|
||||
- **Add terms of service** (#33055, #33233, #33230, #33703, #33699, #33994, #33993, #34105, #34122, #34200, #34527 and #35053 by @ClearlyClaire, @Gargron, @mjankowski, and @oneiros)\
|
||||
Server administrators can now fill in Terms of Service, optionally using a provided template.
|
||||
- **Add terms of service** (#33055, #33233, #33230, #33703, #33699, #33994, #33993, #34105, #34122, #34200, #34527, #35053, #35115, #35126, #35127 and #35233 by @ClearlyClaire, @Gargron, @mjankowski, and @oneiros)\
|
||||
Server administrators can now fill in Terms of Service and notify their users of upcoming changes.
|
||||
- Add optional bulk mailer settings (#35191 and #35203 by @oneiros)\
|
||||
This adds the optional environment variables `BULK_SMTP_PORT`, `BULK_SMTP_SERVER`, `BULK_SMTP_LOGIN` and so on analogous to `SMTP_PORT`, `SMTP_SERVER`, `SMTP_LOGIN` and related SMTP configuration environment variables.\
|
||||
When `BULK_SMTP_SERVER` is set, this group of variables is used instead of the regular ones for sending announcement notification emails and Terms of Service notification emails.
|
||||
- **Add age verification on sign-up** (#34150, #34663, and #34636 by @ClearlyClaire and @Gargron)\
|
||||
Server administrators now have a setting to set a minimum age requirement for creating a new server, asking users for their date of birth. The date of birth is checked against the minimum age requirement server-side but not stored.\
|
||||
The following REST API changes have been made to accommodate this:
|
||||
@@ -48,10 +302,12 @@ All notable changes to this project will be documented in this file.
|
||||
- Add ability to dismiss alt text badge by tapping it in web UI (#33737 by @Gargron)
|
||||
- Add loading indicator to timeline gap indicators in web UI (#33762 by @Gargron)
|
||||
- Add interaction modal when trying to interact with a poll while logged out (#32609 by @ThisIsMissEm)
|
||||
- **Add experimental FASP support** (#34031, #34415, #34765, #34965, and #34964 by @oneiros)\
|
||||
- **Add experimental FASP support** (#34031, #34415, #34765, #34965, #34964, #34033, #35218, #35262 and #35263 by @oneiros)\
|
||||
This is a first step towards supporting “Fediverse Auxiliary Service Providers” (https://github.com/mastodon/fediverse_auxiliary_service_provider_specifications). This is mostly interesting to developers who would like to implement their own FASP, but also includes the capability to share data with a discovery provider (see https://www.fediscovery.org).
|
||||
- Add ability for admins to send announcements to all users via email (#33928 and #34411 by @ClearlyClaire)\
|
||||
This is meant for critical announcements only, as this will potentially send a lot of emails and cannot be opted out of by users.
|
||||
- Add Server Moderation Notes (#31529 by @ThisIsMissEm)
|
||||
- Add loading spinner to “Post” button when sending a post (#35153 by @diondiondion)
|
||||
- Add option to use system scrollbar styling (#32117 by @vmstan)
|
||||
- Add hover cards to follow suggestions (#33749 by @ClearlyClaire)
|
||||
- Add `t` hotkey for post translations (#33441 by @ClearlyClaire)
|
||||
@@ -59,7 +315,7 @@ All notable changes to this project will be documented in this file.
|
||||
- Add dropdown menu with quick actions to lists of accounts in web UI (#34391, #34709, and #34767 by @Gargron, @diondiondion, and @mkljczk)
|
||||
- Add support for displaying “year in review” notification in web UI (#32710, #32765, #32709, #32807, #32914, #33148, and #33882 by @Gargron and @mjankowski)\
|
||||
Note that the notification is currently not generated automatically, and at the moment requires a manual undocumented administrator action.
|
||||
- Add experimental support for receiving HTTP Message Signatures (RFC9421) (#34814 and #35033 by @oneiros)\
|
||||
- Add experimental support for receiving HTTP Message Signatures (RFC9421) (#34814, #35033, #35109 and #35278 by @oneiros)\
|
||||
For now, this needs to be explicitly enabled through the `http_message_signatures` feature flag (`EXPERIMENTAL_FEATURES=http_message_signatures`). This currently only covers verifying such signatures (inbound HTTP requests), not issuing them (outbound HTTP requests).
|
||||
- Add experimental Async Refreshes API (#34918 by @oneiros)
|
||||
- Add experimental server-side feature to fetch remote replies (#32615, #34147, #34149, #34151, #34615, #34682, and #34702 by @ClearlyClaire and @sneakers-the-rat)\
|
||||
@@ -112,7 +368,7 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
### Changed
|
||||
|
||||
- Change design of navigation panel in Web UI, change layout on narrow screens (#34910, #34987, #35017, #34986, #35029, #35065, #35067 and #35072 by @ClearlyClaire, @Gargron, and @diondiondion)
|
||||
- Change design of navigation panel in Web UI, change layout on narrow screens (#34910, #34987, #35017, #34986, #35029, #35065, #35067, #35072, #35074, #35075, #35101, #35173, #35183, #35193 and #35225 by @ClearlyClaire, @Gargron, and @diondiondion)
|
||||
- Change design of lists in web UI (#32881, #33054, and #33036 by @Gargron)
|
||||
- Change design of edit media modal in web UI (#33516, #33702, #33725, #33725, #33771, and #34345 by @Gargron)
|
||||
- Change design of audio player in web UI (#34520, #34740, #34865, #34929, #34933, and #35034 by @ClearlyClaire, @Gargron, and @diondiondion)
|
||||
@@ -126,15 +382,17 @@ All notable changes to this project will be documented in this file.
|
||||
Moderators will still be able to access them while they are kept, but they won't be accessible to the public in the meantime.
|
||||
- Change language names in compose box language picker to be localized (#33402 by @c960657)
|
||||
- Change onboarding flow in web UI (#32998, #33119, #33471 and #34962 by @ClearlyClaire and @Gargron)
|
||||
- Change Advanced Web UI to use the new main menu instead of the “Getting started” column (#35117 by @diondiondion)
|
||||
- Change emoji categories in admin interface to be ordered by name (#33630 by @ShadowJonathan)
|
||||
- Change design of rich text elements in web UI (#32633 by @Gargron)
|
||||
- Change wording of “single choice” to “pick one” in poll authoring form (#32397 by @ThisIsMissEm)
|
||||
- Change returned favorite and boost counts to use those provided by the remote server, if available (#32620, #34594, #34618, and #34619 by @ClearlyClaire and @sneakers-the-rat)
|
||||
- Change label of favourite notifications on private mentions (#31659 by @ClearlyClaire)
|
||||
- Change wording of "discard draft?" confirmation dialogs (#35192 by @diondiondion)
|
||||
- Change `libvips` to be enabled by default in place of ImageMagick (#34741 and #34753 by @ClearlyClaire and @diondiondion)
|
||||
- Change avatar and header size limits from 2MB to 8MB when using libvips (#33002 by @Gargron)
|
||||
- Change search to use query params in web UI (#32949 and #33670 by @ClearlyClaire and @Gargron)
|
||||
- Change build system from Webpack to Vite (#34454, #34450, #34758, #34768, #34813, #34808, #34837, #34732, #35007 and #35035 by @ChaosExAnima, @ClearlyClaire, @mjankowski, and @renchap)
|
||||
- Change build system from Webpack to Vite (#34454, #34450, #34758, #34768, #34813, #34808, #34837, #34732, #35007, #35035 and #35177 by @ChaosExAnima, @ClearlyClaire, @mjankowski, and @renchap)
|
||||
- Change account creation API to forbid creation from user tokens (#34828 by @ThisIsMissEm)
|
||||
- Change `/api/v2/instance` to be enabled without authentication when limited federation mode is enabled (#34576 by @ClearlyClaire)
|
||||
- Change `DEFAULT_LOCALE` to not override unauthenticated users’ browser language (#34535 by @ClearlyClaire)\
|
||||
@@ -202,17 +460,23 @@ All notable changes to this project will be documented in this file.
|
||||
- Fix not being able to scroll dropdown on touch devices in web UI (#34873 by @Gargron)
|
||||
- Fix inconsistent filtering of silenced accounts for other silenced accounts (#34863 by @ClearlyClaire)
|
||||
- Fix update checker listing updates older or equal to current running version (#33906 by @ClearlyClaire)
|
||||
- Fix clicking a status multiple times causing duplicate entries in browser history (#35118 by @ClearlyClaire)
|
||||
- Fix “Alt text” button submitting form in moderation interface (#35147 by @ClearlyClaire)
|
||||
- Fix Firefox sometimes not updating spellcheck language in textarea (#35148 by @ClearlyClaire)
|
||||
- Fix `NoMethodError` in edge case of emoji cache handling (#34749 by @dariusk)
|
||||
- Fix handling of inlined `featured` collections in ActivityPub actor objects (#34789 and #34811 by @ClearlyClaire)
|
||||
- Fix long link names in admin sidebar being truncated (#34727 by @diondiondion)
|
||||
- Fix admin dashboard crash on specific Elasticsearch connection errors (#34683 by @ClearlyClaire)
|
||||
- Fix OIDC account creation failing for long display names (#34639 by @defnull)
|
||||
- Fix use of the deprecated `/api/v1/instance` endpoint in the moderation interface (#34613 by @renchap)
|
||||
- Fix inaccessible “Clear search” button (#35152 and #35281 by @diondiondion)
|
||||
- Fix search operators sometimes getting lost (#35190 by @ClearlyClaire)
|
||||
- Fix directory scroll position reset (#34560 by @przucidlo)
|
||||
- Fix needlessly complex SVG paths for oEmbed and logo (#34538 by @edent)
|
||||
- Fix avatar sizing with long account name in some UI elements (#34514 by @gomasy)
|
||||
- Fix empty menu section in status dropdown (#34431 by @ClearlyClaire)
|
||||
- Fix the delete suggestion button not working (#34396 and #34398 by @ClearlyClaire and @renchap)
|
||||
- Fix popover/dialog backgrounds not being blurred on older Webkit browsers (#35220 by @diondiondion)
|
||||
- Fix radio buttons not always being correctly centered (#34389 by @ChaosExAnima)
|
||||
- Fix visual glitches with adding post filters (#34387 by @ChaosExAnima)
|
||||
- Fix bugs with upload progress (#34325 by @ChaosExAnima)
|
||||
@@ -220,7 +484,7 @@ All notable changes to this project will be documented in this file.
|
||||
- Fix extra space under left-indented vertical videos (#34313 by @ClearlyClaire)
|
||||
- Fix glitchy iOS media attachment drag interactions (#35057 by @diondiondion)
|
||||
- Fix zoomed images being blurry in Safari (#35052 by @diondiondion)
|
||||
- Fix redundant focus stop within status component in Web UI (#35037 and #35051 by @diondiondion)
|
||||
- Fix redundant focus stop within status component in Web UI and make focus style more noticeable (#35037, #35051, #35096, #35150 and #35251 by @diondiondion)
|
||||
- Fix digits in media player time readout not having a consistent width (#35038 by @diondiondion)
|
||||
- Fix wrong text color for “Open in advanced web interface” banner in high-contrast theme (#35032 by @diondiondion)
|
||||
- Fix hover card for limited accounts not hiding information as expected (#35024 by @diondiondion)
|
||||
|
||||
@@ -48,3 +48,23 @@ Mastodon requires all `POST` requests to be signed, and MAY require `GET` reques
|
||||
### Additional documentation
|
||||
|
||||
- [Mastodon documentation](https://docs.joinmastodon.org/)
|
||||
|
||||
## Size limits
|
||||
|
||||
Mastodon imposes a few hard limits on federated content.
|
||||
These limits are intended to be very generous and way above what the Mastodon user experience is optimized for, so as to accomodate future changes and unusual or unforeseen usage patterns, while still providing some limits for performance reasons.
|
||||
The following table attempts to summary those limits.
|
||||
|
||||
| Limited property | Size limit | Consequence of exceeding the limit |
|
||||
| ------------------------------------------------------------- | ---------- | ---------------------------------- |
|
||||
| Serialized JSON-LD | 1MB | **Activity is rejected/dropped** |
|
||||
| Profile fields (actor `PropertyValue` attachments) name/value | 2047 | Field name/value is truncated |
|
||||
| Number of profile fields (actor `PropertyValue` attachments) | 50 | Fields list is truncated |
|
||||
| Poll options (number of `anyOf`/`oneOf` in a `Question`) | 500 | Items list is truncated |
|
||||
| Account username (actor `preferredUsername`) length | 2048 | **Actor will be rejected** |
|
||||
| Account display name (actor `name`) length | 2048 | Display name will be truncated |
|
||||
| Account note (actor `summary`) length | 20kB | Account note will be truncated |
|
||||
| Account `attributionDomains` | 256 | List will be truncated |
|
||||
| Account aliases (actor `alsoKnownAs`) | 256 | List will be truncated |
|
||||
| Custom emoji shortcode (`Emoji` `name`) | 2048 | Emoji will be rejected |
|
||||
| Media descriptions (`name`/`summary`) | 10000 | Description will be truncated |
|
||||
|
||||
7
Gemfile
7
Gemfile
@@ -28,7 +28,7 @@ gem 'bootsnap', '~> 1.18.0', require: false
|
||||
gem 'browser'
|
||||
gem 'charlock_holmes', '~> 0.7.7'
|
||||
gem 'chewy', '~> 7.3'
|
||||
gem 'devise', '~> 4.9'
|
||||
gem 'devise'
|
||||
gem 'devise-two-factor'
|
||||
|
||||
group :pam_authentication, optional: true do
|
||||
@@ -111,7 +111,7 @@ group :opentelemetry do
|
||||
gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.22.0', require: false
|
||||
gem 'opentelemetry-instrumentation-excon', '~> 0.23.0', require: false
|
||||
gem 'opentelemetry-instrumentation-faraday', '~> 0.27.0', require: false
|
||||
gem 'opentelemetry-instrumentation-http', '~> 0.24.0', require: false
|
||||
gem 'opentelemetry-instrumentation-http', '~> 0.25.0', require: false
|
||||
gem 'opentelemetry-instrumentation-http_client', '~> 0.23.0', require: false
|
||||
gem 'opentelemetry-instrumentation-net_http', '~> 0.23.0', require: false
|
||||
gem 'opentelemetry-instrumentation-pg', '~> 0.30.0', require: false
|
||||
@@ -159,6 +159,9 @@ group :test do
|
||||
|
||||
# Stub web requests for specs
|
||||
gem 'webmock', '~> 3.18'
|
||||
|
||||
# Websocket driver for testing integration between rails/sidekiq and streaming
|
||||
gem 'websocket-driver', '~> 0.8', require: false
|
||||
end
|
||||
|
||||
group :development do
|
||||
|
||||
185
Gemfile.lock
185
Gemfile.lock
@@ -10,29 +10,29 @@ GIT
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
actioncable (8.0.4.1)
|
||||
actionpack (= 8.0.4.1)
|
||||
activesupport (= 8.0.4.1)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
activejob (= 8.0.2)
|
||||
activerecord (= 8.0.2)
|
||||
activestorage (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
actionmailbox (8.0.4.1)
|
||||
actionpack (= 8.0.4.1)
|
||||
activejob (= 8.0.4.1)
|
||||
activerecord (= 8.0.4.1)
|
||||
activestorage (= 8.0.4.1)
|
||||
activesupport (= 8.0.4.1)
|
||||
mail (>= 2.8.0)
|
||||
actionmailer (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
actionview (= 8.0.2)
|
||||
activejob (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
actionmailer (8.0.4.1)
|
||||
actionpack (= 8.0.4.1)
|
||||
actionview (= 8.0.4.1)
|
||||
activejob (= 8.0.4.1)
|
||||
activesupport (= 8.0.4.1)
|
||||
mail (>= 2.8.0)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (8.0.2)
|
||||
actionview (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
actionpack (8.0.4.1)
|
||||
actionview (= 8.0.4.1)
|
||||
activesupport (= 8.0.4.1)
|
||||
nokogiri (>= 1.8.5)
|
||||
rack (>= 2.2.4)
|
||||
rack-session (>= 1.0.1)
|
||||
@@ -40,15 +40,15 @@ GEM
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
useragent (~> 0.16)
|
||||
actiontext (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
activerecord (= 8.0.2)
|
||||
activestorage (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
actiontext (8.0.4.1)
|
||||
actionpack (= 8.0.4.1)
|
||||
activerecord (= 8.0.4.1)
|
||||
activestorage (= 8.0.4.1)
|
||||
activesupport (= 8.0.4.1)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
actionview (8.0.4.1)
|
||||
activesupport (= 8.0.4.1)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
@@ -58,22 +58,22 @@ GEM
|
||||
activemodel (>= 4.1)
|
||||
case_transform (>= 0.2)
|
||||
jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
|
||||
activejob (8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
activejob (8.0.4.1)
|
||||
activesupport (= 8.0.4.1)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
activerecord (8.0.2)
|
||||
activemodel (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
activemodel (8.0.4.1)
|
||||
activesupport (= 8.0.4.1)
|
||||
activerecord (8.0.4.1)
|
||||
activemodel (= 8.0.4.1)
|
||||
activesupport (= 8.0.4.1)
|
||||
timeout (>= 0.4.0)
|
||||
activestorage (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
activejob (= 8.0.2)
|
||||
activerecord (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
activestorage (8.0.4.1)
|
||||
actionpack (= 8.0.4.1)
|
||||
activejob (= 8.0.4.1)
|
||||
activerecord (= 8.0.4.1)
|
||||
activesupport (= 8.0.4.1)
|
||||
marcel (~> 1.0)
|
||||
activesupport (8.0.2)
|
||||
activesupport (8.0.4.1)
|
||||
base64
|
||||
benchmark (>= 0.3)
|
||||
bigdecimal
|
||||
@@ -82,7 +82,7 @@ GEM
|
||||
drb
|
||||
i18n (>= 1.6, < 2)
|
||||
logger (>= 1.4.2)
|
||||
minitest (>= 5.1)
|
||||
minitest (>= 5.1, < 6)
|
||||
securerandom (>= 0.3)
|
||||
tzinfo (~> 2.0, >= 2.0.5)
|
||||
uri (>= 0.13.1)
|
||||
@@ -90,7 +90,9 @@ GEM
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
aes_key_wrap (1.1.0)
|
||||
android_key_attestation (0.3.0)
|
||||
annotaterb (4.15.0)
|
||||
annotaterb (4.16.0)
|
||||
activerecord (>= 6.0.0)
|
||||
activesupport (>= 6.0.0)
|
||||
ast (2.4.3)
|
||||
attr_required (1.0.2)
|
||||
aws-eventstream (1.3.2)
|
||||
@@ -114,12 +116,12 @@ GEM
|
||||
base64 (0.3.0)
|
||||
bcp47_spec (0.2.1)
|
||||
bcrypt (3.1.20)
|
||||
benchmark (0.4.0)
|
||||
benchmark (0.4.1)
|
||||
better_errors (2.10.1)
|
||||
erubi (>= 1.0.0)
|
||||
rack (>= 0.9.0)
|
||||
rouge (>= 1.0.0)
|
||||
bigdecimal (3.1.9)
|
||||
bigdecimal (3.2.2)
|
||||
bindata (2.5.1)
|
||||
binding_of_caller (1.0.1)
|
||||
debug_inspector (>= 1.2.0)
|
||||
@@ -178,20 +180,20 @@ GEM
|
||||
database_cleaner-core (~> 2.0.0)
|
||||
database_cleaner-core (2.0.1)
|
||||
date (3.4.1)
|
||||
debug (1.10.0)
|
||||
debug (1.11.0)
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
debug_inspector (1.2.0)
|
||||
devise (4.9.4)
|
||||
devise (5.0.3)
|
||||
bcrypt (~> 3.0)
|
||||
orm_adapter (~> 0.1)
|
||||
railties (>= 4.1.0)
|
||||
railties (>= 7.0)
|
||||
responders
|
||||
warden (~> 1.2.3)
|
||||
devise-two-factor (6.1.0)
|
||||
activesupport (>= 7.0, < 8.1)
|
||||
devise (~> 4.0)
|
||||
railties (>= 7.0, < 8.1)
|
||||
devise-two-factor (6.4.0)
|
||||
activesupport (>= 7.2, < 8.2)
|
||||
devise (>= 4.0, < 6.0)
|
||||
railties (>= 7.2, < 8.2)
|
||||
rotp (~> 6.0)
|
||||
devise_pam_authenticatable2 (9.2.0)
|
||||
devise (>= 4.0.0)
|
||||
@@ -222,6 +224,7 @@ GEM
|
||||
mail (~> 2.7)
|
||||
email_validator (2.2.4)
|
||||
activemodel
|
||||
erb (5.0.1)
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.2.11)
|
||||
tzinfo
|
||||
@@ -230,13 +233,13 @@ GEM
|
||||
fabrication (3.0.0)
|
||||
faker (3.5.1)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.13.1)
|
||||
faraday (2.14.1)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
faraday-follow_redirects (0.3.0)
|
||||
faraday (>= 1, < 3)
|
||||
faraday-httpclient (2.0.1)
|
||||
faraday-httpclient (2.0.2)
|
||||
httpclient (>= 2.2)
|
||||
faraday-net_http (3.4.0)
|
||||
net-http (>= 0.5.0)
|
||||
@@ -284,7 +287,7 @@ GEM
|
||||
activesupport (>= 5.1)
|
||||
haml (>= 4.0.6)
|
||||
railties (>= 5.1)
|
||||
haml_lint (0.62.0)
|
||||
haml_lint (0.64.0)
|
||||
haml (>= 5.0)
|
||||
parallel (~> 1.10)
|
||||
rainbow
|
||||
@@ -455,7 +458,7 @@ GEM
|
||||
net-smtp (0.5.1)
|
||||
net-protocol
|
||||
nio4r (2.7.4)
|
||||
nokogiri (1.18.8)
|
||||
nokogiri (1.18.10)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
oj (3.16.11)
|
||||
@@ -491,7 +494,7 @@ GEM
|
||||
tzinfo
|
||||
validate_url
|
||||
webfinger (~> 2.0)
|
||||
openssl (3.3.0)
|
||||
openssl (3.3.1)
|
||||
openssl-signature_algorithm (1.3.0)
|
||||
openssl (> 2.0)
|
||||
opentelemetry-api (1.5.0)
|
||||
@@ -550,7 +553,7 @@ GEM
|
||||
opentelemetry-instrumentation-faraday (0.27.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-instrumentation-http (0.24.0)
|
||||
opentelemetry-instrumentation-http (0.25.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-instrumentation-http_client (0.23.0)
|
||||
@@ -639,7 +642,7 @@ GEM
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.1.16)
|
||||
rack (3.1.20)
|
||||
rack-attack (6.7.0)
|
||||
rack (>= 1.0, < 4)
|
||||
rack-cors (3.0.0)
|
||||
@@ -665,20 +668,20 @@ GEM
|
||||
rack (>= 1.3)
|
||||
rackup (2.2.1)
|
||||
rack (>= 3)
|
||||
rails (8.0.2)
|
||||
actioncable (= 8.0.2)
|
||||
actionmailbox (= 8.0.2)
|
||||
actionmailer (= 8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
actiontext (= 8.0.2)
|
||||
actionview (= 8.0.2)
|
||||
activejob (= 8.0.2)
|
||||
activemodel (= 8.0.2)
|
||||
activerecord (= 8.0.2)
|
||||
activestorage (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
rails (8.0.4.1)
|
||||
actioncable (= 8.0.4.1)
|
||||
actionmailbox (= 8.0.4.1)
|
||||
actionmailer (= 8.0.4.1)
|
||||
actionpack (= 8.0.4.1)
|
||||
actiontext (= 8.0.4.1)
|
||||
actionview (= 8.0.4.1)
|
||||
activejob (= 8.0.4.1)
|
||||
activemodel (= 8.0.4.1)
|
||||
activerecord (= 8.0.4.1)
|
||||
activestorage (= 8.0.4.1)
|
||||
activesupport (= 8.0.4.1)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 8.0.2)
|
||||
railties (= 8.0.4.1)
|
||||
rails-dom-testing (2.2.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
@@ -689,13 +692,14 @@ GEM
|
||||
rails-i18n (8.0.1)
|
||||
i18n (>= 0.7, < 2)
|
||||
railties (>= 8.0.0, < 9)
|
||||
railties (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
railties (8.0.4.1)
|
||||
actionpack (= 8.0.4.1)
|
||||
activesupport (= 8.0.4.1)
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
thor (~> 1.0, >= 1.2.2)
|
||||
tsort (>= 0.2)
|
||||
zeitwerk (~> 2.6)
|
||||
rainbow (3.1.1)
|
||||
rake (13.3.0)
|
||||
@@ -705,7 +709,8 @@ GEM
|
||||
link_header (~> 0.0, >= 0.0.8)
|
||||
rdf-normalize (0.7.0)
|
||||
rdf (~> 3.3)
|
||||
rdoc (6.13.1)
|
||||
rdoc (6.14.1)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
redcarpet (3.6.1)
|
||||
redis (4.8.1)
|
||||
@@ -721,7 +726,7 @@ GEM
|
||||
responders (3.1.1)
|
||||
actionpack (>= 5.2)
|
||||
railties (>= 5.2)
|
||||
rexml (3.4.1)
|
||||
rexml (3.4.4)
|
||||
rotp (6.3.0)
|
||||
rouge (4.5.2)
|
||||
rpam2 (4.0.2)
|
||||
@@ -733,17 +738,17 @@ GEM
|
||||
rspec-core (~> 3.13.0)
|
||||
rspec-expectations (~> 3.13.0)
|
||||
rspec-mocks (~> 3.13.0)
|
||||
rspec-core (3.13.3)
|
||||
rspec-core (3.13.4)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-expectations (3.13.4)
|
||||
rspec-expectations (3.13.5)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-github (3.0.0)
|
||||
rspec-core (~> 3.0)
|
||||
rspec-mocks (3.13.4)
|
||||
rspec-mocks (3.13.5)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-rails (8.0.0)
|
||||
rspec-rails (8.0.1)
|
||||
actionpack (>= 7.2)
|
||||
activesupport (>= 7.2)
|
||||
railties (>= 7.2)
|
||||
@@ -756,8 +761,8 @@ GEM
|
||||
rspec-expectations (~> 3.0)
|
||||
rspec-mocks (~> 3.0)
|
||||
sidekiq (>= 5, < 9)
|
||||
rspec-support (3.13.3)
|
||||
rubocop (1.76.1)
|
||||
rspec-support (3.13.4)
|
||||
rubocop (1.77.0)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
@@ -765,7 +770,7 @@ GEM
|
||||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.45.0, < 2.0)
|
||||
rubocop-ast (>= 1.45.1, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.45.1)
|
||||
@@ -797,7 +802,7 @@ GEM
|
||||
ruby-prof (1.7.2)
|
||||
base64
|
||||
ruby-progressbar (1.13.0)
|
||||
ruby-saml (1.18.0)
|
||||
ruby-saml (1.18.1)
|
||||
nokogiri (>= 1.13.10)
|
||||
rexml
|
||||
ruby-vips (2.2.4)
|
||||
@@ -851,8 +856,8 @@ GEM
|
||||
stoplight (4.1.1)
|
||||
redlock (~> 1.0)
|
||||
stringio (3.1.7)
|
||||
strong_migrations (2.3.0)
|
||||
activerecord (>= 7)
|
||||
strong_migrations (2.4.0)
|
||||
activerecord (>= 7.1)
|
||||
swd (2.0.3)
|
||||
activesupport (>= 3)
|
||||
attr_required (>= 0.0.5)
|
||||
@@ -865,13 +870,14 @@ GEM
|
||||
terrapin (1.1.0)
|
||||
climate_control
|
||||
test-prof (1.4.4)
|
||||
thor (1.3.2)
|
||||
thor (1.4.0)
|
||||
tilt (2.6.0)
|
||||
timeout (0.4.3)
|
||||
tpm-key_attestation (0.14.1)
|
||||
bindata (~> 2.4)
|
||||
openssl (> 2.0)
|
||||
openssl-signature_algorithm (~> 1.0)
|
||||
tsort (0.2.0)
|
||||
tty-color (0.6.0)
|
||||
tty-cursor (0.7.1)
|
||||
tty-prompt (0.23.1)
|
||||
@@ -895,7 +901,7 @@ GEM
|
||||
unicode-display_width (3.1.4)
|
||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||
unicode-emoji (4.0.4)
|
||||
uri (1.0.3)
|
||||
uri (1.0.4)
|
||||
useragent (0.16.11)
|
||||
validate_url (1.0.15)
|
||||
activemodel (>= 3.0.0)
|
||||
@@ -928,7 +934,7 @@ GEM
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
webrick (1.9.1)
|
||||
websocket-driver (0.7.7)
|
||||
websocket-driver (0.8.0)
|
||||
base64
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
@@ -936,7 +942,7 @@ GEM
|
||||
xorcist (1.1.3)
|
||||
xpath (3.2.0)
|
||||
nokogiri (~> 1.8)
|
||||
zeitwerk (2.7.2)
|
||||
zeitwerk (2.7.3)
|
||||
|
||||
PLATFORMS
|
||||
ruby
|
||||
@@ -966,7 +972,7 @@ DEPENDENCIES
|
||||
csv (~> 3.2)
|
||||
database_cleaner-active_record
|
||||
debug (~> 1.8)
|
||||
devise (~> 4.9)
|
||||
devise
|
||||
devise-two-factor
|
||||
devise_pam_authenticatable2 (~> 9.2)
|
||||
discard (~> 1.2)
|
||||
@@ -1027,7 +1033,7 @@ DEPENDENCIES
|
||||
opentelemetry-instrumentation-concurrent_ruby (~> 0.22.0)
|
||||
opentelemetry-instrumentation-excon (~> 0.23.0)
|
||||
opentelemetry-instrumentation-faraday (~> 0.27.0)
|
||||
opentelemetry-instrumentation-http (~> 0.24.0)
|
||||
opentelemetry-instrumentation-http (~> 0.25.0)
|
||||
opentelemetry-instrumentation-http_client (~> 0.23.0)
|
||||
opentelemetry-instrumentation-net_http (~> 0.23.0)
|
||||
opentelemetry-instrumentation-pg (~> 0.30.0)
|
||||
@@ -1092,6 +1098,7 @@ DEPENDENCIES
|
||||
webauthn (~> 3.0)
|
||||
webmock (~> 3.18)
|
||||
webpush!
|
||||
websocket-driver (~> 0.8)
|
||||
xorcist (~> 1.1)
|
||||
|
||||
RUBY VERSION
|
||||
|
||||
10
SECURITY.md
10
SECURITY.md
@@ -13,8 +13,8 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | --------- |
|
||||
| 4.3.x | Yes |
|
||||
| 4.2.x | Yes |
|
||||
| < 4.2 | No |
|
||||
| Version | Supported |
|
||||
| ------- | ---------------- |
|
||||
| 4.4.x | Yes |
|
||||
| 4.3.x | Until 2026-05-06 |
|
||||
| < 4.3 | No |
|
||||
|
||||
@@ -4,17 +4,31 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController
|
||||
vary_by -> { 'Signature' if authorized_fetch_mode? }
|
||||
|
||||
before_action :require_account_signature!, if: :authorized_fetch_mode?
|
||||
before_action :check_authorization
|
||||
before_action :set_items
|
||||
before_action :set_size
|
||||
before_action :set_type
|
||||
|
||||
def show
|
||||
expires_in 3.minutes, public: public_fetch_mode?
|
||||
render_with_cache json: collection_presenter, content_type: 'application/activity+json', serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
|
||||
|
||||
if @unauthorized
|
||||
render json: collection_presenter, content_type: 'application/activity+json', serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
|
||||
else
|
||||
render_with_cache json: collection_presenter, content_type: 'application/activity+json', serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def check_authorization
|
||||
# Because in public fetch mode we cache the response, there would be no
|
||||
# benefit from performing the check below, since a blocked account or domain
|
||||
# would likely be served the cache from the reverse proxy anyway
|
||||
|
||||
@unauthorized = authorized_fetch_mode? && !signed_request_account.nil? && (@account.blocking?(signed_request_account) || (!signed_request_account.domain.nil? && @account.domain_blocking?(signed_request_account.domain)))
|
||||
end
|
||||
|
||||
def set_items
|
||||
case params[:id]
|
||||
when 'featured'
|
||||
@@ -57,11 +71,7 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController
|
||||
end
|
||||
|
||||
def for_signed_account
|
||||
# Because in public fetch mode we cache the response, there would be no
|
||||
# benefit from performing the check below, since a blocked account or domain
|
||||
# would likely be served the cache from the reverse proxy anyway
|
||||
|
||||
if authorized_fetch_mode? && !signed_request_account.nil? && (@account.blocking?(signed_request_account) || (!signed_request_account.domain.nil? && @account.domain_blocking?(signed_request_account.domain)))
|
||||
if @unauthorized
|
||||
[]
|
||||
else
|
||||
yield
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
class ActivityPub::InboxesController < ActivityPub::BaseController
|
||||
include JsonLdHelper
|
||||
|
||||
before_action :skip_large_payload
|
||||
before_action :skip_unknown_actor_activity
|
||||
before_action :require_actor_signature!
|
||||
skip_before_action :authenticate_user!
|
||||
@@ -16,6 +17,10 @@ class ActivityPub::InboxesController < ActivityPub::BaseController
|
||||
|
||||
private
|
||||
|
||||
def skip_large_payload
|
||||
head 413 if request.content_length > ActivityPub::Activity::MAX_JSON_SIZE
|
||||
end
|
||||
|
||||
def skip_unknown_actor_activity
|
||||
head 202 if unknown_affected_account?
|
||||
end
|
||||
|
||||
@@ -22,7 +22,7 @@ class ActivityPub::LikesController < ActivityPub::BaseController
|
||||
def set_status
|
||||
@status = @account.statuses.find(params[:status_id])
|
||||
authorize @status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController
|
||||
def set_status
|
||||
@status = @account.statuses.find(params[:status_id])
|
||||
authorize @status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ class ActivityPub::SharesController < ActivityPub::BaseController
|
||||
def set_status
|
||||
@status = @account.statuses.find(params[:status_id])
|
||||
authorize @status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
|
||||
@@ -14,16 +14,20 @@ module Admin
|
||||
def create
|
||||
authorize @account, :show?
|
||||
|
||||
account_action = Admin::AccountAction.new(resource_params)
|
||||
account_action.target_account = @account
|
||||
account_action.current_account = current_account
|
||||
@account_action = Admin::AccountAction.new(resource_params)
|
||||
@account_action.target_account = @account
|
||||
@account_action.current_account = current_account
|
||||
|
||||
account_action.save!
|
||||
|
||||
if account_action.with_report?
|
||||
redirect_to admin_reports_path, notice: I18n.t('admin.reports.processed_msg', id: resource_params[:report_id])
|
||||
if @account_action.save
|
||||
if @account_action.with_report?
|
||||
redirect_to admin_reports_path, notice: I18n.t('admin.reports.processed_msg', id: resource_params[:report_id])
|
||||
else
|
||||
redirect_to admin_account_path(@account.id)
|
||||
end
|
||||
else
|
||||
redirect_to admin_account_path(@account.id)
|
||||
@warning_presets = AccountWarningPreset.all
|
||||
|
||||
render :new
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -9,10 +9,16 @@ module Admin
|
||||
|
||||
@pending_appeals_count = Appeal.pending.async_count
|
||||
@pending_reports_count = Report.unresolved.async_count
|
||||
@pending_tags_count = Tag.pending_review.async_count
|
||||
@pending_tags_count = pending_tags.async_count
|
||||
@pending_users_count = User.pending.async_count
|
||||
@system_checks = Admin::SystemCheck.perform(current_user)
|
||||
@time_period = (29.days.ago.to_date...Time.now.utc.to_date)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def pending_tags
|
||||
::Trends::TagFilter.new(status: :pending_review).results
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Admin::Instances::ModerationNotesController < Admin::BaseController
|
||||
before_action :set_instance, only: [:create]
|
||||
before_action :set_instance_note, only: [:destroy]
|
||||
|
||||
def create
|
||||
authorize :instance_moderation_note, :create?
|
||||
|
||||
@instance_moderation_note = current_account.instance_moderation_notes.new(content: resource_params[:content], domain: @instance.domain)
|
||||
|
||||
if @instance_moderation_note.save
|
||||
redirect_to admin_instance_path(@instance.domain, anchor: helpers.dom_id(@instance_moderation_note)), notice: I18n.t('admin.instances.moderation_notes.created_msg')
|
||||
else
|
||||
@instance_moderation_notes = @instance.moderation_notes.includes(:account).chronological
|
||||
@time_period = (6.days.ago.to_date...Time.now.utc.to_date)
|
||||
@action_logs = Admin::ActionLogFilter.new(target_domain: @instance.domain).results.limit(5)
|
||||
|
||||
render 'admin/instances/show'
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize @instance_moderation_note, :destroy?
|
||||
@instance_moderation_note.destroy!
|
||||
redirect_to admin_instance_path(@instance_moderation_note.domain, anchor: 'instance-notes'), notice: I18n.t('admin.instances.moderation_notes.destroyed_msg')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def resource_params
|
||||
params
|
||||
.expect(instance_moderation_note: [:content])
|
||||
end
|
||||
|
||||
def set_instance
|
||||
domain = params[:instance_id]&.strip
|
||||
@instance = Instance.find_or_initialize_by(domain: TagManager.instance.normalize_domain(domain))
|
||||
end
|
||||
|
||||
def set_instance_note
|
||||
@instance_moderation_note = InstanceModerationNote.find(params[:id])
|
||||
end
|
||||
end
|
||||
@@ -14,6 +14,9 @@ module Admin
|
||||
|
||||
def show
|
||||
authorize :instance, :show?
|
||||
|
||||
@instance_moderation_note = @instance.moderation_notes.new
|
||||
@instance_moderation_notes = @instance.moderation_notes.includes(:account).chronological
|
||||
@time_period = (6.days.ago.to_date...Time.now.utc.to_date)
|
||||
@action_logs = Admin::ActionLogFilter.new(target_domain: @instance.domain).results.limit(LOGS_LIMIT)
|
||||
end
|
||||
@@ -52,7 +55,8 @@ module Admin
|
||||
private
|
||||
|
||||
def set_instance
|
||||
@instance = Instance.find_or_initialize_by(domain: TagManager.instance.normalize_domain(params[:id]&.strip))
|
||||
domain = params[:id]&.strip
|
||||
@instance = Instance.find_or_initialize_by(domain: TagManager.instance.normalize_domain(domain))
|
||||
end
|
||||
|
||||
def set_instances
|
||||
|
||||
@@ -17,6 +17,9 @@ module Admin
|
||||
|
||||
def edit
|
||||
authorize @rule, :update?
|
||||
|
||||
missing_languages = RuleTranslation.languages - @rule.translations.pluck(:language)
|
||||
missing_languages.each { |lang| @rule.translations.build(language: lang) }
|
||||
end
|
||||
|
||||
def create
|
||||
|
||||
@@ -4,7 +4,7 @@ class Admin::Trends::TagsController < Admin::BaseController
|
||||
def index
|
||||
authorize :tag, :review?
|
||||
|
||||
@pending_tags_count = Tag.pending_review.async_count
|
||||
@pending_tags_count = pending_tags.async_count
|
||||
@tags = filtered_tags.page(params[:page])
|
||||
@form = Trends::TagBatch.new
|
||||
end
|
||||
@@ -22,6 +22,10 @@ class Admin::Trends::TagsController < Admin::BaseController
|
||||
|
||||
private
|
||||
|
||||
def pending_tags
|
||||
Trends::TagFilter.new(status: :pending_review).results
|
||||
end
|
||||
|
||||
def filtered_tags
|
||||
Trends::TagFilter.new(filter_params).results
|
||||
end
|
||||
|
||||
@@ -47,7 +47,7 @@ class Api::Fasp::BaseController < ApplicationController
|
||||
provider = nil
|
||||
|
||||
Linzer.verify!(request.rack_request, no_older_than: 5.minutes) do |keyid|
|
||||
provider = Fasp::Provider.find(keyid)
|
||||
provider = Fasp::Provider.confirmed.find(keyid)
|
||||
Linzer.new_ed25519_public_key(provider.provider_public_key_pem, keyid)
|
||||
end
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ class Api::V1::FiltersController < Api::BaseController
|
||||
ApplicationRecord.transaction do
|
||||
@filter.update!(keyword_params)
|
||||
@filter.custom_filter.assign_attributes(filter_params)
|
||||
raise Mastodon::ValidationError, I18n.t('filters.errors.deprecated_api_multiple_keywords') if @filter.custom_filter.changed? && @filter.custom_filter.keywords.count > 1
|
||||
raise Mastodon::ValidationError, I18n.t('filters.errors.deprecated_api_multiple_keywords') if @filter.custom_filter.changed? && @filter.custom_filter.keywords.many?
|
||||
|
||||
@filter.custom_filter.save!
|
||||
end
|
||||
|
||||
@@ -15,8 +15,9 @@ class Api::V1::Instances::TermsOfServicesController < Api::V1::Instances::BaseCo
|
||||
if params[:date].present?
|
||||
TermsOfService.published.find_by!(effective_date: params[:date])
|
||||
else
|
||||
TermsOfService.live.first || TermsOfService.published.first! # For the case when none of the published terms have become effective yet
|
||||
TermsOfService.current
|
||||
end
|
||||
end
|
||||
not_found if @terms_of_service.nil?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -17,7 +17,7 @@ class Api::V1::Polls::VotesController < Api::BaseController
|
||||
def set_poll
|
||||
@poll = Poll.find(params[:poll_id])
|
||||
authorize @poll.status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ class Api::V1::PollsController < Api::BaseController
|
||||
def set_poll
|
||||
@poll = Poll.find(params[:id])
|
||||
authorize @poll.status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ class Api::V1::Statuses::BaseController < Api::BaseController
|
||||
def set_status
|
||||
@status = Status.find(params[:status_id])
|
||||
authorize @status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
end
|
||||
|
||||
@@ -23,7 +23,7 @@ class Api::V1::Statuses::BookmarksController < Api::V1::Statuses::BaseController
|
||||
bookmark&.destroy!
|
||||
|
||||
render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_account.id, bookmarks_map: { @status.id => false })
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
end
|
||||
|
||||
@@ -25,7 +25,7 @@ class Api::V1::Statuses::FavouritesController < Api::V1::Statuses::BaseControlle
|
||||
|
||||
relationships = StatusRelationshipsPresenter.new([@status], current_account.id, favourites_map: { @status.id => false }, attributes_map: { @status.id => { favourites_count: count } })
|
||||
render json: @status, serializer: REST::StatusSerializer, relationships: relationships
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
end
|
||||
|
||||
@@ -36,7 +36,7 @@ class Api::V1::Statuses::ReblogsController < Api::V1::Statuses::BaseController
|
||||
|
||||
relationships = StatusRelationshipsPresenter.new([@status], current_account.id, reblogs_map: { @reblog.id => false }, attributes_map: { @reblog.id => { reblogs_count: count } })
|
||||
render json: @reblog, serializer: REST::StatusSerializer, relationships: relationships
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
@@ -45,7 +45,7 @@ class Api::V1::Statuses::ReblogsController < Api::V1::Statuses::BaseController
|
||||
def set_reblog
|
||||
@reblog = Status.find(params[:status_id])
|
||||
authorize @reblog, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ class Api::V1::StatusesController < Api::BaseController
|
||||
def set_status
|
||||
@status = Status.find(params[:id])
|
||||
authorize @status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V2::SearchController < Api::BaseController
|
||||
include AsyncRefreshesConcern
|
||||
include Authorization
|
||||
|
||||
RESULTS_LIMIT = (ENV['MAX_SEARCH_RESULTS'] || 20).to_i
|
||||
@@ -13,6 +14,7 @@ class Api::V2::SearchController < Api::BaseController
|
||||
before_action :remote_resolve_error, if: :remote_resolve_requested?
|
||||
end
|
||||
before_action :require_valid_pagination_options!
|
||||
before_action :handle_fasp_requests
|
||||
|
||||
def index
|
||||
@search = Search.new(search_results)
|
||||
@@ -37,6 +39,21 @@ class Api::V2::SearchController < Api::BaseController
|
||||
render json: { error: 'Search queries that resolve remote resources are not supported without authentication' }, status: 401
|
||||
end
|
||||
|
||||
def handle_fasp_requests
|
||||
return unless Mastodon::Feature.fasp_enabled?
|
||||
return if params[:q].blank?
|
||||
|
||||
# Do not schedule a new retrieval if the request is a follow-up
|
||||
# to an earlier retrieval
|
||||
return if request.headers['Mastodon-Async-Refresh-Id'].present?
|
||||
|
||||
refresh_key = "fasp:account_search:#{Digest::MD5.base64digest(params[:q])}"
|
||||
return if AsyncRefresh.new(refresh_key).running?
|
||||
|
||||
add_async_refresh_header(AsyncRefresh.create(refresh_key))
|
||||
@query_fasp = true
|
||||
end
|
||||
|
||||
def remote_resolve_requested?
|
||||
truthy_param?(:resolve)
|
||||
end
|
||||
@@ -58,7 +75,8 @@ class Api::V2::SearchController < Api::BaseController
|
||||
search_params.merge(
|
||||
resolve: truthy_param?(:resolve),
|
||||
exclude_unreviewed: truthy_param?(:exclude_unreviewed),
|
||||
following: truthy_param?(:following)
|
||||
following: truthy_param?(:following),
|
||||
query_fasp: @query_fasp
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ class Api::Web::EmbedsController < Api::Web::BaseController
|
||||
def set_status
|
||||
@status = Status.find(params[:id])
|
||||
authorize @status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
end
|
||||
|
||||
@@ -62,7 +62,7 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
|
||||
end
|
||||
|
||||
def set_push_subscription
|
||||
@push_subscription = ::Web::PushSubscription.find(params[:id])
|
||||
@push_subscription = ::Web::PushSubscription.where(user_id: active_session.user_id).find(params[:id])
|
||||
end
|
||||
|
||||
def subscription_params
|
||||
|
||||
@@ -101,7 +101,7 @@ class ApplicationController < ActionController::Base
|
||||
end
|
||||
|
||||
def after_sign_out_path_for(_resource_or_scope)
|
||||
if ENV['OMNIAUTH_ONLY'] == 'true' && ENV['OIDC_ENABLED'] == 'true'
|
||||
if ENV['OMNIAUTH_ONLY'] == 'true' && Rails.configuration.x.omniauth.oidc_enabled?
|
||||
'/auth/auth/openid_connect/logout'
|
||||
else
|
||||
new_user_session_path
|
||||
|
||||
@@ -186,14 +186,14 @@ class Auth::SessionsController < Devise::SessionsController
|
||||
"2fa_auth_attempts:#{user.id}:#{Time.now.utc.hour}"
|
||||
end
|
||||
|
||||
def respond_to_on_destroy
|
||||
def respond_to_on_destroy(**)
|
||||
respond_to do |format|
|
||||
format.json do
|
||||
render json: {
|
||||
redirect_to: after_sign_out_path_for(resource_name),
|
||||
}, status: 200
|
||||
end
|
||||
format.all { super }
|
||||
format.all { super(**) }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -21,7 +21,7 @@ class AuthorizeInteractionsController < ApplicationController
|
||||
def set_resource
|
||||
@resource = located_resource
|
||||
authorize(@resource, :show?) if @resource.is_a?(Status)
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ module CacheConcern
|
||||
# from being used as cache keys, while allowing to `Vary` on them (to not serve
|
||||
# anonymous cached data to authenticated requests when authentication matters)
|
||||
def enforce_cache_control!
|
||||
vary = response.headers['Vary']&.split&.map { |x| x.strip.downcase }
|
||||
vary = response.headers['Vary'].to_s.split(',').map { |x| x.strip.downcase }.reject(&:empty?)
|
||||
return unless vary.present? && %w(cookie authorization signature).any? { |header| vary.include?(header) && request.headers[header].present? }
|
||||
|
||||
response.cache_control.replace(private: true, no_store: true)
|
||||
|
||||
@@ -64,13 +64,19 @@ module SignatureVerification
|
||||
return (@signed_request_actor = actor) if signed_request.verified?(actor)
|
||||
|
||||
fail_with! "Verification failed for #{actor.to_log_human_identifier} #{actor.uri}"
|
||||
rescue Mastodon::MalformedHeaderError => e
|
||||
@signature_verification_failure_code = 400
|
||||
fail_with! e.message
|
||||
rescue Mastodon::SignatureVerificationError => e
|
||||
fail_with! e.message
|
||||
rescue *Mastodon::HTTP_CONNECTION_ERRORS => e
|
||||
@signature_verification_failure_code ||= 503
|
||||
fail_with! "Failed to fetch remote data: #{e.message}"
|
||||
rescue Mastodon::UnexpectedResponseError
|
||||
@signature_verification_failure_code ||= 503
|
||||
fail_with! 'Failed to fetch remote data (got unexpected reply from server)'
|
||||
rescue Stoplight::Error::RedLight
|
||||
@signature_verification_failure_code ||= 503
|
||||
fail_with! 'Fetching attempt skipped because of recent connection failure'
|
||||
end
|
||||
|
||||
@@ -82,7 +88,7 @@ module SignatureVerification
|
||||
end
|
||||
|
||||
def actor_from_key_id
|
||||
key_id = signature_key_id
|
||||
key_id = signed_request.key_id
|
||||
domain = key_id.start_with?('acct:') ? key_id.split('@').last : key_id
|
||||
|
||||
if domain_not_allowed?(domain)
|
||||
|
||||
@@ -50,6 +50,13 @@ module WebAppControllerConcern
|
||||
return unless current_user&.require_tos_interstitial?
|
||||
|
||||
@terms_of_service = TermsOfService.published.first
|
||||
|
||||
# Handle case where terms of service have been removed from the database
|
||||
if @terms_of_service.nil?
|
||||
current_user.update(require_tos_interstitial: false)
|
||||
return
|
||||
end
|
||||
|
||||
render 'terms_of_service_interstitial/show', layout: 'auth'
|
||||
end
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ class MediaController < ApplicationController
|
||||
|
||||
def verify_permitted_status!
|
||||
authorize @media_attachment.status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
|
||||
class OAuth::AuthorizationsController < Doorkeeper::AuthorizationsController
|
||||
skip_before_action :authenticate_resource_owner!
|
||||
|
||||
before_action :store_current_location
|
||||
before_action :authenticate_resource_owner!
|
||||
|
||||
layout 'modal'
|
||||
|
||||
content_security_policy do |p|
|
||||
p.form_action(false)
|
||||
end
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicationsController
|
||||
class OAuth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicationsController
|
||||
skip_before_action :authenticate_resource_owner!
|
||||
|
||||
before_action :store_current_location
|
||||
@@ -11,6 +11,8 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio
|
||||
|
||||
skip_before_action :require_functional!
|
||||
|
||||
layout 'admin'
|
||||
|
||||
include Localized
|
||||
|
||||
def destroy
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Oauth::TokensController < Doorkeeper::TokensController
|
||||
class OAuth::TokensController < Doorkeeper::TokensController
|
||||
def revoke
|
||||
unsubscribe_for_token if token.present? && authorized? && token.accessible?
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Oauth::UserinfoController < Api::BaseController
|
||||
class OAuth::UserinfoController < Api::BaseController
|
||||
before_action -> { doorkeeper_authorize! :profile }, only: [:show]
|
||||
before_action :require_user!
|
||||
|
||||
def show
|
||||
@account = current_account
|
||||
render json: @account, serializer: OauthUserinfoSerializer
|
||||
render json: @account, serializer: OAuthUserinfoSerializer
|
||||
end
|
||||
end
|
||||
|
||||
@@ -26,7 +26,7 @@ class SeveredRelationshipsController < ApplicationController
|
||||
private
|
||||
|
||||
def set_event
|
||||
@event = AccountRelationshipSeveranceEvent.find(params[:id])
|
||||
@event = AccountRelationshipSeveranceEvent.where(account: current_account).find(params[:id])
|
||||
end
|
||||
|
||||
def following_data
|
||||
|
||||
@@ -59,7 +59,7 @@ class StatusesController < ApplicationController
|
||||
def set_status
|
||||
@status = @account.statuses.find(params[:id])
|
||||
authorize @status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module WellKnown
|
||||
class OauthMetadataController < ActionController::Base # rubocop:disable Rails/ApplicationController
|
||||
class OAuthMetadataController < ActionController::Base # rubocop:disable Rails/ApplicationController
|
||||
include CacheConcern
|
||||
|
||||
# Prevent `active_model_serializer`'s `ActionController::Serialization` from calling `current_user`
|
||||
@@ -13,8 +13,8 @@ module WellKnown
|
||||
# new OAuth scopes are added), we don't use expires_in to cache upstream,
|
||||
# instead just caching in the rails cache:
|
||||
render_with_cache(
|
||||
json: ::OauthMetadataPresenter.new,
|
||||
serializer: ::OauthMetadataSerializer,
|
||||
json: ::OAuthMetadataPresenter.new,
|
||||
serializer: ::OAuthMetadataSerializer,
|
||||
content_type: 'application/json',
|
||||
expires_in: 15.minutes
|
||||
)
|
||||
|
||||
@@ -66,7 +66,7 @@ module ApplicationHelper
|
||||
|
||||
def provider_sign_in_link(provider)
|
||||
label = Devise.omniauth_configs[provider]&.strategy&.display_name.presence || I18n.t("auth.providers.#{provider}", default: provider.to_s.chomp('_oauth2').capitalize)
|
||||
link_to label, omniauth_authorize_path(:user, provider), class: "button button-#{provider}", method: :post
|
||||
link_to label, omniauth_authorize_path(:user, provider), class: "btn button-#{provider}", method: :post
|
||||
end
|
||||
|
||||
def locale_direction
|
||||
@@ -102,6 +102,16 @@ module ApplicationHelper
|
||||
policy(record).public_send(:"#{action}?")
|
||||
end
|
||||
|
||||
def conditional_link_to(condition, name, options = {}, html_options = {}, &block)
|
||||
if condition && !current_page?(block_given? ? name : options)
|
||||
link_to(name, options, html_options, &block)
|
||||
elsif block_given?
|
||||
content_tag(:span, options, html_options, &block)
|
||||
else
|
||||
content_tag(:span, name, html_options)
|
||||
end
|
||||
end
|
||||
|
||||
def material_symbol(icon, attributes = {})
|
||||
safe_join(
|
||||
[
|
||||
|
||||
@@ -27,6 +27,12 @@ module ContextHelper
|
||||
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
|
||||
attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@type' => '@id' } },
|
||||
quote_requests: { 'QuoteRequest' => 'https://w3id.org/fep/044f#QuoteRequest' },
|
||||
quotes: {
|
||||
'quote' => 'https://w3id.org/fep/044f#quote',
|
||||
'quoteUri' => 'http://fedibird.com/ns#quoteUri',
|
||||
'_misskey_quote' => 'https://misskey-hub.net/ns#_misskey_quote',
|
||||
'quoteAuthorization' => { '@id' => 'https://w3id.org/fep/044f#quoteAuthorization', '@type' => '@id' },
|
||||
},
|
||||
interaction_policies: {
|
||||
'gts' => 'https://gotosocial.org/ns#',
|
||||
'interactionPolicy' => { '@id' => 'gts:interactionPolicy', '@type' => '@id' },
|
||||
|
||||
@@ -179,15 +179,25 @@ function loaded() {
|
||||
({ target }) => {
|
||||
if (!(target instanceof HTMLInputElement)) return;
|
||||
|
||||
if (target.value && target.value.length > 0) {
|
||||
const checkedUsername = target.value;
|
||||
if (checkedUsername && checkedUsername.length > 0) {
|
||||
axios
|
||||
.get('/api/v1/accounts/lookup', { params: { acct: target.value } })
|
||||
.get('/api/v1/accounts/lookup', {
|
||||
params: { acct: checkedUsername },
|
||||
})
|
||||
.then(() => {
|
||||
target.setCustomValidity(formatMessage(messages.usernameTaken));
|
||||
// Only update the validity if the result is for the currently-typed username
|
||||
if (checkedUsername === target.value) {
|
||||
target.setCustomValidity(formatMessage(messages.usernameTaken));
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.catch(() => {
|
||||
target.setCustomValidity('');
|
||||
// Only update the validity if the result is for the currently-typed username
|
||||
if (checkedUsername === target.value) {
|
||||
target.setCustomValidity('');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
target.setCustomValidity('');
|
||||
|
||||
@@ -101,12 +101,17 @@ export const ensureComposeIsVisible = (getState) => {
|
||||
};
|
||||
|
||||
export function setComposeToStatus(status, text, spoiler_text, content_type) {
|
||||
return{
|
||||
type: COMPOSE_SET_STATUS,
|
||||
status,
|
||||
text,
|
||||
spoiler_text,
|
||||
content_type,
|
||||
return (dispatch, getState) => {
|
||||
const maxOptions = getState().server.getIn(['server', 'configuration', 'polls', 'max_options']);
|
||||
|
||||
dispatch({
|
||||
type: COMPOSE_SET_STATUS,
|
||||
status,
|
||||
text,
|
||||
spoiler_text,
|
||||
content_type,
|
||||
maxOptions
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -193,8 +198,9 @@ export function directCompose(account) {
|
||||
|
||||
/**
|
||||
* @param {null | string} overridePrivacy
|
||||
* @param {undefined | Function} successCallback
|
||||
*/
|
||||
export function submitCompose(overridePrivacy = null) {
|
||||
export function submitCompose(overridePrivacy = null, successCallback = undefined) {
|
||||
return function (dispatch, getState) {
|
||||
let status = getState().getIn(['compose', 'text'], '');
|
||||
const media = getState().getIn(['compose', 'media_attachments']);
|
||||
@@ -259,6 +265,9 @@ export function submitCompose(overridePrivacy = null) {
|
||||
|
||||
dispatch(insertIntoTagHistory(response.data.tags, status));
|
||||
dispatch(submitComposeSuccess({ ...response.data }));
|
||||
if (typeof successCallback === 'function') {
|
||||
successCallback(response.data);
|
||||
}
|
||||
|
||||
// To make the app more responsive, immediately push the status
|
||||
// into the columns
|
||||
|
||||
@@ -21,6 +21,15 @@ export function normalizeFilterResult(result) {
|
||||
return normalResult;
|
||||
}
|
||||
|
||||
function stripQuoteFallback(text) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.innerHTML = text;
|
||||
|
||||
wrapper.querySelector('.quote-inline')?.remove();
|
||||
|
||||
return wrapper.innerHTML;
|
||||
}
|
||||
|
||||
export function normalizeStatus(status, normalOldStatus, settings) {
|
||||
const normalStatus = { ...status };
|
||||
|
||||
@@ -78,6 +87,11 @@ export function normalizeStatus(status, normalOldStatus, settings) {
|
||||
normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap);
|
||||
normalStatus.hidden = (spoilerText.length > 0 || normalStatus.sensitive) && autoHideCW(settings, spoilerText);
|
||||
|
||||
// Remove quote fallback link from the DOM so it doesn't mess with paragraph margins
|
||||
if (normalStatus.quote) {
|
||||
normalStatus.contentHtml = stripQuoteFallback(normalStatus.contentHtml);
|
||||
}
|
||||
|
||||
if (normalStatus.url && !(normalStatus.url.startsWith('http://') || normalStatus.url.startsWith('https://'))) {
|
||||
normalStatus.url = null;
|
||||
}
|
||||
@@ -117,6 +131,11 @@ export function normalizeStatusTranslation(translation, status) {
|
||||
spoiler_text: translation.spoiler_text,
|
||||
};
|
||||
|
||||
// Remove quote fallback link from the DOM so it doesn't mess with paragraph margins
|
||||
if (status.get('quote')) {
|
||||
normalTranslation.contentHtml = stripQuoteFallback(normalTranslation.contentHtml);
|
||||
}
|
||||
|
||||
return normalTranslation;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { browserHistory } from 'flavours/glitch/components/router';
|
||||
import api from '../api';
|
||||
|
||||
import { ensureComposeIsVisible, setComposeToStatus } from './compose';
|
||||
import { importFetchedStatus, importFetchedStatuses, importFetchedAccount } from './importer';
|
||||
import { importFetchedStatus, importFetchedAccount } from './importer';
|
||||
import { fetchContext } from './statuses_typed';
|
||||
import { deleteFromTimelines } from './timelines';
|
||||
|
||||
@@ -48,7 +48,18 @@ export function fetchStatusRequest(id, skipLoading) {
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchStatus(id, forceFetch = false, alsoFetchContext = true) {
|
||||
/**
|
||||
* @param {string} id
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.forceFetch]
|
||||
* @param {boolean} [options.alsoFetchContext]
|
||||
* @param {string | null | undefined} [options.parentQuotePostId]
|
||||
*/
|
||||
export function fetchStatus(id, {
|
||||
forceFetch = false,
|
||||
alsoFetchContext = true,
|
||||
parentQuotePostId,
|
||||
} = {}) {
|
||||
return (dispatch, getState) => {
|
||||
const skipLoading = !forceFetch && getState().getIn(['statuses', id], null) !== null;
|
||||
|
||||
@@ -66,7 +77,9 @@ export function fetchStatus(id, forceFetch = false, alsoFetchContext = true) {
|
||||
dispatch(importFetchedStatus(response.data));
|
||||
dispatch(fetchStatusSuccess(skipLoading));
|
||||
}).catch(error => {
|
||||
dispatch(fetchStatusFail(id, error, skipLoading));
|
||||
dispatch(fetchStatusFail(id, error, skipLoading, parentQuotePostId));
|
||||
if (error.status === 404)
|
||||
dispatch(deleteFromTimelines(id));
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -78,22 +91,28 @@ export function fetchStatusSuccess(skipLoading) {
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchStatusFail(id, error, skipLoading) {
|
||||
export function fetchStatusFail(id, error, skipLoading, parentQuotePostId) {
|
||||
return {
|
||||
type: STATUS_FETCH_FAIL,
|
||||
id,
|
||||
error,
|
||||
parentQuotePostId,
|
||||
skipLoading,
|
||||
skipAlert: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function redraft(status, raw_text, content_type) {
|
||||
return {
|
||||
type: REDRAFT,
|
||||
status,
|
||||
raw_text,
|
||||
content_type,
|
||||
return (dispatch, getState) => {
|
||||
const maxOptions = getState().server.getIn(['server', 'configuration', 'polls', 'max_options']);
|
||||
|
||||
dispatch({
|
||||
type: REDRAFT,
|
||||
status,
|
||||
raw_text,
|
||||
content_type,
|
||||
maxOptions,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,30 @@
|
||||
import { createAction } from '@reduxjs/toolkit';
|
||||
|
||||
import {
|
||||
apiGetTag,
|
||||
apiFollowTag,
|
||||
apiUnfollowTag,
|
||||
apiFeatureTag,
|
||||
apiUnfeatureTag,
|
||||
apiGetFollowedTags,
|
||||
} from 'flavours/glitch/api/tags';
|
||||
import { createDataLoadingThunk } from 'flavours/glitch/store/typed_functions';
|
||||
|
||||
export const fetchFollowedHashtags = createDataLoadingThunk(
|
||||
'tags/fetch-followed',
|
||||
async ({ next }: { next?: string } = {}) => {
|
||||
const response = await apiGetFollowedTags(next);
|
||||
return {
|
||||
...response,
|
||||
replace: !next,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
export const markFollowedHashtagsStale = createAction(
|
||||
'tags/mark-followed-stale',
|
||||
);
|
||||
|
||||
export const fetchHashtag = createDataLoadingThunk(
|
||||
'tags/fetch',
|
||||
({ tagId }: { tagId: string }) => apiGetTag(tagId),
|
||||
@@ -15,6 +33,9 @@ export const fetchHashtag = createDataLoadingThunk(
|
||||
export const followHashtag = createDataLoadingThunk(
|
||||
'tags/follow',
|
||||
({ tagId }: { tagId: string }) => apiFollowTag(tagId),
|
||||
(_, { dispatch }) => {
|
||||
void dispatch(markFollowedHashtagsStale());
|
||||
},
|
||||
);
|
||||
|
||||
export const unfollowHashtag = createDataLoadingThunk(
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { apiRequestPost } from 'flavours/glitch/api';
|
||||
import type { Status, StatusVisibility } from 'flavours/glitch/models/status';
|
||||
import type { ApiStatusJSON } from 'flavours/glitch/api_types/statuses';
|
||||
import type { StatusVisibility } from 'flavours/glitch/models/status';
|
||||
|
||||
export const apiReblog = (statusId: string, visibility: StatusVisibility) =>
|
||||
apiRequestPost<{ reblog: Status }>(`v1/statuses/${statusId}/reblog`, {
|
||||
apiRequestPost<{ reblog: ApiStatusJSON }>(`v1/statuses/${statusId}/reblog`, {
|
||||
visibility,
|
||||
});
|
||||
|
||||
export const apiUnreblog = (statusId: string) =>
|
||||
apiRequestPost<Status>(`v1/statuses/${statusId}/unreblog`);
|
||||
apiRequestPost<ApiStatusJSON>(`v1/statuses/${statusId}/unreblog`);
|
||||
|
||||
@@ -1,12 +1,30 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { useLinks } from 'flavours/glitch/hooks/useLinks';
|
||||
|
||||
export const AccountBio: React.FC<{
|
||||
interface AccountBioProps {
|
||||
note: string;
|
||||
className: string;
|
||||
}> = ({ note, className }) => {
|
||||
const handleClick = useLinks();
|
||||
dropdownAccountId?: string;
|
||||
}
|
||||
|
||||
if (note.length === 0 || note === '<p></p>') {
|
||||
export const AccountBio: React.FC<AccountBioProps> = ({
|
||||
note,
|
||||
className,
|
||||
dropdownAccountId,
|
||||
}) => {
|
||||
const handleClick = useLinks(!!dropdownAccountId);
|
||||
const handleNodeChange = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
if (!dropdownAccountId || !node || node.childNodes.length === 0) {
|
||||
return;
|
||||
}
|
||||
addDropdownToHashtags(node, dropdownAccountId);
|
||||
},
|
||||
[dropdownAccountId],
|
||||
);
|
||||
|
||||
if (note.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -15,6 +33,28 @@ export const AccountBio: React.FC<{
|
||||
className={`${className} translate`}
|
||||
dangerouslySetInnerHTML={{ __html: note }}
|
||||
onClickCapture={handleClick}
|
||||
ref={handleNodeChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
function addDropdownToHashtags(node: HTMLElement | null, accountId: string) {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
for (const childNode of node.childNodes) {
|
||||
if (!(childNode instanceof HTMLElement)) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
childNode instanceof HTMLAnchorElement &&
|
||||
(childNode.classList.contains('hashtag') ||
|
||||
childNode.innerText.startsWith('#')) &&
|
||||
!childNode.dataset.menuHashtag
|
||||
) {
|
||||
childNode.dataset.menuHashtag = accountId;
|
||||
} else if (childNode.childNodes.length > 0) {
|
||||
addDropdownToHashtags(childNode, accountId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export const AltTextBadge: React.FC<{
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type='button'
|
||||
ref={anchorRef}
|
||||
className='media-gallery__alt__label'
|
||||
onClick={handleClick}
|
||||
|
||||
@@ -162,6 +162,14 @@ const AutosuggestTextarea = forwardRef(({
|
||||
}
|
||||
}, [suggestions, textareaRef, setSuggestionsHidden]);
|
||||
|
||||
// Hack to force Firefox to change language in autocorrect
|
||||
useEffect(() => {
|
||||
if (lang && textareaRef.current && textareaRef.current === document.activeElement) {
|
||||
textareaRef.current.blur();
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
}, [lang]);
|
||||
|
||||
const renderSuggestion = (suggestion, i) => {
|
||||
let inner, key;
|
||||
|
||||
|
||||
@@ -3,12 +3,15 @@ import { useCallback } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
||||
|
||||
interface BaseProps
|
||||
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
|
||||
block?: boolean;
|
||||
secondary?: boolean;
|
||||
compact?: boolean;
|
||||
dangerous?: boolean;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
interface PropsChildren extends PropsWithChildren<BaseProps> {
|
||||
@@ -22,6 +25,10 @@ interface PropsWithText extends BaseProps {
|
||||
|
||||
type Props = PropsWithText | PropsChildren;
|
||||
|
||||
/**
|
||||
* Primary UI component for user interaction that doesn't result in navigation.
|
||||
*/
|
||||
|
||||
export const Button: React.FC<Props> = ({
|
||||
type = 'button',
|
||||
onClick,
|
||||
@@ -30,6 +37,7 @@ export const Button: React.FC<Props> = ({
|
||||
secondary,
|
||||
compact,
|
||||
dangerous,
|
||||
loading,
|
||||
className,
|
||||
title,
|
||||
text,
|
||||
@@ -38,13 +46,18 @@ export const Button: React.FC<Props> = ({
|
||||
}) => {
|
||||
const handleClick = useCallback<React.MouseEventHandler<HTMLButtonElement>>(
|
||||
(e) => {
|
||||
if (!disabled && onClick) {
|
||||
if (disabled || loading) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
} else if (onClick) {
|
||||
onClick(e);
|
||||
}
|
||||
},
|
||||
[disabled, onClick],
|
||||
[disabled, loading, onClick],
|
||||
);
|
||||
|
||||
const label = text ?? children;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={classNames('button', className, {
|
||||
@@ -52,14 +65,27 @@ export const Button: React.FC<Props> = ({
|
||||
'button--compact': compact,
|
||||
'button--block': block,
|
||||
'button--dangerous': dangerous,
|
||||
loading,
|
||||
})}
|
||||
disabled={disabled}
|
||||
// Disabled buttons can't have focus, so we don't really
|
||||
// disable the button during loading
|
||||
disabled={disabled && !loading}
|
||||
aria-disabled={loading}
|
||||
// If the loading prop is used, announce label changes
|
||||
aria-live={loading !== undefined ? 'polite' : undefined}
|
||||
onClick={handleClick}
|
||||
title={title}
|
||||
type={type}
|
||||
{...props}
|
||||
>
|
||||
{text ?? children}
|
||||
{loading ? (
|
||||
<>
|
||||
<span className='button__label-wrapper'>{label}</span>
|
||||
<LoadingIndicator role='none' />
|
||||
</>
|
||||
) : (
|
||||
label
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -18,7 +18,7 @@ import { useIdentity } from 'flavours/glitch/identity_context';
|
||||
|
||||
import { useAppHistory } from './router';
|
||||
|
||||
const messages = defineMessages({
|
||||
export const messages = defineMessages({
|
||||
show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
|
||||
hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' },
|
||||
moveLeft: {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
useCallback,
|
||||
cloneElement,
|
||||
Children,
|
||||
useId,
|
||||
} from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
@@ -16,6 +17,7 @@ import Overlay from 'react-overlays/Overlay';
|
||||
import type {
|
||||
OffsetValue,
|
||||
UsePopperOptions,
|
||||
Placement,
|
||||
} from 'react-overlays/esm/usePopper';
|
||||
|
||||
import { fetchRelationships } from 'flavours/glitch/actions/accounts';
|
||||
@@ -295,6 +297,11 @@ interface DropdownProps<Item = MenuItem> {
|
||||
title?: string;
|
||||
disabled?: boolean;
|
||||
scrollable?: boolean;
|
||||
placement?: Placement;
|
||||
/**
|
||||
* Prevent the `ScrollableList` with this scrollKey
|
||||
* from being scrolled while the dropdown is open
|
||||
*/
|
||||
scrollKey?: string;
|
||||
status?: ImmutableMap<string, unknown>;
|
||||
forceDropdown?: boolean;
|
||||
@@ -316,6 +323,7 @@ export const Dropdown = <Item = MenuItem,>({
|
||||
title = 'Menu',
|
||||
disabled,
|
||||
scrollable,
|
||||
placement = 'bottom',
|
||||
status,
|
||||
forceDropdown = false,
|
||||
renderItem,
|
||||
@@ -331,16 +339,15 @@ export const Dropdown = <Item = MenuItem,>({
|
||||
);
|
||||
const [currentId] = useState(id++);
|
||||
const open = currentId === openDropdownId;
|
||||
const activeElement = useRef<HTMLElement | null>(null);
|
||||
const targetRef = useRef<HTMLButtonElement | null>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const menuId = useId();
|
||||
const prefetchAccountId = status
|
||||
? status.getIn(['account', 'id'])
|
||||
: undefined;
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (activeElement.current) {
|
||||
activeElement.current.focus({ preventScroll: true });
|
||||
activeElement.current = null;
|
||||
if (buttonRef.current) {
|
||||
buttonRef.current.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
dispatch(
|
||||
@@ -375,7 +382,7 @@ export const Dropdown = <Item = MenuItem,>({
|
||||
[handleClose, onItemClick, items],
|
||||
);
|
||||
|
||||
const handleClick = useCallback(
|
||||
const toggleDropdown = useCallback(
|
||||
(e: React.MouseEvent | React.KeyboardEvent) => {
|
||||
const { type } = e;
|
||||
|
||||
@@ -423,38 +430,6 @@ export const Dropdown = <Item = MenuItem,>({
|
||||
],
|
||||
);
|
||||
|
||||
const handleMouseDown = useCallback(() => {
|
||||
if (!open && document.activeElement instanceof HTMLElement) {
|
||||
activeElement.current = document.activeElement;
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleButtonKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
handleMouseDown();
|
||||
break;
|
||||
}
|
||||
},
|
||||
[handleMouseDown],
|
||||
);
|
||||
|
||||
const handleKeyPress = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
handleClick(e);
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
},
|
||||
[handleClick],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (currentId === openDropdownId) {
|
||||
@@ -465,14 +440,16 @@ export const Dropdown = <Item = MenuItem,>({
|
||||
|
||||
let button: React.ReactElement;
|
||||
|
||||
const buttonProps = {
|
||||
disabled,
|
||||
onClick: toggleDropdown,
|
||||
'aria-expanded': open,
|
||||
'aria-controls': menuId,
|
||||
ref: buttonRef,
|
||||
};
|
||||
|
||||
if (children) {
|
||||
button = cloneElement(Children.only(children), {
|
||||
onClick: handleClick,
|
||||
onMouseDown: handleMouseDown,
|
||||
onKeyDown: handleButtonKeyDown,
|
||||
onKeyPress: handleKeyPress,
|
||||
ref: targetRef,
|
||||
});
|
||||
button = cloneElement(Children.only(children), buttonProps);
|
||||
} else if (icon && iconComponent) {
|
||||
button = (
|
||||
<IconButton
|
||||
@@ -480,12 +457,7 @@ export const Dropdown = <Item = MenuItem,>({
|
||||
iconComponent={iconComponent}
|
||||
title={title}
|
||||
active={open}
|
||||
disabled={disabled}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
onKeyDown={handleButtonKeyDown}
|
||||
onKeyPress={handleKeyPress}
|
||||
ref={targetRef}
|
||||
{...buttonProps}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
@@ -499,13 +471,13 @@ export const Dropdown = <Item = MenuItem,>({
|
||||
<Overlay
|
||||
show={open}
|
||||
offset={offset}
|
||||
placement='bottom'
|
||||
placement={placement}
|
||||
flip
|
||||
target={targetRef}
|
||||
target={buttonRef}
|
||||
popperConfig={popperConfig}
|
||||
>
|
||||
{({ props, arrowProps, placement }) => (
|
||||
<div {...props}>
|
||||
<div {...props} id={menuId}>
|
||||
<div className={`dropdown-animation dropdown-menu ${placement}`}>
|
||||
<div
|
||||
className={`dropdown-menu__arrow ${placement}`}
|
||||
|
||||
@@ -20,7 +20,7 @@ import { useDrag } from '@use-gesture/react';
|
||||
import { expandAccountFeaturedTimeline } from '@/flavours/glitch/actions/timelines';
|
||||
import { Icon } from '@/flavours/glitch/components/icon';
|
||||
import { IconButton } from '@/flavours/glitch/components/icon_button';
|
||||
import StatusContainer from '@/flavours/glitch/containers/status_container';
|
||||
import { StatusQuoteManager } from '@/flavours/glitch/components/status_quoted';
|
||||
import { usePrevious } from '@/flavours/glitch/hooks/usePrevious';
|
||||
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
|
||||
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
|
||||
@@ -218,12 +218,7 @@ const FeaturedCarouselItem: React.FC<
|
||||
ref={handleRef}
|
||||
{...props}
|
||||
>
|
||||
<StatusContainer
|
||||
// @ts-expect-error inferred props are wrong
|
||||
id={statusId}
|
||||
contextType='account'
|
||||
withCounters
|
||||
/>
|
||||
<StatusQuoteManager id={statusId} contextType='account' withCounters />
|
||||
</animated.div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,6 +14,10 @@ import { useTimeout } from 'flavours/glitch/hooks/useTimeout';
|
||||
const offset = [-12, 4] as OffsetValue;
|
||||
const enterDelay = 750;
|
||||
const leaveDelay = 150;
|
||||
// Only open the card if the mouse was moved within this time,
|
||||
// to avoid triggering the card without intentional mouse movement
|
||||
// (e.g. when content changed underneath the mouse cursor)
|
||||
const activeMovementThreshold = 150;
|
||||
const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
|
||||
|
||||
const isHoverCardAnchor = (element: HTMLElement) =>
|
||||
@@ -26,6 +30,7 @@ export const HoverCardController: React.FC = () => {
|
||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||
const [setLeaveTimeout, cancelLeaveTimeout] = useTimeout();
|
||||
const [setEnterTimeout, cancelEnterTimeout, delayEnterTimeout] = useTimeout();
|
||||
const [setMoveTimeout, cancelMoveTimeout] = useTimeout();
|
||||
const [setScrollTimeout] = useTimeout();
|
||||
const location = useLocation();
|
||||
|
||||
@@ -42,6 +47,8 @@ export const HoverCardController: React.FC = () => {
|
||||
|
||||
useEffect(() => {
|
||||
let isScrolling = false;
|
||||
let isUsingTouch = false;
|
||||
let isActiveMouseMovement = false;
|
||||
let currentAnchor: HTMLElement | null = null;
|
||||
let currentTitle: string | null = null;
|
||||
|
||||
@@ -60,6 +67,12 @@ export const HoverCardController: React.FC = () => {
|
||||
setAccountId(undefined);
|
||||
};
|
||||
|
||||
const handleTouchStart = () => {
|
||||
// Keeping track of touch events to prevent the
|
||||
// hover card from being displayed on touch devices
|
||||
isUsingTouch = true;
|
||||
};
|
||||
|
||||
const handleMouseEnter = (e: MouseEvent) => {
|
||||
const { target } = e;
|
||||
|
||||
@@ -69,8 +82,14 @@ export const HoverCardController: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Bail out if we're scrolling, a touch is active,
|
||||
// or if there was no active mouse movement
|
||||
if (isScrolling || !isActiveMouseMovement || isUsingTouch) {
|
||||
return;
|
||||
}
|
||||
|
||||
// We've entered an anchor
|
||||
if (!isScrolling && isHoverCardAnchor(target)) {
|
||||
if (isHoverCardAnchor(target)) {
|
||||
cancelLeaveTimeout();
|
||||
|
||||
currentAnchor?.removeAttribute('aria-describedby');
|
||||
@@ -85,10 +104,7 @@ export const HoverCardController: React.FC = () => {
|
||||
}
|
||||
|
||||
// We've entered the hover card
|
||||
if (
|
||||
!isScrolling &&
|
||||
(target === currentAnchor || target === cardRef.current)
|
||||
) {
|
||||
if (target === currentAnchor || target === cardRef.current) {
|
||||
cancelLeaveTimeout();
|
||||
}
|
||||
};
|
||||
@@ -127,9 +143,23 @@ export const HoverCardController: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleMouseMove = () => {
|
||||
if (isUsingTouch) {
|
||||
isUsingTouch = false;
|
||||
}
|
||||
|
||||
delayEnterTimeout(enterDelay);
|
||||
|
||||
cancelMoveTimeout();
|
||||
isActiveMouseMovement = true;
|
||||
setMoveTimeout(() => {
|
||||
isActiveMouseMovement = false;
|
||||
}, activeMovementThreshold);
|
||||
};
|
||||
|
||||
document.body.addEventListener('touchstart', handleTouchStart, {
|
||||
passive: true,
|
||||
});
|
||||
|
||||
document.body.addEventListener('mouseenter', handleMouseEnter, {
|
||||
passive: true,
|
||||
capture: true,
|
||||
@@ -151,6 +181,7 @@ export const HoverCardController: React.FC = () => {
|
||||
});
|
||||
|
||||
return () => {
|
||||
document.body.removeEventListener('touchstart', handleTouchStart);
|
||||
document.body.removeEventListener('mouseenter', handleMouseEnter);
|
||||
document.body.removeEventListener('mousemove', handleMouseMove);
|
||||
document.body.removeEventListener('mouseleave', handleMouseLeave);
|
||||
@@ -166,6 +197,8 @@ export const HoverCardController: React.FC = () => {
|
||||
setOpen,
|
||||
setAccountId,
|
||||
setAnchor,
|
||||
setMoveTimeout,
|
||||
cancelMoveTimeout,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -13,14 +13,13 @@ interface Props extends React.SVGProps<SVGSVGElement> {
|
||||
children?: never;
|
||||
id: string;
|
||||
icon: IconProp;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const Icon: React.FC<Props> = ({
|
||||
id,
|
||||
icon: IconComponent,
|
||||
className,
|
||||
title: titleProp,
|
||||
'aria-label': ariaLabel,
|
||||
...other
|
||||
}) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
@@ -34,18 +33,19 @@ export const Icon: React.FC<Props> = ({
|
||||
IconComponent = CheckBoxOutlineBlankIcon;
|
||||
}
|
||||
|
||||
const ariaHidden = titleProp ? undefined : true;
|
||||
const ariaHidden = ariaLabel ? undefined : true;
|
||||
const role = !ariaHidden ? 'img' : undefined;
|
||||
|
||||
// Set the title to an empty string to remove the built-in SVG one if any
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
const title = titleProp || '';
|
||||
const title = ariaLabel || '';
|
||||
|
||||
return (
|
||||
<IconComponent
|
||||
className={classNames('icon', `icon-${id}`, className)}
|
||||
title={title}
|
||||
aria-hidden={ariaHidden}
|
||||
aria-label={ariaLabel}
|
||||
role={role}
|
||||
{...other}
|
||||
/>
|
||||
|
||||
@@ -14,7 +14,6 @@ interface Props {
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
onMouseDown?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
onKeyDown?: React.KeyboardEventHandler<HTMLButtonElement>;
|
||||
onKeyPress?: React.KeyboardEventHandler<HTMLButtonElement>;
|
||||
active?: boolean;
|
||||
expanded?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
@@ -47,7 +46,6 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(
|
||||
activeStyle,
|
||||
onClick,
|
||||
onKeyDown,
|
||||
onKeyPress,
|
||||
onMouseDown,
|
||||
active = false,
|
||||
disabled = false,
|
||||
@@ -89,16 +87,6 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(
|
||||
[disabled, onClick],
|
||||
);
|
||||
|
||||
const handleKeyPress: React.KeyboardEventHandler<HTMLButtonElement> =
|
||||
useCallback(
|
||||
(e) => {
|
||||
if (!disabled) {
|
||||
onKeyPress?.(e);
|
||||
}
|
||||
},
|
||||
[disabled, onKeyPress],
|
||||
);
|
||||
|
||||
const handleMouseDown: React.MouseEventHandler<HTMLButtonElement> =
|
||||
useCallback(
|
||||
(e) => {
|
||||
@@ -166,7 +154,6 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyPress={handleKeyPress} // eslint-disable-line @typescript-eslint/no-deprecated
|
||||
style={buttonStyle}
|
||||
tabIndex={tabIndex}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -6,15 +6,34 @@ const messages = defineMessages({
|
||||
loading: { id: 'loading_indicator.label', defaultMessage: 'Loading…' },
|
||||
});
|
||||
|
||||
export const LoadingIndicator: React.FC = () => {
|
||||
interface LoadingIndicatorProps {
|
||||
/**
|
||||
* Use role='none' to opt out of the current default role 'progressbar'
|
||||
* and aria attributes which we should re-visit to check if they're appropriate.
|
||||
* In Firefox the aria-label is not applied, instead an implied value of `50` is
|
||||
* used as the label.
|
||||
*/
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
||||
role = 'progressbar',
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const a11yProps =
|
||||
role === 'progressbar'
|
||||
? ({
|
||||
role,
|
||||
'aria-busy': true,
|
||||
'aria-live': 'polite',
|
||||
} as const)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
className='loading-indicator'
|
||||
role='progressbar'
|
||||
aria-busy
|
||||
aria-live='polite'
|
||||
{...a11yProps}
|
||||
aria-label={intl.formatMessage(messages.loading)}
|
||||
>
|
||||
<CircularProgress size={50} strokeWidth={6} />
|
||||
|
||||
@@ -48,7 +48,7 @@ export const MediaIcon: React.FC<{
|
||||
className={className}
|
||||
id={icon}
|
||||
icon={iconComponents[icon]}
|
||||
title={intl.formatMessage(messages[icon])}
|
||||
aria-label={intl.formatMessage(messages[icon])}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -318,7 +318,7 @@ const PollOption: React.FC<PollOptionProps> = (props) => {
|
||||
id='check'
|
||||
icon={CheckIcon}
|
||||
className='poll__voted__mark'
|
||||
title={intl.formatMessage(messages.voted)}
|
||||
aria-label={intl.formatMessage(messages.voted)}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -377,7 +377,11 @@ class Status extends ImmutablePureComponent {
|
||||
if (newTab) {
|
||||
window.open(path, '_blank', 'noopener');
|
||||
} else {
|
||||
history.push(path);
|
||||
if (history.location.pathname.replace('/deck/', '/') === path) {
|
||||
history.replace(path);
|
||||
} else {
|
||||
history.push(path);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -8,6 +8,10 @@ export enum BannerVariant {
|
||||
Filter = 'filter',
|
||||
}
|
||||
|
||||
const stopPropagation: MouseEventHandler = (e) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
export const StatusBanner: React.FC<{
|
||||
children: React.ReactNode;
|
||||
variant: BannerVariant;
|
||||
@@ -38,6 +42,7 @@ export const StatusBanner: React.FC<{
|
||||
: 'content-warning content-warning--filter'
|
||||
}
|
||||
onClick={forwardClick}
|
||||
onMouseUp={stopPropagation}
|
||||
>
|
||||
<p id={descriptionId}>{children}</p>
|
||||
|
||||
|
||||
@@ -190,7 +190,7 @@ class StatusContent extends PureComponent {
|
||||
link.classList.add('unhandled-link');
|
||||
|
||||
link.setAttribute('target', '_blank');
|
||||
link.setAttribute('rel', 'noopener nofollow noreferrer');
|
||||
link.setAttribute('rel', 'noopener nofollow');
|
||||
|
||||
try {
|
||||
if (tagLinks && isLinkMisleading(link)) {
|
||||
|
||||
@@ -61,16 +61,14 @@ class StatusIcons extends PureComponent {
|
||||
className='status__reply-icon'
|
||||
id='comment'
|
||||
icon={ForumIcon}
|
||||
aria-hidden='true'
|
||||
title={intl.formatMessage(messages.inReplyTo)}
|
||||
aria-label={intl.formatMessage(messages.inReplyTo)}
|
||||
/>
|
||||
) : null}
|
||||
{settings.get('local_only') && status.get('local_only') &&
|
||||
<Icon
|
||||
id='home'
|
||||
icon={HomeIcon}
|
||||
aria-hidden='true'
|
||||
title={intl.formatMessage(messages.localOnly)}
|
||||
aria-label={intl.formatMessage(messages.localOnly)}
|
||||
/>}
|
||||
{settings.get('media') && !!mediaIcons && mediaIcons.map(icon => (<MediaIcon key={`media-icon--${icon}`} className='status__media-icon' icon={icon} />))}
|
||||
{settings.get('visibility') && <VisibilityIcon visibility={status.get('visibility')} />}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
@@ -12,12 +12,15 @@ import ArticleIcon from '@/material-icons/400-24px/article.svg?react';
|
||||
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import StatusContainer from 'flavours/glitch/containers/status_container';
|
||||
import { domain } from 'flavours/glitch/initial_state';
|
||||
import type { Status } from 'flavours/glitch/models/status';
|
||||
import type { RootState } from 'flavours/glitch/store';
|
||||
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
||||
|
||||
import { revealAccount } from '../actions/accounts_typed';
|
||||
import { fetchStatus } from '../actions/statuses';
|
||||
import { makeGetStatus } from '../selectors';
|
||||
import { getAccountHidden } from '../selectors/accounts';
|
||||
|
||||
const MAX_QUOTE_POSTS_NESTING_LEVEL = 1;
|
||||
|
||||
@@ -37,9 +40,7 @@ const QuoteWrapper: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
const NestedQuoteLink: React.FC<{
|
||||
status: Status;
|
||||
}> = ({ status }) => {
|
||||
const NestedQuoteLink: React.FC<{ status: Status }> = ({ status }) => {
|
||||
const accountId = status.get('account') as string;
|
||||
const account = useAppSelector((state) =>
|
||||
accountId ? state.accounts.get(accountId) : undefined,
|
||||
@@ -75,24 +76,74 @@ type GetStatusSelector = (
|
||||
props: { id?: string | null; contextType?: string },
|
||||
) => Status | null;
|
||||
|
||||
const LimitedAccountHint: React.FC<{ accountId: string }> = ({ accountId }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const reveal = useCallback(() => {
|
||||
dispatch(revealAccount({ id: accountId }));
|
||||
}, [dispatch, accountId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormattedMessage
|
||||
id='status.quote_error.limited_account_hint.title'
|
||||
defaultMessage='This account has been hidden by the moderators of {domain}.'
|
||||
values={{ domain }}
|
||||
/>
|
||||
<button onClick={reveal} className='link-button'>
|
||||
<FormattedMessage
|
||||
id='status.quote_error.limited_account_hint.action'
|
||||
defaultMessage='Show anyway'
|
||||
/>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const QuotedStatus: React.FC<{
|
||||
quote: QuoteMap;
|
||||
contextType?: string;
|
||||
parentQuotePostId?: string | null;
|
||||
variant?: 'full' | 'link';
|
||||
nestingLevel?: number;
|
||||
}> = ({ quote, contextType, nestingLevel = 1, variant = 'full' }) => {
|
||||
}> = ({
|
||||
quote,
|
||||
contextType,
|
||||
parentQuotePostId,
|
||||
nestingLevel = 1,
|
||||
variant = 'full',
|
||||
}) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const quoteState = useAppSelector((state) =>
|
||||
parentQuotePostId
|
||||
? state.statuses.getIn([parentQuotePostId, 'quote', 'state'])
|
||||
: quote.get('state'),
|
||||
);
|
||||
|
||||
const quotedStatusId = quote.get('quoted_status');
|
||||
const quoteState = quote.get('state');
|
||||
const status = useAppSelector((state) =>
|
||||
quotedStatusId ? state.statuses.get(quotedStatusId) : undefined,
|
||||
);
|
||||
|
||||
const shouldLoadQuote = !status?.get('isLoading') && quoteState !== 'deleted';
|
||||
|
||||
const accountId: string | null = status?.get('account', null) as
|
||||
| string
|
||||
| null;
|
||||
|
||||
const hiddenAccount = useAppSelector(
|
||||
(state) => accountId && getAccountHidden(state, accountId),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!status && quotedStatusId) {
|
||||
dispatch(fetchStatus(quotedStatusId));
|
||||
if (shouldLoadQuote && quotedStatusId) {
|
||||
dispatch(
|
||||
fetchStatus(quotedStatusId, {
|
||||
parentQuotePostId,
|
||||
alsoFetchContext: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, [status, quotedStatusId, dispatch]);
|
||||
}, [shouldLoadQuote, quotedStatusId, parentQuotePostId, dispatch]);
|
||||
|
||||
// In order to find out whether the quoted post should be completely hidden
|
||||
// due to a matching filter, we run it through the selector used by `status_container`.
|
||||
@@ -147,6 +198,8 @@ export const QuotedStatus: React.FC<{
|
||||
defaultMessage='This post cannot be displayed.'
|
||||
/>
|
||||
);
|
||||
} else if (hiddenAccount && accountId) {
|
||||
quoteError = <LimitedAccountHint accountId={accountId} />;
|
||||
}
|
||||
|
||||
if (quoteError) {
|
||||
@@ -173,6 +226,7 @@ export const QuotedStatus: React.FC<{
|
||||
{canRenderChildQuote && (
|
||||
<QuotedStatus
|
||||
quote={childQuote}
|
||||
parentQuotePostId={quotedStatusId}
|
||||
contextType={contextType}
|
||||
variant={
|
||||
nestingLevel === MAX_QUOTE_POSTS_NESTING_LEVEL ? 'link' : 'full'
|
||||
@@ -209,7 +263,11 @@ export const StatusQuoteManager = (props: StatusQuoteManagerProps) => {
|
||||
return (
|
||||
/* @ts-expect-error Status is not yet typed */
|
||||
<StatusContainer {...props}>
|
||||
<QuotedStatus quote={quote} contextType={props.contextType} />
|
||||
<QuotedStatus
|
||||
quote={quote}
|
||||
parentQuotePostId={status?.get('id') as string}
|
||||
contextType={props.contextType}
|
||||
/>
|
||||
</StatusContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ export const VisibilityIcon: React.FC<{ visibility: StatusVisibility }> = ({
|
||||
<Icon
|
||||
id={visibilityIcon.icon}
|
||||
icon={visibilityIcon.iconComponent}
|
||||
title={visibilityIcon.text}
|
||||
aria-label={visibilityIcon.text}
|
||||
className={'status__visibility-icon'}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ import initialState, { title as siteTitle } from 'flavours/glitch/initial_state'
|
||||
import { IntlProvider } from 'flavours/glitch/locales';
|
||||
import { store } from 'flavours/glitch/store';
|
||||
import { isProduction } from 'flavours/glitch/utils/environment';
|
||||
import { BodyScrollLock } from 'flavours/glitch/features/ui/components/body_scroll_lock';
|
||||
|
||||
const title = isProduction() ? siteTitle : `${siteTitle} (Dev)`;
|
||||
|
||||
@@ -63,6 +64,7 @@ export default class Mastodon extends PureComponent {
|
||||
<ScrollContext shouldUpdateScroll={this.shouldUpdateScroll}>
|
||||
<Route path='/' component={UI} />
|
||||
</ScrollContext>
|
||||
<BodyScrollLock />
|
||||
</Router>
|
||||
|
||||
<Helmet defaultTitle={title} titleTemplate={`%s - ${title}`} />
|
||||
|
||||
@@ -14,7 +14,6 @@ import MediaModal from 'flavours/glitch/features/ui/components/media_modal';
|
||||
import { Video } from 'flavours/glitch/features/video';
|
||||
import { IntlProvider } from 'flavours/glitch/locales';
|
||||
import { createPollFromServerJSON } from 'flavours/glitch/models/poll';
|
||||
import { getScrollbarWidth } from 'flavours/glitch/utils/scrollbar';
|
||||
|
||||
const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag, Audio };
|
||||
|
||||
@@ -34,9 +33,6 @@ export default class MediaContainer extends PureComponent {
|
||||
};
|
||||
|
||||
handleOpenMedia = (media, index, lang) => {
|
||||
document.body.classList.add('with-modals--active');
|
||||
document.documentElement.style.marginRight = `${getScrollbarWidth()}px`;
|
||||
|
||||
this.setState({ media, index, lang });
|
||||
};
|
||||
|
||||
@@ -45,16 +41,10 @@ export default class MediaContainer extends PureComponent {
|
||||
const { media } = JSON.parse(components[options.componentIndex].getAttribute('data-props'));
|
||||
const mediaList = fromJS(media);
|
||||
|
||||
document.body.classList.add('with-modals--active');
|
||||
document.documentElement.style.marginRight = `${getScrollbarWidth()}px`;
|
||||
|
||||
this.setState({ media: mediaList, lang, options });
|
||||
};
|
||||
|
||||
handleCloseMedia = () => {
|
||||
document.body.classList.remove('with-modals--active');
|
||||
document.documentElement.style.marginRight = '0';
|
||||
|
||||
this.setState({
|
||||
media: null,
|
||||
index: null,
|
||||
|
||||
@@ -179,15 +179,25 @@ function loaded() {
|
||||
({ target }) => {
|
||||
if (!(target instanceof HTMLInputElement)) return;
|
||||
|
||||
if (target.value && target.value.length > 0) {
|
||||
const checkedUsername = target.value;
|
||||
if (checkedUsername && checkedUsername.length > 0) {
|
||||
axios
|
||||
.get('/api/v1/accounts/lookup', { params: { acct: target.value } })
|
||||
.get('/api/v1/accounts/lookup', {
|
||||
params: { acct: checkedUsername },
|
||||
})
|
||||
.then(() => {
|
||||
target.setCustomValidity(formatMessage(messages.usernameTaken));
|
||||
// Only update the validity if the result is for the currently-typed username
|
||||
if (checkedUsername === target.value) {
|
||||
target.setCustomValidity(formatMessage(messages.usernameTaken));
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.catch(() => {
|
||||
target.setCustomValidity('');
|
||||
// Only update the validity if the result is for the currently-typed username
|
||||
if (checkedUsername === target.value) {
|
||||
target.setCustomValidity('');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
target.setCustomValidity('');
|
||||
|
||||
@@ -6,6 +6,7 @@ import classNames from 'classnames';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import { AccountBio } from '@/flavours/glitch/components/account_bio';
|
||||
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
@@ -772,12 +773,11 @@ export const AccountHeader: React.FC<{
|
||||
<Icon
|
||||
id='lock'
|
||||
icon={LockIcon}
|
||||
title={intl.formatMessage(messages.account_locked)}
|
||||
aria-label={intl.formatMessage(messages.account_locked)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const content = { __html: account.note_emojified };
|
||||
const displayNameHtml = { __html: account.display_name_html };
|
||||
const fields = account.fields;
|
||||
const isLocal = !account.acct.includes('@');
|
||||
@@ -901,12 +901,11 @@ export const AccountHeader: React.FC<{
|
||||
<AccountNote accountId={accountId} />
|
||||
)}
|
||||
|
||||
{account.note.length > 0 && account.note !== '<p></p>' && (
|
||||
<div
|
||||
className='account__header__content translate'
|
||||
dangerouslySetInnerHTML={content}
|
||||
/>
|
||||
)}
|
||||
<AccountBio
|
||||
note={account.note_emojified}
|
||||
dropdownAccountId={accountId}
|
||||
className='account__header__content'
|
||||
/>
|
||||
|
||||
<div className='account__header__fields'>
|
||||
<dl>
|
||||
|
||||
@@ -261,7 +261,9 @@ export const AltTextModal = forwardRef<ModalRef, Props & Partial<RestoreProps>>(
|
||||
);
|
||||
const lang = useAppSelector(
|
||||
(state) =>
|
||||
(state.compose as ImmutableMap<string, unknown>).get('lang') as string,
|
||||
(state.compose as ImmutableMap<string, unknown>).get(
|
||||
'language',
|
||||
) as string,
|
||||
);
|
||||
const focusX =
|
||||
(media?.getIn(['meta', 'focus', 'x'], 0) as number | undefined) ?? 0;
|
||||
|
||||
@@ -12,9 +12,10 @@ import { length } from 'stringz';
|
||||
|
||||
import { missingAltTextModal } from 'flavours/glitch/initial_state';
|
||||
|
||||
import AutosuggestInput from '../../../components/autosuggest_input';
|
||||
import AutosuggestTextarea from '../../../components/autosuggest_textarea';
|
||||
import { Button } from '../../../components/button';
|
||||
import AutosuggestInput from 'flavours/glitch/components/autosuggest_input';
|
||||
import AutosuggestTextarea from 'flavours/glitch/components/autosuggest_textarea';
|
||||
import { Button } from 'flavours/glitch/components/button';
|
||||
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
||||
import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
|
||||
import PollButtonContainer from '../containers/poll_button_container';
|
||||
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
|
||||
@@ -80,6 +81,7 @@ class ComposeForm extends ImmutablePureComponent {
|
||||
singleColumn: PropTypes.bool,
|
||||
lang: PropTypes.string,
|
||||
maxChars: PropTypes.number,
|
||||
redirectOnSuccess: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
@@ -242,9 +244,8 @@ class ComposeForm extends ImmutablePureComponent {
|
||||
};
|
||||
|
||||
render () {
|
||||
const { intl, onPaste, autoFocus, withoutNavigation, maxChars } = this.props;
|
||||
const { intl, onPaste, autoFocus, withoutNavigation, maxChars, isSubmitting } = this.props;
|
||||
const { highlighted } = this.state;
|
||||
const disabled = this.props.isSubmitting;
|
||||
|
||||
return (
|
||||
<form className='compose-form' onSubmit={this.handleSubmit}>
|
||||
@@ -263,7 +264,7 @@ class ComposeForm extends ImmutablePureComponent {
|
||||
<AutosuggestInput
|
||||
placeholder={intl.formatMessage(messages.spoiler_placeholder)}
|
||||
value={this.props.spoilerText}
|
||||
disabled={disabled}
|
||||
disabled={isSubmitting}
|
||||
onChange={this.handleChangeSpoilerText}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
ref={this.setSpoilerText}
|
||||
@@ -285,7 +286,7 @@ class ComposeForm extends ImmutablePureComponent {
|
||||
<AutosuggestTextarea
|
||||
ref={this.textareaRef}
|
||||
placeholder={intl.formatMessage(messages.placeholder)}
|
||||
disabled={disabled}
|
||||
disabled={isSubmitting}
|
||||
value={this.props.text}
|
||||
onChange={this.handleChange}
|
||||
suggestions={this.props.suggestions}
|
||||
@@ -331,9 +332,15 @@ class ComposeForm extends ImmutablePureComponent {
|
||||
<Button
|
||||
type='submit'
|
||||
compact
|
||||
text={intl.formatMessage(this.props.isEditing ? messages.saveChanges : (this.props.isInReply ? messages.reply : messages.publish))}
|
||||
disabled={!this.canSubmit()}
|
||||
/>
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{intl.formatMessage(
|
||||
this.props.isEditing ?
|
||||
messages.saveChanges :
|
||||
(this.props.isInReply ? messages.reply : messages.publish)
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,7 @@ import { HASHTAG_REGEX } from 'flavours/glitch/utils/hashtags';
|
||||
|
||||
const messages = defineMessages({
|
||||
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
|
||||
clearSearch: { id: 'search.clear', defaultMessage: 'Clear search' },
|
||||
placeholderSignedIn: {
|
||||
id: 'search.search_or_paste',
|
||||
defaultMessage: 'Search or paste URL',
|
||||
@@ -46,8 +47,32 @@ const labelForRecentSearch = (search: RecentSearch) => {
|
||||
}
|
||||
};
|
||||
|
||||
const unfocus = () => {
|
||||
document.querySelector('.ui')?.parentElement?.focus();
|
||||
const ClearButton: React.FC<{
|
||||
onClick: () => void;
|
||||
hasValue: boolean;
|
||||
}> = ({ onClick, hasValue }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('search__icon-wrapper', { 'has-value': hasValue })}
|
||||
>
|
||||
<Icon id='search' icon={SearchIcon} className='search__icon' />
|
||||
<button
|
||||
type='button'
|
||||
onClick={onClick}
|
||||
className='search__icon search__icon--clear-button'
|
||||
tabIndex={hasValue ? undefined : -1}
|
||||
aria-hidden={!hasValue}
|
||||
>
|
||||
<Icon
|
||||
id='times-circle'
|
||||
icon={CancelIcon}
|
||||
aria-label={intl.formatMessage(messages.clearSearch)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SearchOption {
|
||||
@@ -78,6 +103,11 @@ export const Search: React.FC<{
|
||||
}, [initialValue]);
|
||||
const searchOptions: SearchOption[] = [];
|
||||
|
||||
const unfocus = useCallback(() => {
|
||||
document.querySelector('.ui')?.parentElement?.focus();
|
||||
setExpanded(false);
|
||||
}, []);
|
||||
|
||||
if (searchEnabled) {
|
||||
searchOptions.push(
|
||||
{
|
||||
@@ -253,7 +283,7 @@ export const Search: React.FC<{
|
||||
history.push({ pathname: '/search', search: queryParams.toString() });
|
||||
unfocus();
|
||||
},
|
||||
[dispatch, history],
|
||||
[dispatch, history, unfocus],
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
@@ -373,14 +403,15 @@ export const Search: React.FC<{
|
||||
|
||||
setQuickActions(newQuickActions);
|
||||
},
|
||||
[dispatch, history, signedIn, setValue, setQuickActions, submit],
|
||||
[signedIn, dispatch, unfocus, history, submit],
|
||||
);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setValue('');
|
||||
setQuickActions([]);
|
||||
setSelectedOption(-1);
|
||||
}, [setValue, setQuickActions, setSelectedOption]);
|
||||
unfocus();
|
||||
}, [unfocus]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
@@ -431,7 +462,7 @@ export const Search: React.FC<{
|
||||
break;
|
||||
}
|
||||
},
|
||||
[navigableOptions, value, selectedOption, setSelectedOption, submit],
|
||||
[unfocus, navigableOptions, selectedOption, submit, value],
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
@@ -451,12 +482,38 @@ export const Search: React.FC<{
|
||||
}, [setExpanded, setSelectedOption, singleColumn]);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setExpanded(false);
|
||||
setSelectedOption(-1);
|
||||
}, [setExpanded, setSelectedOption]);
|
||||
}, [setSelectedOption]);
|
||||
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// If the search popover is expanded, close it when tabbing or
|
||||
// clicking outside of it or the search form, while allowing
|
||||
// tabbing or clicking inside of the popover
|
||||
if (expanded) {
|
||||
function closeOnLeave(event: FocusEvent | MouseEvent) {
|
||||
const form = formRef.current;
|
||||
const isClickInsideForm =
|
||||
form &&
|
||||
(form === event.target || form.contains(event.target as Node));
|
||||
if (!isClickInsideForm) {
|
||||
setExpanded(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('focusin', closeOnLeave);
|
||||
document.addEventListener('click', closeOnLeave);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('focusin', closeOnLeave);
|
||||
document.removeEventListener('click', closeOnLeave);
|
||||
};
|
||||
}
|
||||
return () => null;
|
||||
}, [expanded]);
|
||||
|
||||
return (
|
||||
<form className={classNames('search', { active: expanded })}>
|
||||
<form ref={formRef} className={classNames('search', { active: expanded })}>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
className='search__input'
|
||||
@@ -474,21 +531,9 @@ export const Search: React.FC<{
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
|
||||
<button type='button' className='search__icon' onClick={handleClear}>
|
||||
<Icon
|
||||
id='search'
|
||||
icon={SearchIcon}
|
||||
className={hasValue ? '' : 'active'}
|
||||
/>
|
||||
<Icon
|
||||
id='times-circle'
|
||||
icon={CancelIcon}
|
||||
className={hasValue ? 'active' : ''}
|
||||
aria-label={intl.formatMessage(messages.placeholder)}
|
||||
/>
|
||||
</button>
|
||||
<ClearButton hasValue={hasValue} onClick={handleClear} />
|
||||
|
||||
<div className='search__popout'>
|
||||
<div className='search__popout' tabIndex={-1}>
|
||||
{!hasValue && (
|
||||
<>
|
||||
<h4>
|
||||
|
||||
@@ -56,7 +56,7 @@ const mapStateToProps = state => ({
|
||||
maxChars: state.getIn(['server', 'server', 'configuration', 'statuses', 'max_characters'], 500),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
const mapDispatchToProps = (dispatch, props) => ({
|
||||
|
||||
onChange (text) {
|
||||
dispatch(changeCompose(text));
|
||||
@@ -69,7 +69,11 @@ const mapDispatchToProps = (dispatch) => ({
|
||||
modalProps: { overridePrivacy },
|
||||
}));
|
||||
} else {
|
||||
dispatch(submitCompose(overridePrivacy));
|
||||
dispatch(submitCompose(overridePrivacy, (status) => {
|
||||
if (props.redirectOnSuccess) {
|
||||
window.location.assign(status.url);
|
||||
}
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -24,33 +24,28 @@ import { Icon } from 'flavours/glitch/components/icon';
|
||||
import glitchedElephant1 from 'flavours/glitch/images/mbstobon-ui-0.png';
|
||||
import glitchedElephant2 from 'flavours/glitch/images/mbstobon-ui-1.png';
|
||||
import glitchedElephant3 from 'flavours/glitch/images/mbstobon-ui-2.png';
|
||||
import { mascot } from 'flavours/glitch/initial_state';
|
||||
import { mascot, reduceMotion } from 'flavours/glitch/initial_state';
|
||||
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
||||
|
||||
import { messages as navbarMessages } from '../ui/components/navigation_bar';
|
||||
|
||||
import { Search } from './components/search';
|
||||
import ComposeFormContainer from './containers/compose_form_container';
|
||||
|
||||
const messages = defineMessages({
|
||||
start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
|
||||
home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' },
|
||||
notifications: {
|
||||
id: 'tabs_bar.notifications',
|
||||
defaultMessage: 'Notifications',
|
||||
live_feed_public: {
|
||||
id: 'navigation_bar.live_feed_public',
|
||||
defaultMessage: 'Live feed (public)',
|
||||
},
|
||||
public: {
|
||||
id: 'navigation_bar.public_timeline',
|
||||
defaultMessage: 'Federated timeline',
|
||||
},
|
||||
community: {
|
||||
id: 'navigation_bar.community_timeline',
|
||||
defaultMessage: 'Local timeline',
|
||||
live_feed_local: {
|
||||
id: 'navigation_bar.live_feed_local',
|
||||
defaultMessage: 'Live feed (local)',
|
||||
},
|
||||
settings: {
|
||||
id: 'navigation_bar.app_settings',
|
||||
defaultMessage: 'App settings',
|
||||
},
|
||||
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
|
||||
compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new post' },
|
||||
});
|
||||
|
||||
type ColumnMap = ImmutableMap<'id' | 'uuid' | 'params', string>;
|
||||
@@ -127,19 +122,27 @@ const Compose: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
||||
elephantUIPlane,
|
||||
][elefriend];
|
||||
|
||||
const scrollNavbarIntoView = useCallback(() => {
|
||||
const navbar = document.querySelector('.navigation-panel');
|
||||
navbar?.scrollIntoView({
|
||||
behavior: reduceMotion ? 'auto' : 'smooth',
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (multiColumn) {
|
||||
return (
|
||||
<div
|
||||
className='drawer'
|
||||
role='region'
|
||||
aria-label={intl.formatMessage(messages.compose)}
|
||||
aria-label={intl.formatMessage(navbarMessages.publish)}
|
||||
>
|
||||
<nav className='drawer__header'>
|
||||
<Link
|
||||
to='/getting-started'
|
||||
className='drawer__tab'
|
||||
title={intl.formatMessage(messages.start)}
|
||||
aria-label={intl.formatMessage(messages.start)}
|
||||
title={intl.formatMessage(navbarMessages.menu)}
|
||||
aria-label={intl.formatMessage(navbarMessages.menu)}
|
||||
onClick={scrollNavbarIntoView}
|
||||
>
|
||||
<Icon id='bars' icon={MenuIcon} />
|
||||
</Link>
|
||||
@@ -147,8 +150,8 @@ const Compose: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
||||
<Link
|
||||
to='/home'
|
||||
className='drawer__tab'
|
||||
title={intl.formatMessage(messages.home_timeline)}
|
||||
aria-label={intl.formatMessage(messages.home_timeline)}
|
||||
title={intl.formatMessage(navbarMessages.home)}
|
||||
aria-label={intl.formatMessage(navbarMessages.home)}
|
||||
>
|
||||
<Icon id='home' icon={HomeIcon} />
|
||||
</Link>
|
||||
@@ -157,8 +160,8 @@ const Compose: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
||||
<Link
|
||||
to='/notifications'
|
||||
className='drawer__tab'
|
||||
title={intl.formatMessage(messages.notifications)}
|
||||
aria-label={intl.formatMessage(messages.notifications)}
|
||||
title={intl.formatMessage(navbarMessages.notifications)}
|
||||
aria-label={intl.formatMessage(navbarMessages.notifications)}
|
||||
>
|
||||
<span className='icon-badge-wrapper'>
|
||||
<Icon id='bell' icon={NotificationsIcon} />
|
||||
@@ -172,8 +175,8 @@ const Compose: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
||||
<Link
|
||||
to='/public/local'
|
||||
className='drawer__tab'
|
||||
title={intl.formatMessage(messages.community)}
|
||||
aria-label={intl.formatMessage(messages.community)}
|
||||
title={intl.formatMessage(messages.live_feed_local)}
|
||||
aria-label={intl.formatMessage(messages.live_feed_local)}
|
||||
>
|
||||
<Icon id='users' icon={PeopleIcon} />
|
||||
</Link>
|
||||
@@ -182,8 +185,8 @@ const Compose: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
||||
<Link
|
||||
to='/public'
|
||||
className='drawer__tab'
|
||||
title={intl.formatMessage(messages.public)}
|
||||
aria-label={intl.formatMessage(messages.public)}
|
||||
title={intl.formatMessage(messages.live_feed_public)}
|
||||
aria-label={intl.formatMessage(messages.live_feed_public)}
|
||||
>
|
||||
<Icon id='globe' icon={PublicIcon} />
|
||||
</Link>
|
||||
@@ -230,12 +233,12 @@ const Compose: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
||||
return (
|
||||
<Column
|
||||
bindToDocument={!multiColumn}
|
||||
label={intl.formatMessage(messages.compose)}
|
||||
label={intl.formatMessage(navbarMessages.publish)}
|
||||
>
|
||||
<ColumnHeader
|
||||
icon='pencil'
|
||||
iconComponent={EditIcon}
|
||||
title={intl.formatMessage(messages.compose)}
|
||||
title={intl.formatMessage(navbarMessages.publish)}
|
||||
multiColumn={multiColumn}
|
||||
showBackButton
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useEffect, useCallback, useRef } from 'react';
|
||||
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
@@ -7,8 +7,10 @@ import { Helmet } from 'react-helmet';
|
||||
import { isFulfilled } from '@reduxjs/toolkit';
|
||||
|
||||
import TagIcon from '@/material-icons/400-24px/tag.svg?react';
|
||||
import { unfollowHashtag } from 'flavours/glitch/actions/tags_typed';
|
||||
import { apiGetFollowedTags } from 'flavours/glitch/api/tags';
|
||||
import {
|
||||
fetchFollowedHashtags,
|
||||
unfollowHashtag,
|
||||
} from 'flavours/glitch/actions/tags_typed';
|
||||
import type { ApiHashtagJSON } from 'flavours/glitch/api_types/tags';
|
||||
import { Button } from 'flavours/glitch/components/button';
|
||||
import { Column } from 'flavours/glitch/components/column';
|
||||
@@ -16,7 +18,7 @@ import type { ColumnRef } from 'flavours/glitch/components/column';
|
||||
import { ColumnHeader } from 'flavours/glitch/components/column_header';
|
||||
import { Hashtag } from 'flavours/glitch/components/hashtag';
|
||||
import ScrollableList from 'flavours/glitch/components/scrollable_list';
|
||||
import { useAppDispatch } from 'flavours/glitch/store';
|
||||
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'followed_tags', defaultMessage: 'Followed hashtags' },
|
||||
@@ -59,55 +61,32 @@ const FollowedTag: React.FC<{
|
||||
|
||||
const FollowedTags: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
||||
const intl = useIntl();
|
||||
const [tags, setTags] = useState<ApiHashtagJSON[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [next, setNext] = useState<string | undefined>();
|
||||
const dispatch = useAppDispatch();
|
||||
const { tags, loading, next, stale } = useAppSelector(
|
||||
(state) => state.followedTags,
|
||||
);
|
||||
const hasMore = !!next;
|
||||
const columnRef = useRef<ColumnRef>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
|
||||
void apiGetFollowedTags()
|
||||
.then(({ tags, links }) => {
|
||||
const next = links.refs.find((link) => link.rel === 'next');
|
||||
|
||||
setTags(tags);
|
||||
setLoading(false);
|
||||
setNext(next?.uri);
|
||||
|
||||
return '';
|
||||
})
|
||||
.catch(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [setTags, setLoading, setNext]);
|
||||
if (stale) {
|
||||
void dispatch(fetchFollowedHashtags());
|
||||
}
|
||||
}, [dispatch, stale]);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
setLoading(true);
|
||||
|
||||
void apiGetFollowedTags(next)
|
||||
.then(({ tags, links }) => {
|
||||
const next = links.refs.find((link) => link.rel === 'next');
|
||||
|
||||
setLoading(false);
|
||||
setTags((previousTags) => [...previousTags, ...tags]);
|
||||
setNext(next?.uri);
|
||||
|
||||
return '';
|
||||
})
|
||||
.catch(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [setTags, setLoading, setNext, next]);
|
||||
if (next) {
|
||||
void dispatch(fetchFollowedHashtags({ next }));
|
||||
}
|
||||
}, [dispatch, next]);
|
||||
|
||||
const handleUnfollow = useCallback(
|
||||
(tagId: string) => {
|
||||
setTags((tags) => tags.filter((tag) => tag.name !== tagId));
|
||||
void dispatch(unfollowHashtag({ tagId }));
|
||||
},
|
||||
[setTags],
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const columnRef = useRef<ColumnRef>(null);
|
||||
const handleHeaderClick = useCallback(() => {
|
||||
columnRef.current?.scrollTop();
|
||||
}, []);
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import BookmarksIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react';
|
||||
import ExploreIcon from '@/material-icons/400-24px/explore.svg?react';
|
||||
import ModerationIcon from '@/material-icons/400-24px/gavel.svg?react';
|
||||
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
|
||||
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
|
||||
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
|
||||
import MailIcon from '@/material-icons/400-24px/mail.svg?react';
|
||||
import AdministrationIcon from '@/material-icons/400-24px/manufacturing.svg?react';
|
||||
import MenuIcon from '@/material-icons/400-24px/menu.svg?react';
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react';
|
||||
import PersonAddIcon from '@/material-icons/400-24px/person_add.svg?react';
|
||||
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
|
||||
import SettingsIcon from '@/material-icons/400-24px/settings-fill.svg?react';
|
||||
import { fetchFollowRequests } from 'flavours/glitch/actions/accounts';
|
||||
import { fetchLists } from 'flavours/glitch/actions/lists';
|
||||
import { openModal } from 'flavours/glitch/actions/modal';
|
||||
import Column from 'flavours/glitch/features/ui/components/column';
|
||||
import { LinkFooter } from 'flavours/glitch/features/ui/components/link_footer';
|
||||
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
|
||||
import { canManageReports, canViewAdminDashboard } from 'flavours/glitch/permissions';
|
||||
import { preferencesLink } from 'flavours/glitch/utils/backend_links';
|
||||
|
||||
import { me, showTrends } from '../../initial_state';
|
||||
import { NavigationBar } from '../compose/components/navigation_bar';
|
||||
import { ColumnLink } from '../ui/components/column_link';
|
||||
import ColumnSubheading from '../ui/components/column_subheading';
|
||||
|
||||
import { Trends } from 'flavours/glitch/features/navigation_panel/components/trends';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
|
||||
home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' },
|
||||
notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' },
|
||||
public_timeline: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' },
|
||||
navigation_subheading: { id: 'column_subheading.navigation', defaultMessage: 'Navigation' },
|
||||
settings_subheading: { id: 'column_subheading.settings', defaultMessage: 'Settings' },
|
||||
community_timeline: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
|
||||
explore: { id: 'navigation_bar.explore', defaultMessage: 'Explore' },
|
||||
direct: { id: 'navigation_bar.direct', defaultMessage: 'Private mentions' },
|
||||
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
|
||||
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
|
||||
administration: { id: 'navigation_bar.administration', defaultMessage: 'Administration' },
|
||||
moderation: { id: 'navigation_bar.moderation', defaultMessage: 'Moderation' },
|
||||
settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' },
|
||||
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
|
||||
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
|
||||
keyboard_shortcuts: { id: 'navigation_bar.keyboard_shortcuts', defaultMessage: 'Keyboard shortcuts' },
|
||||
lists_subheading: { id: 'column_subheading.lists', defaultMessage: 'Lists' },
|
||||
misc: { id: 'navigation_bar.misc', defaultMessage: 'Misc' },
|
||||
menu: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
|
||||
});
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const getOrderedLists = createSelector([state => state.get('lists')], lists => {
|
||||
if (!lists) {
|
||||
return lists;
|
||||
}
|
||||
|
||||
return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title')));
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
lists: getOrderedLists(state),
|
||||
myAccount: state.getIn(['accounts', me]),
|
||||
columns: state.getIn(['settings', 'columns']),
|
||||
unreadFollowRequests: state.getIn(['user_lists', 'follow_requests', 'items'], ImmutableList()).size,
|
||||
unreadNotifications: state.getIn(['notifications', 'unread']),
|
||||
});
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
fetchFollowRequests: () => dispatch(fetchFollowRequests()),
|
||||
fetchLists: () => dispatch(fetchLists()),
|
||||
openSettings: () => dispatch(openModal({
|
||||
modalType: 'SETTINGS',
|
||||
modalProps: {},
|
||||
})),
|
||||
});
|
||||
|
||||
const badgeDisplay = (number, limit) => {
|
||||
if (number === 0) {
|
||||
return undefined;
|
||||
} else if (limit && number >= limit) {
|
||||
return `${limit}+`;
|
||||
} else {
|
||||
return number;
|
||||
}
|
||||
};
|
||||
|
||||
class GettingStarted extends ImmutablePureComponent {
|
||||
static propTypes = {
|
||||
identity: identityContextPropShape,
|
||||
intl: PropTypes.object.isRequired,
|
||||
myAccount: ImmutablePropTypes.record,
|
||||
columns: ImmutablePropTypes.list,
|
||||
multiColumn: PropTypes.bool,
|
||||
fetchFollowRequests: PropTypes.func.isRequired,
|
||||
unreadFollowRequests: PropTypes.number,
|
||||
unreadNotifications: PropTypes.number,
|
||||
lists: ImmutablePropTypes.list,
|
||||
fetchLists: PropTypes.func.isRequired,
|
||||
openSettings: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
UNSAFE_componentWillMount () {
|
||||
this.props.fetchLists();
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
const { fetchFollowRequests } = this.props;
|
||||
const { signedIn } = this.props.identity;
|
||||
|
||||
if (!signedIn) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetchFollowRequests();
|
||||
}
|
||||
|
||||
render () {
|
||||
const { intl, myAccount, columns, multiColumn, unreadFollowRequests, unreadNotifications, lists, openSettings } = this.props;
|
||||
const { signedIn, permissions } = this.props.identity;
|
||||
|
||||
const navItems = [];
|
||||
let listItems = [];
|
||||
|
||||
if (multiColumn) {
|
||||
if (signedIn && !columns.find(item => item.get('id') === 'HOME')) {
|
||||
navItems.push(<ColumnLink key='home' icon='home' iconComponent={HomeIcon} text={intl.formatMessage(messages.home_timeline)} to='/home' />);
|
||||
}
|
||||
|
||||
if (!columns.find(item => item.get('id') === 'NOTIFICATIONS')) {
|
||||
navItems.push(<ColumnLink key='notifications' icon='bell' iconComponent={NotificationsIcon} text={intl.formatMessage(messages.notifications)} badge={badgeDisplay(unreadNotifications)} to='/notifications' />);
|
||||
}
|
||||
|
||||
if (!columns.find(item => item.get('id') === 'COMMUNITY')) {
|
||||
navItems.push(<ColumnLink key='community_timeline' icon='users' iconComponent={PeopleIcon} text={intl.formatMessage(messages.community_timeline)} to='/public/local' />);
|
||||
}
|
||||
|
||||
if (!columns.find(item => item.get('id') === 'PUBLIC')) {
|
||||
navItems.push(<ColumnLink key='public_timeline' icon='globe' iconComponent={PublicIcon} text={intl.formatMessage(messages.public_timeline)} to='/public' />);
|
||||
}
|
||||
}
|
||||
|
||||
if (showTrends) {
|
||||
navItems.push(<ColumnLink key='explore' icon='explore' iconComponent={ExploreIcon} text={intl.formatMessage(messages.explore)} to='/explore' />);
|
||||
}
|
||||
|
||||
if (signedIn) {
|
||||
if (!multiColumn || !columns.find(item => item.get('id') === 'DIRECT')) {
|
||||
navItems.push(<ColumnLink key='conversations' icon='envelope' iconComponent={MailIcon} text={intl.formatMessage(messages.direct)} to='/conversations' />);
|
||||
}
|
||||
|
||||
if (!multiColumn || !columns.find(item => item.get('id') === 'BOOKMARKS')) {
|
||||
navItems.push(<ColumnLink key='bookmarks' icon='bookmark' iconComponent={BookmarksIcon} text={intl.formatMessage(messages.bookmarks)} to='/bookmarks' />);
|
||||
}
|
||||
|
||||
if (myAccount.get('locked') || unreadFollowRequests > 0) {
|
||||
navItems.push(<ColumnLink key='follow_requests' icon='user-plus' iconComponent={PersonAddIcon} text={intl.formatMessage(messages.follow_requests)} badge={badgeDisplay(unreadFollowRequests, 40)} to='/follow_requests' />);
|
||||
}
|
||||
|
||||
navItems.push(<ColumnLink key='getting_started' icon='ellipsis-h' iconComponent={MoreHorizIcon} text={intl.formatMessage(messages.misc)} to='/getting-started-misc' />);
|
||||
|
||||
listItems = listItems.concat([
|
||||
<div key='9'>
|
||||
<ColumnLink key='lists' icon='bars' iconComponent={ListAltIcon} text={intl.formatMessage(messages.lists)} to='/lists' />
|
||||
{lists.filter(list => !columns.find(item => item.get('id') === 'LIST' && item.getIn(['params', 'id']) === list.get('id'))).map(list =>
|
||||
<ColumnLink key={`list-${list.get('id')}`} to={`/lists/${list.get('id')}`} icon='list-ul' iconComponent={ListAltIcon} text={list.get('title')} />,
|
||||
)}
|
||||
</div>,
|
||||
]);
|
||||
}
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn} icon='bars' iconComponent={MenuIcon} heading={intl.formatMessage(messages.heading)} label={intl.formatMessage(messages.menu)} hideHeadingOnMobile>
|
||||
<div className='scrollable optionally-scrollable'>
|
||||
<div className='getting-started__wrapper'>
|
||||
{!multiColumn && signedIn && <NavigationBar account={myAccount} />}
|
||||
{multiColumn && <ColumnSubheading text={intl.formatMessage(messages.navigation_subheading)} />}
|
||||
{navItems}
|
||||
{signedIn && (
|
||||
<>
|
||||
<ColumnSubheading text={intl.formatMessage(messages.lists_subheading)} />
|
||||
{listItems}
|
||||
<ColumnSubheading text={intl.formatMessage(messages.settings_subheading)} />
|
||||
{ preferencesLink !== undefined && <ColumnLink icon='cog' iconComponent={SettingsIcon} text={intl.formatMessage(messages.preferences)} href={preferencesLink} /> }
|
||||
<ColumnLink icon='cogs' iconComponent={AdministrationIcon} text={intl.formatMessage(messages.settings)} onClick={openSettings} />
|
||||
{canManageReports(permissions) && <ColumnLink key='moderation' href='/admin/reports' icon='flag' iconComponent={ModerationIcon} text={intl.formatMessage(messages.moderation)} />}
|
||||
{canViewAdminDashboard(permissions) && <ColumnLink key='administration' href='/admin/dashboard' icon='tachometer' iconComponent={AdministrationIcon} text={intl.formatMessage(messages.administration)} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<LinkFooter multiColumn />
|
||||
</div>
|
||||
|
||||
{(multiColumn && showTrends) && <Trends />}
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.menu)}</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default withIdentity(connect(makeMapStateToProps, mapDispatchToProps)(injectIntl(GettingStarted)));
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import { Column } from 'flavours/glitch/components/column';
|
||||
|
||||
import { NavigationPanel } from '../navigation_panel';
|
||||
import { LinkFooter } from '../ui/components/link_footer';
|
||||
|
||||
const GettingStarted: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<Column>
|
||||
<NavigationPanel multiColumn />
|
||||
|
||||
<LinkFooter multiColumn />
|
||||
|
||||
<Helmet>
|
||||
<title>
|
||||
{intl.formatMessage({
|
||||
id: 'getting_started.heading',
|
||||
defaultMessage: 'Getting started',
|
||||
})}
|
||||
</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default GettingStarted;
|
||||
@@ -1,59 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import BlockIcon from '@/material-icons/400-24px/block.svg?react';
|
||||
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
import PushPinIcon from '@/material-icons/400-24px/push_pin.svg?react';
|
||||
import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
|
||||
import VolumeOffIcon from '@/material-icons/400-24px/volume_off.svg?react';
|
||||
import Column from 'flavours/glitch/features/ui/components/column';
|
||||
import { ColumnLink } from 'flavours/glitch/features/ui/components/column_link';
|
||||
import ColumnSubheading from 'flavours/glitch/features/ui/components/column_subheading';
|
||||
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.heading', defaultMessage: 'Misc' },
|
||||
subheading: { id: 'column.subheading', defaultMessage: 'Miscellaneous options' },
|
||||
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' },
|
||||
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
|
||||
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Blocked domains' },
|
||||
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
|
||||
pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' },
|
||||
keyboard_shortcuts: { id: 'navigation_bar.keyboard_shortcuts', defaultMessage: 'Keyboard shortcuts' },
|
||||
});
|
||||
|
||||
class GettingStartedMisc extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
identity: identityContextPropShape,
|
||||
intl: PropTypes.object.isRequired,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { intl } = this.props;
|
||||
const { signedIn } = this.props.identity;
|
||||
|
||||
return (
|
||||
<Column icon='ellipsis-h' iconComponent={MoreHorizIcon} heading={intl.formatMessage(messages.heading)} alwaysShowBackButton>
|
||||
<div className='scrollable'>
|
||||
<ColumnSubheading text={intl.formatMessage(messages.subheading)} />
|
||||
{signedIn && (<ColumnLink key='favourites' icon='star' iconComponent={StarIcon} text={intl.formatMessage(messages.favourites)} to='/favourites' />)}
|
||||
{signedIn && (<ColumnLink key='pinned' icon='thumb-tack' iconComponent={PushPinIcon} text={intl.formatMessage(messages.pins)} to='/pinned' />)}
|
||||
{signedIn && (<ColumnLink key='mutes' icon='volume-off' iconComponent={VolumeOffIcon} text={intl.formatMessage(messages.mutes)} to='/mutes' />)}
|
||||
{signedIn && (<ColumnLink key='blocks' icon='ban' iconComponent={BlockIcon} text={intl.formatMessage(messages.blocks)} to='/blocks' />)}
|
||||
{signedIn && (<ColumnLink key='domain_blocks' icon='minus-circle' iconComponent={BlockIcon} text={intl.formatMessage(messages.domain_blocks)} to='/domain_blocks' />)}
|
||||
<ColumnLink key='shortcuts' icon='question' iconComponent={InfoIcon} text={intl.formatMessage(messages.keyboard_shortcuts)} to='/keyboard-shortcuts' />
|
||||
</div>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect()(withIdentity(injectIntl(GettingStartedMisc)));
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import TagIcon from '@/material-icons/400-24px/tag.svg?react';
|
||||
import { apiGetFollowedTags } from 'flavours/glitch/api/tags';
|
||||
import type { ApiHashtagJSON } from 'flavours/glitch/api_types/tags';
|
||||
import { fetchFollowedHashtags } from 'flavours/glitch/actions/tags_typed';
|
||||
import { ColumnLink } from 'flavours/glitch/features/ui/components/column_link';
|
||||
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
||||
|
||||
import { CollapsiblePanel } from './collapsible_panel';
|
||||
|
||||
@@ -24,25 +24,20 @@ const messages = defineMessages({
|
||||
},
|
||||
});
|
||||
|
||||
const TAG_LIMIT = 4;
|
||||
|
||||
export const FollowedTagsPanel: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const [tags, setTags] = useState<ApiHashtagJSON[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const dispatch = useAppDispatch();
|
||||
const { tags, stale, loading } = useAppSelector(
|
||||
(state) => state.followedTags,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
|
||||
void apiGetFollowedTags(undefined, 4)
|
||||
.then(({ tags }) => {
|
||||
setTags(tags);
|
||||
setLoading(false);
|
||||
|
||||
return '';
|
||||
})
|
||||
.catch(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [setLoading, setTags]);
|
||||
if (stale) {
|
||||
void dispatch(fetchFollowedHashtags());
|
||||
}
|
||||
}, [dispatch, stale]);
|
||||
|
||||
return (
|
||||
<CollapsiblePanel
|
||||
@@ -54,14 +49,14 @@ export const FollowedTagsPanel: React.FC = () => {
|
||||
expandTitle={intl.formatMessage(messages.expand)}
|
||||
loading={loading}
|
||||
>
|
||||
{tags.map((tag) => (
|
||||
{tags.slice(0, TAG_LIMIT).map((tag) => (
|
||||
<ColumnLink
|
||||
transparent
|
||||
icon='hashtag'
|
||||
key={tag.name}
|
||||
iconComponent={TagIcon}
|
||||
text={`#${tag.name}`}
|
||||
to={`/tags/${tag.name}`}
|
||||
transparent
|
||||
/>
|
||||
))}
|
||||
</CollapsiblePanel>
|
||||
|
||||
@@ -53,16 +53,22 @@ export const MoreLink: React.FC = () => {
|
||||
|
||||
const menu = useMemo(() => {
|
||||
const arr: MenuItem[] = [
|
||||
{ text: intl.formatMessage(messages.filters), href: '/filters' },
|
||||
{ text: intl.formatMessage(messages.mutes), to: '/mutes' },
|
||||
{ text: intl.formatMessage(messages.blocks), to: '/blocks' },
|
||||
{
|
||||
text: intl.formatMessage(messages.domainBlocks),
|
||||
to: '/domain_blocks',
|
||||
href: '/filters',
|
||||
text: intl.formatMessage(messages.filters),
|
||||
},
|
||||
{
|
||||
to: '/mutes',
|
||||
text: intl.formatMessage(messages.mutes),
|
||||
},
|
||||
{
|
||||
to: '/blocks',
|
||||
text: intl.formatMessage(messages.blocks),
|
||||
},
|
||||
{
|
||||
to: '/domain_blocks',
|
||||
text: intl.formatMessage(messages.domainBlocks),
|
||||
},
|
||||
];
|
||||
|
||||
arr.push(
|
||||
null,
|
||||
{
|
||||
href: '/settings/privacy',
|
||||
@@ -80,7 +86,7 @@ export const MoreLink: React.FC = () => {
|
||||
href: '/settings/export',
|
||||
text: intl.formatMessage(messages.importExport),
|
||||
},
|
||||
);
|
||||
];
|
||||
|
||||
if (canManageReports(permissions)) {
|
||||
arr.push(null, {
|
||||
@@ -109,7 +115,7 @@ export const MoreLink: React.FC = () => {
|
||||
}, [intl, dispatch, permissions]);
|
||||
|
||||
return (
|
||||
<Dropdown items={menu}>
|
||||
<Dropdown items={menu} placement='bottom-start'>
|
||||
<button className='column-link column-link--transparent'>
|
||||
<Icon id='' icon={MoreHorizIcon} className='column-link__icon' />
|
||||
|
||||
|
||||
@@ -203,13 +203,186 @@ const isFirehoseActive = (
|
||||
|
||||
const MENU_WIDTH = 284;
|
||||
|
||||
export const NavigationPanel: React.FC = () => {
|
||||
export const NavigationPanel: React.FC<{ multiColumn?: boolean }> = ({
|
||||
multiColumn = false,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const { signedIn, disabledAccountId } = useIdentity();
|
||||
const location = useLocation();
|
||||
const showSearch = useBreakpoint('full') && !multiColumn;
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
let banner: React.ReactNode;
|
||||
|
||||
if (transientSingleColumn) {
|
||||
banner = (
|
||||
<div className='switch-to-advanced'>
|
||||
{intl.formatMessage(messages.openedInClassicInterface)}{' '}
|
||||
<a
|
||||
href={`/deck${location.pathname}`}
|
||||
className='switch-to-advanced__toggle'
|
||||
>
|
||||
{intl.formatMessage(messages.advancedInterface)}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleOpenSettings = useCallback<MouseEventHandler>(
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'SETTINGS',
|
||||
modalProps: {},
|
||||
}),
|
||||
);
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='navigation-panel'>
|
||||
{showSearch && <Search singleColumn />}
|
||||
|
||||
{!multiColumn && <ProfileCard />}
|
||||
|
||||
{banner && <div className='navigation-panel__banner'>{banner}</div>}
|
||||
|
||||
<div className='navigation-panel__menu'>
|
||||
{signedIn && (
|
||||
<>
|
||||
{!multiColumn && (
|
||||
<ColumnLink
|
||||
to='/publish'
|
||||
icon='plus'
|
||||
iconComponent={AddIcon}
|
||||
activeIconComponent={AddIcon}
|
||||
text={intl.formatMessage(messages.compose)}
|
||||
className='button navigation-panel__compose-button'
|
||||
/>
|
||||
)}
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/home'
|
||||
icon='home'
|
||||
iconComponent={HomeIcon}
|
||||
activeIconComponent={HomeActiveIcon}
|
||||
text={intl.formatMessage(messages.home)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{trendsEnabled && (
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/explore'
|
||||
icon='explore'
|
||||
iconComponent={TrendingUpIcon}
|
||||
text={intl.formatMessage(messages.explore)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(signedIn || timelinePreview) && (
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/public/local'
|
||||
icon='globe'
|
||||
iconComponent={PublicIcon}
|
||||
isActive={isFirehoseActive}
|
||||
text={intl.formatMessage(messages.firehose)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{signedIn && (
|
||||
<>
|
||||
<NotificationsLink />
|
||||
|
||||
<FollowRequestsLink />
|
||||
|
||||
<hr />
|
||||
|
||||
<ListPanel />
|
||||
|
||||
<FollowedTagsPanel />
|
||||
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/favourites'
|
||||
icon='star'
|
||||
iconComponent={StarIcon}
|
||||
activeIconComponent={StarActiveIcon}
|
||||
text={intl.formatMessage(messages.favourites)}
|
||||
/>
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/bookmarks'
|
||||
icon='bookmarks'
|
||||
iconComponent={BookmarksIcon}
|
||||
activeIconComponent={BookmarksActiveIcon}
|
||||
text={intl.formatMessage(messages.bookmarks)}
|
||||
/>
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/conversations'
|
||||
icon='at'
|
||||
iconComponent={AlternateEmailIcon}
|
||||
text={intl.formatMessage(messages.direct)}
|
||||
/>
|
||||
|
||||
<hr />
|
||||
|
||||
<ColumnLink
|
||||
transparent
|
||||
href='/settings/preferences'
|
||||
icon='cog'
|
||||
iconComponent={SettingsIcon}
|
||||
text={intl.formatMessage(messages.preferences)}
|
||||
/>
|
||||
<ColumnLink
|
||||
transparent
|
||||
onClick={handleOpenSettings}
|
||||
icon='cogs'
|
||||
iconComponent={AdministrationIcon}
|
||||
text={intl.formatMessage(messages.app_settings)}
|
||||
/>
|
||||
|
||||
<MoreLink />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className='navigation-panel__legal'>
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/about'
|
||||
icon='ellipsis-h'
|
||||
iconComponent={InfoIcon}
|
||||
text={intl.formatMessage(messages.about)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!signedIn && (
|
||||
<div className='navigation-panel__sign-in-banner'>
|
||||
<hr />
|
||||
|
||||
{disabledAccountId ? <DisabledAccountBanner /> : <SignInBanner />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex-spacer' />
|
||||
|
||||
<Trends />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CollapsibleNavigationPanel: React.FC = () => {
|
||||
const open = useAppSelector((state) => state.navigation.open);
|
||||
const dispatch = useAppDispatch();
|
||||
const openable = useBreakpoint('openable');
|
||||
const showSearch = useBreakpoint('full');
|
||||
const location = useLocation();
|
||||
const overlayRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -293,6 +466,7 @@ export const NavigationPanel: React.FC = () => {
|
||||
filterTaps: true,
|
||||
bounds: isLtrDir ? { left: 0 } : { right: 0 },
|
||||
rubberband: true,
|
||||
enabled: openable,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -311,37 +485,6 @@ export const NavigationPanel: React.FC = () => {
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
let banner: React.ReactNode;
|
||||
|
||||
if (transientSingleColumn) {
|
||||
banner = (
|
||||
<div className='switch-to-advanced'>
|
||||
{intl.formatMessage(messages.openedInClassicInterface)}{' '}
|
||||
<a
|
||||
href={`/deck${location.pathname}`}
|
||||
className='switch-to-advanced__toggle'
|
||||
>
|
||||
{intl.formatMessage(messages.advancedInterface)}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleOpenSettings = useCallback<MouseEventHandler>(
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'SETTINGS',
|
||||
modalProps: {},
|
||||
}),
|
||||
);
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const showOverlay = openable && open;
|
||||
|
||||
return (
|
||||
@@ -357,140 +500,7 @@ export const NavigationPanel: React.FC = () => {
|
||||
{...bind()}
|
||||
style={openable ? { x } : undefined}
|
||||
>
|
||||
<div className='navigation-panel'>
|
||||
{showSearch && <Search singleColumn />}
|
||||
|
||||
<ProfileCard />
|
||||
|
||||
{banner && <div className='navigation-panel__banner'>{banner}</div>}
|
||||
|
||||
<div className='navigation-panel__menu'>
|
||||
{signedIn && (
|
||||
<>
|
||||
<ColumnLink
|
||||
to='/publish'
|
||||
icon='plus'
|
||||
iconComponent={AddIcon}
|
||||
activeIconComponent={AddIcon}
|
||||
text={intl.formatMessage(messages.compose)}
|
||||
className='button navigation-panel__compose-button'
|
||||
/>
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/home'
|
||||
icon='home'
|
||||
iconComponent={HomeIcon}
|
||||
activeIconComponent={HomeActiveIcon}
|
||||
text={intl.formatMessage(messages.home)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{trendsEnabled && (
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/explore'
|
||||
icon='explore'
|
||||
iconComponent={TrendingUpIcon}
|
||||
text={intl.formatMessage(messages.explore)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(signedIn || timelinePreview) && (
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/public/local'
|
||||
icon='globe'
|
||||
iconComponent={PublicIcon}
|
||||
isActive={isFirehoseActive}
|
||||
text={intl.formatMessage(messages.firehose)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{signedIn && (
|
||||
<>
|
||||
<NotificationsLink />
|
||||
|
||||
<FollowRequestsLink />
|
||||
|
||||
<hr />
|
||||
|
||||
<ListPanel />
|
||||
|
||||
<FollowedTagsPanel />
|
||||
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/favourites'
|
||||
icon='star'
|
||||
iconComponent={StarIcon}
|
||||
activeIconComponent={StarActiveIcon}
|
||||
text={intl.formatMessage(messages.favourites)}
|
||||
/>
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/bookmarks'
|
||||
icon='bookmarks'
|
||||
iconComponent={BookmarksIcon}
|
||||
activeIconComponent={BookmarksActiveIcon}
|
||||
text={intl.formatMessage(messages.bookmarks)}
|
||||
/>
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/conversations'
|
||||
icon='at'
|
||||
iconComponent={AlternateEmailIcon}
|
||||
text={intl.formatMessage(messages.direct)}
|
||||
/>
|
||||
|
||||
<hr />
|
||||
|
||||
<ColumnLink
|
||||
transparent
|
||||
href='/settings/preferences'
|
||||
icon='cog'
|
||||
iconComponent={SettingsIcon}
|
||||
text={intl.formatMessage(messages.preferences)}
|
||||
/>
|
||||
<ColumnLink
|
||||
transparent
|
||||
onClick={handleOpenSettings}
|
||||
icon='cogs'
|
||||
iconComponent={AdministrationIcon}
|
||||
text={intl.formatMessage(messages.app_settings)}
|
||||
/>
|
||||
|
||||
<MoreLink />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className='navigation-panel__legal'>
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/about'
|
||||
icon='ellipsis-h'
|
||||
iconComponent={InfoIcon}
|
||||
text={intl.formatMessage(messages.about)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!signedIn && (
|
||||
<div className='navigation-panel__sign-in-banner'>
|
||||
<hr />
|
||||
|
||||
{disabledAccountId ? (
|
||||
<DisabledAccountBanner />
|
||||
) : (
|
||||
<SignInBanner />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex-spacer' />
|
||||
|
||||
<Trends />
|
||||
</div>
|
||||
<NavigationPanel />
|
||||
</animated.div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import SettingsIcon from '@/material-icons/400-20px/settings.svg?react';
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import { requestBrowserPermission } from 'flavours/glitch/actions/notifications';
|
||||
import { changeSetting } from 'flavours/glitch/actions/settings';
|
||||
import { Button } from 'flavours/glitch/components/button';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import { IconButton } from 'flavours/glitch/components/icon_button';
|
||||
|
||||
const messages = defineMessages({
|
||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||
});
|
||||
|
||||
class NotificationsPermissionBanner extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
handleClick = () => {
|
||||
this.props.dispatch(requestBrowserPermission());
|
||||
};
|
||||
|
||||
handleClose = () => {
|
||||
this.props.dispatch(changeSetting(['notifications', 'dismissPermissionBanner'], true));
|
||||
};
|
||||
|
||||
render () {
|
||||
const { intl } = this.props;
|
||||
|
||||
return (
|
||||
<div className='notifications-permission-banner'>
|
||||
<div className='notifications-permission-banner__close'>
|
||||
<IconButton icon='times' iconComponent={CloseIcon} onClick={this.handleClose} title={intl.formatMessage(messages.close)} />
|
||||
</div>
|
||||
|
||||
<h2><FormattedMessage id='notifications_permission_banner.title' defaultMessage='Never miss a thing' /></h2>
|
||||
<p><FormattedMessage id='notifications_permission_banner.how_to_control' defaultMessage="To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled." values={{ icon: <Icon id='sliders' icon={SettingsIcon} /> }} /></p>
|
||||
<Button onClick={this.handleClick}><FormattedMessage id='notifications_permission_banner.enable' defaultMessage='Enable desktop notifications' /></Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect()(injectIntl(NotificationsPermissionBanner));
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { useAppDispatch } from '@/flavours/glitch/store';
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import UnfoldMoreIcon from '@/material-icons/400-24px/unfold_more.svg?react';
|
||||
import { requestBrowserPermission } from 'flavours/glitch/actions/notifications';
|
||||
import { changeSetting } from 'flavours/glitch/actions/settings';
|
||||
import { Button } from 'flavours/glitch/components/button';
|
||||
import { messages as columnHeaderMessages } from 'flavours/glitch/components/column_header';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import { IconButton } from 'flavours/glitch/components/icon_button';
|
||||
|
||||
const messages = defineMessages({
|
||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||
});
|
||||
|
||||
const NotificationsPermissionBanner: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
dispatch(requestBrowserPermission());
|
||||
}, [dispatch]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
dispatch(changeSetting(['notifications', 'dismissPermissionBanner'], true));
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<div className='notifications-permission-banner'>
|
||||
<div className='notifications-permission-banner__close'>
|
||||
<IconButton
|
||||
icon='times'
|
||||
iconComponent={CloseIcon}
|
||||
onClick={handleClose}
|
||||
title={intl.formatMessage(messages.close)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
id='notifications_permission_banner.title'
|
||||
defaultMessage='Never miss a thing'
|
||||
/>
|
||||
</h2>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id='notifications_permission_banner.how_to_control'
|
||||
defaultMessage="To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled."
|
||||
values={{
|
||||
icon: (
|
||||
<Icon
|
||||
id='sliders'
|
||||
icon={UnfoldMoreIcon}
|
||||
aria-label={intl.formatMessage(columnHeaderMessages.show)}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
<Button onClick={handleClick}>
|
||||
<FormattedMessage
|
||||
id='notifications_permission_banner.enable'
|
||||
defaultMessage='Enable desktop notifications'
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default NotificationsPermissionBanner;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user