mirror of
https://github.com/glitch-soc/mastodon.git
synced 2025-12-13 07:49:29 +00:00
Compare commits
318 Commits
v4.4.0-bet
...
v4.4.8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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=${{ startsWith(github.ref, 'refs/tags/v4.4.') }}
|
||||
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=${{ startsWith(github.ref, 'refs/tags/v4.4.') }}
|
||||
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
|
||||
@@ -28,7 +28,7 @@ Metrics/PerceivedComplexity:
|
||||
Max: 27
|
||||
|
||||
# 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,
|
||||
}
|
||||
}
|
||||
167
CHANGELOG.md
167
CHANGELOG.md
@@ -2,7 +2,143 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [4.4.0] - UNRELEASED
|
||||
## [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 +159,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 +174,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 +187,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 +200,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 +253,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 +267,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 +345,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 +369,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)
|
||||
|
||||
5
Gemfile
5
Gemfile
@@ -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
|
||||
|
||||
165
Gemfile.lock
165
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.2.1)
|
||||
actionpack (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.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.2.1)
|
||||
actionpack (= 8.0.2.1)
|
||||
activejob (= 8.0.2.1)
|
||||
activerecord (= 8.0.2.1)
|
||||
activestorage (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.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.2.1)
|
||||
actionpack (= 8.0.2.1)
|
||||
actionview (= 8.0.2.1)
|
||||
activejob (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.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.2.1)
|
||||
actionview (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.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.2.1)
|
||||
actionpack (= 8.0.2.1)
|
||||
activerecord (= 8.0.2.1)
|
||||
activestorage (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
actionview (8.0.2.1)
|
||||
activesupport (= 8.0.2.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.2.1)
|
||||
activesupport (= 8.0.2.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.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
activerecord (8.0.2.1)
|
||||
activemodel (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.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.2.1)
|
||||
actionpack (= 8.0.2.1)
|
||||
activejob (= 8.0.2.1)
|
||||
activerecord (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
marcel (~> 1.0)
|
||||
activesupport (8.0.2)
|
||||
activesupport (8.0.2.1)
|
||||
base64
|
||||
benchmark (>= 0.3)
|
||||
bigdecimal
|
||||
@@ -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,7 +180,7 @@ 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)
|
||||
@@ -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
|
||||
@@ -236,7 +239,7 @@ GEM
|
||||
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.9)
|
||||
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.18)
|
||||
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.2.1)
|
||||
actioncable (= 8.0.2.1)
|
||||
actionmailbox (= 8.0.2.1)
|
||||
actionmailer (= 8.0.2.1)
|
||||
actionpack (= 8.0.2.1)
|
||||
actiontext (= 8.0.2.1)
|
||||
actionview (= 8.0.2.1)
|
||||
activejob (= 8.0.2.1)
|
||||
activemodel (= 8.0.2.1)
|
||||
activerecord (= 8.0.2.1)
|
||||
activestorage (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 8.0.2)
|
||||
railties (= 8.0.2.1)
|
||||
rails-dom-testing (2.2.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
@@ -689,9 +692,9 @@ 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.2.1)
|
||||
actionpack (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
@@ -705,7 +708,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 +725,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 +737,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 +760,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 +769,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 +801,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 +855,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,7 +869,7 @@ 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)
|
||||
@@ -895,7 +899,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 +932,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 +940,7 @@ GEM
|
||||
xorcist (1.1.3)
|
||||
xpath (3.2.0)
|
||||
nokogiri (~> 1.8)
|
||||
zeitwerk (2.7.2)
|
||||
zeitwerk (2.7.3)
|
||||
|
||||
PLATFORMS
|
||||
ruby
|
||||
@@ -1027,7 +1031,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 +1096,7 @@ DEPENDENCIES
|
||||
webauthn (~> 3.0)
|
||||
webmock (~> 3.18)
|
||||
webpush!
|
||||
websocket-driver (~> 0.8)
|
||||
xorcist (~> 1.1)
|
||||
|
||||
RUBY VERSION
|
||||
|
||||
11
SECURITY.md
11
SECURITY.md
@@ -13,8 +13,9 @@ 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 | Yes |
|
||||
| 4.2.x | Until 2026-01-08 |
|
||||
| < 4.2 | No |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -64,6 +64,9 @@ 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
|
||||
@@ -82,7 +85,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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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,7 @@ 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));
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -78,22 +89,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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -122,98 +122,93 @@ export const PolicyControls: React.FC = () => {
|
||||
value={notificationPolicy.for_not_following}
|
||||
onChange={handleFilterNotFollowing}
|
||||
options={options}
|
||||
>
|
||||
<strong>
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='notifications.policy.filter_not_following_title'
|
||||
defaultMessage="People you don't follow"
|
||||
/>
|
||||
</strong>
|
||||
<span className='hint'>
|
||||
}
|
||||
hint={
|
||||
<FormattedMessage
|
||||
id='notifications.policy.filter_not_following_hint'
|
||||
defaultMessage='Until you manually approve them'
|
||||
/>
|
||||
</span>
|
||||
</SelectWithLabel>
|
||||
}
|
||||
/>
|
||||
|
||||
<SelectWithLabel
|
||||
value={notificationPolicy.for_not_followers}
|
||||
onChange={handleFilterNotFollowers}
|
||||
options={options}
|
||||
>
|
||||
<strong>
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='notifications.policy.filter_not_followers_title'
|
||||
defaultMessage='People not following you'
|
||||
/>
|
||||
</strong>
|
||||
<span className='hint'>
|
||||
}
|
||||
hint={
|
||||
<FormattedMessage
|
||||
id='notifications.policy.filter_not_followers_hint'
|
||||
defaultMessage='Including people who have been following you fewer than {days, plural, one {one day} other {# days}}'
|
||||
values={{ days: 3 }}
|
||||
/>
|
||||
</span>
|
||||
</SelectWithLabel>
|
||||
}
|
||||
/>
|
||||
|
||||
<SelectWithLabel
|
||||
value={notificationPolicy.for_new_accounts}
|
||||
onChange={handleFilterNewAccounts}
|
||||
options={options}
|
||||
>
|
||||
<strong>
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='notifications.policy.filter_new_accounts_title'
|
||||
defaultMessage='New accounts'
|
||||
/>
|
||||
</strong>
|
||||
<span className='hint'>
|
||||
}
|
||||
hint={
|
||||
<FormattedMessage
|
||||
id='notifications.policy.filter_new_accounts.hint'
|
||||
defaultMessage='Created within the past {days, plural, one {one day} other {# days}}'
|
||||
values={{ days: 30 }}
|
||||
/>
|
||||
</span>
|
||||
</SelectWithLabel>
|
||||
}
|
||||
/>
|
||||
|
||||
<SelectWithLabel
|
||||
value={notificationPolicy.for_private_mentions}
|
||||
onChange={handleFilterPrivateMentions}
|
||||
options={options}
|
||||
>
|
||||
<strong>
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='notifications.policy.filter_private_mentions_title'
|
||||
defaultMessage='Unsolicited private mentions'
|
||||
/>
|
||||
</strong>
|
||||
<span className='hint'>
|
||||
}
|
||||
hint={
|
||||
<FormattedMessage
|
||||
id='notifications.policy.filter_private_mentions_hint'
|
||||
defaultMessage="Filtered unless it's in reply to your own mention or if you follow the sender"
|
||||
/>
|
||||
</span>
|
||||
</SelectWithLabel>
|
||||
}
|
||||
/>
|
||||
|
||||
<SelectWithLabel
|
||||
value={notificationPolicy.for_limited_accounts}
|
||||
onChange={handleFilterLimitedAccounts}
|
||||
options={options}
|
||||
>
|
||||
<strong>
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='notifications.policy.filter_limited_accounts_title'
|
||||
defaultMessage='Moderated accounts'
|
||||
/>
|
||||
</strong>
|
||||
<span className='hint'>
|
||||
}
|
||||
hint={
|
||||
<FormattedMessage
|
||||
id='notifications.policy.filter_limited_accounts_hint'
|
||||
defaultMessage='Limited by server moderators'
|
||||
/>
|
||||
</span>
|
||||
</SelectWithLabel>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { useCallback, useState, useRef } from 'react';
|
||||
import { useCallback, useState, useRef, useId } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
@@ -16,6 +16,8 @@ interface DropdownProps {
|
||||
options: SelectItem[];
|
||||
disabled?: boolean;
|
||||
onChange: (value: string) => void;
|
||||
'aria-labelledby': string;
|
||||
'aria-describedby'?: string;
|
||||
placement?: Placement;
|
||||
}
|
||||
|
||||
@@ -24,51 +26,33 @@ const Dropdown: React.FC<DropdownProps> = ({
|
||||
options,
|
||||
disabled,
|
||||
onChange,
|
||||
'aria-labelledby': ariaLabelledBy,
|
||||
'aria-describedby': ariaDescribedBy,
|
||||
placement: initialPlacement = 'bottom-end',
|
||||
}) => {
|
||||
const activeElementRef = useRef<Element | null>(null);
|
||||
const containerRef = useRef(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const [isOpen, setOpen] = useState<boolean>(false);
|
||||
const [placement, setPlacement] = useState<Placement>(initialPlacement);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
if (
|
||||
isOpen &&
|
||||
activeElementRef.current &&
|
||||
activeElementRef.current instanceof HTMLElement
|
||||
) {
|
||||
activeElementRef.current.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
setOpen(!isOpen);
|
||||
}, [isOpen, setOpen]);
|
||||
|
||||
const handleMouseDown = useCallback(() => {
|
||||
if (!isOpen) activeElementRef.current = document.activeElement;
|
||||
}, [isOpen]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
if (!isOpen) activeElementRef.current = document.activeElement;
|
||||
break;
|
||||
}
|
||||
},
|
||||
[isOpen],
|
||||
);
|
||||
const uniqueId = useId();
|
||||
const menuId = `${uniqueId}-menu`;
|
||||
const buttonLabelId = `${uniqueId}-button`;
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (
|
||||
isOpen &&
|
||||
activeElementRef.current &&
|
||||
activeElementRef.current instanceof HTMLElement
|
||||
)
|
||||
activeElementRef.current.focus({ preventScroll: true });
|
||||
if (isOpen && buttonRef.current) {
|
||||
buttonRef.current.focus({ preventScroll: true });
|
||||
}
|
||||
setOpen(false);
|
||||
}, [isOpen]);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
if (isOpen) {
|
||||
handleClose();
|
||||
} else {
|
||||
setOpen(true);
|
||||
}
|
||||
}, [isOpen, handleClose]);
|
||||
|
||||
const handleOverlayEnter = useCallback(
|
||||
(state: Partial<PopperState>) => {
|
||||
if (state.placement) setPlacement(state.placement);
|
||||
@@ -82,13 +66,18 @@ const Dropdown: React.FC<DropdownProps> = ({
|
||||
<div ref={containerRef}>
|
||||
<button
|
||||
type='button'
|
||||
ref={buttonRef}
|
||||
onClick={handleToggle}
|
||||
onMouseDown={handleMouseDown}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={disabled}
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={menuId}
|
||||
aria-labelledby={`${ariaLabelledBy} ${buttonLabelId}`}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
className={classNames('dropdown-button', { active: isOpen })}
|
||||
>
|
||||
<span className='dropdown-button__label'>{valueOption?.text}</span>
|
||||
<span id={buttonLabelId} className='dropdown-button__label'>
|
||||
{valueOption?.text}
|
||||
</span>
|
||||
<Icon id='down' icon={ArrowDropDownIcon} />
|
||||
</button>
|
||||
|
||||
@@ -101,7 +90,7 @@ const Dropdown: React.FC<DropdownProps> = ({
|
||||
popperConfig={{ strategy: 'fixed', onFirstUpdate: handleOverlayEnter }}
|
||||
>
|
||||
{({ props, placement }) => (
|
||||
<div {...props}>
|
||||
<div {...props} id={menuId}>
|
||||
<div
|
||||
className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}
|
||||
>
|
||||
@@ -123,6 +112,8 @@ const Dropdown: React.FC<DropdownProps> = ({
|
||||
interface Props {
|
||||
value: string;
|
||||
options: SelectItem[];
|
||||
label: string | React.ReactElement;
|
||||
hint: string | React.ReactElement;
|
||||
disabled?: boolean;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
@@ -130,13 +121,26 @@ interface Props {
|
||||
export const SelectWithLabel: React.FC<PropsWithChildren<Props>> = ({
|
||||
value,
|
||||
options,
|
||||
label,
|
||||
hint,
|
||||
disabled,
|
||||
children,
|
||||
onChange,
|
||||
}) => {
|
||||
const uniqueId = useId();
|
||||
const labelId = `${uniqueId}-label`;
|
||||
const descId = `${uniqueId}-desc`;
|
||||
|
||||
return (
|
||||
// This label is only used for its click-forwarding behaviour,
|
||||
// accessible names are assigned manually
|
||||
// eslint-disable-next-line jsx-a11y/label-has-associated-control
|
||||
<label className='app-form__toggle'>
|
||||
<div className='app-form__toggle__label'>{children}</div>
|
||||
<div className='app-form__toggle__label'>
|
||||
<strong id={labelId}>{label}</strong>
|
||||
<span className='hint' id={descId}>
|
||||
{hint}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='app-form__toggle__toggle'>
|
||||
<div>
|
||||
@@ -144,6 +148,8 @@ export const SelectWithLabel: React.FC<PropsWithChildren<Props>> = ({
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
aria-labelledby={labelId}
|
||||
aria-describedby={descId}
|
||||
options={options}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
@@ -24,6 +24,10 @@ import { openModal } from 'flavours/glitch/actions/modal';
|
||||
import { IconButton } from 'flavours/glitch/components/icon_button';
|
||||
import { useIdentity } from 'flavours/glitch/identity_context';
|
||||
import { me } from 'flavours/glitch/initial_state';
|
||||
import type { Account } from 'flavours/glitch/models/account';
|
||||
import type { Status } from 'flavours/glitch/models/status';
|
||||
import { makeGetStatus } from 'flavours/glitch/selectors';
|
||||
import type { RootState } from 'flavours/glitch/store';
|
||||
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
@@ -50,6 +54,11 @@ const messages = defineMessages({
|
||||
open: { id: 'status.open', defaultMessage: 'Expand this status' },
|
||||
});
|
||||
|
||||
type GetStatusSelector = (
|
||||
state: RootState,
|
||||
props: { id?: string | null; contextType?: string },
|
||||
) => Status | null;
|
||||
|
||||
export const Footer: React.FC<{
|
||||
statusId: string;
|
||||
withOpenButton?: boolean;
|
||||
@@ -59,11 +68,9 @@ export const Footer: React.FC<{
|
||||
const intl = useIntl();
|
||||
const history = useHistory();
|
||||
const dispatch = useAppDispatch();
|
||||
const status = useAppSelector((state) => state.statuses.get(statusId));
|
||||
const accountId = status?.get('account') as string | undefined;
|
||||
const account = useAppSelector((state) =>
|
||||
accountId ? state.accounts.get(accountId) : undefined,
|
||||
);
|
||||
const getStatus = useMemo(() => makeGetStatus(), []) as GetStatusSelector;
|
||||
const status = useAppSelector((state) => getStatus(state, { id: statusId }));
|
||||
const account = status?.get('account') as Account | undefined;
|
||||
const askReplyConfirmation = useAppSelector(
|
||||
(state) => (state.compose.get('text') as string).trim().length !== 0,
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import ModalContainer from 'flavours/glitch/features/ui/containers/modal_contain
|
||||
|
||||
const Compose = () => (
|
||||
<>
|
||||
<ComposeFormContainer autoFocus withoutNavigation />
|
||||
<ComposeFormContainer autoFocus withoutNavigation redirectOnSuccess />
|
||||
<AlertsController />
|
||||
<ModalContainer />
|
||||
<LoadingBarContainer className='loading-bar' />
|
||||
|
||||
@@ -38,7 +38,7 @@ const Embed: React.FC<{ id: string }> = ({ id }) => {
|
||||
const dispatchRenderSignal = useRenderSignal();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchStatus(id, false, false));
|
||||
dispatch(fetchStatus(id, { alsoFetchContext: false }));
|
||||
}, [dispatch, id]);
|
||||
|
||||
const handleToggleHidden = useCallback(() => {
|
||||
|
||||
@@ -418,7 +418,10 @@ export const DetailedStatus: React.FC<{
|
||||
{hashtagBar}
|
||||
|
||||
{status.get('quote') && (
|
||||
<QuotedStatus quote={status.get('quote')} />
|
||||
<QuotedStatus
|
||||
quote={status.get('quote')}
|
||||
parentQuotePostId={status.get('id')}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useLayoutEffect } from 'react';
|
||||
|
||||
import { createAppSelector, useAppSelector } from 'flavours/glitch/store';
|
||||
|
||||
const getShouldLockBodyScroll = createAppSelector(
|
||||
[
|
||||
(state) => state.navigation.open,
|
||||
(state) => state.modal.get('stack').size > 0,
|
||||
],
|
||||
(isMobileMenuOpen: boolean, isModalOpen: boolean) =>
|
||||
isMobileMenuOpen || isModalOpen,
|
||||
);
|
||||
|
||||
/**
|
||||
* This component locks scrolling on the body when
|
||||
* `getShouldLockBodyScroll` returns true.
|
||||
*/
|
||||
|
||||
export const BodyScrollLock: React.FC = () => {
|
||||
const shouldLockBodyScroll = useAppSelector(getShouldLockBodyScroll);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
document.documentElement.classList.toggle(
|
||||
'has-modal',
|
||||
shouldLockBodyScroll,
|
||||
);
|
||||
}, [shouldLockBodyScroll]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -19,7 +19,6 @@ export const ColumnLink: React.FC<{
|
||||
method?: string;
|
||||
badge?: React.ReactNode;
|
||||
transparent?: boolean;
|
||||
optional?: boolean;
|
||||
className?: string;
|
||||
id?: string;
|
||||
}> = ({
|
||||
@@ -34,13 +33,11 @@ export const ColumnLink: React.FC<{
|
||||
method,
|
||||
badge,
|
||||
transparent,
|
||||
optional,
|
||||
...other
|
||||
}) => {
|
||||
const match = useRouteMatch(to ?? '');
|
||||
const className = classNames('column-link', {
|
||||
'column-link--transparent': transparent,
|
||||
'column-link--optional': optional,
|
||||
});
|
||||
const badgeElement =
|
||||
typeof badge !== 'undefined' ? (
|
||||
|
||||
@@ -23,9 +23,9 @@ import { useColumnsContext } from '../util/columns_context';
|
||||
|
||||
import BundleColumnError from './bundle_column_error';
|
||||
import { ColumnLoading } from './column_loading';
|
||||
import { ComposePanel } from './compose_panel';
|
||||
import { ComposePanel, RedirectToMobileComposeIfNeeded } from './compose_panel';
|
||||
import DrawerLoading from './drawer_loading';
|
||||
import { NavigationPanel } from 'flavours/glitch/features/navigation_panel';
|
||||
import { CollapsibleNavigationPanel } from 'flavours/glitch/features/navigation_panel';
|
||||
|
||||
const componentMap = {
|
||||
'COMPOSE': Compose,
|
||||
@@ -124,6 +124,7 @@ export default class ColumnsArea extends ImmutablePureComponent {
|
||||
<div className='columns-area__panels__pane columns-area__panels__pane--compositional'>
|
||||
<div className='columns-area__panels__pane__inner'>
|
||||
{renderComposePanel && <ComposePanel />}
|
||||
<RedirectToMobileComposeIfNeeded />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -132,7 +133,7 @@ export default class ColumnsArea extends ImmutablePureComponent {
|
||||
<div className='columns-area columns-area--mobile'>{children}</div>
|
||||
</div>
|
||||
|
||||
<NavigationPanel />
|
||||
<CollapsibleNavigationPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useCallback, useEffect, useLayoutEffect } from 'react';
|
||||
|
||||
import { useLayout } from '@/flavours/glitch/hooks/useLayout';
|
||||
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
mountCompose,
|
||||
unmountCompose,
|
||||
} from 'flavours/glitch/actions/compose';
|
||||
import { useAppHistory } from 'flavours/glitch/components/router';
|
||||
import ServerBanner from 'flavours/glitch/components/server_banner';
|
||||
import { Search } from 'flavours/glitch/features/compose/components/search';
|
||||
import ComposeFormContainer from 'flavours/glitch/features/compose/containers/compose_form_container';
|
||||
@@ -54,3 +55,25 @@ export const ComposePanel: React.FC = () => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Redirect the user to the standalone compose page when the
|
||||
* sidebar composer is hidden due to a change in viewport size
|
||||
* while a post is being written.
|
||||
*/
|
||||
|
||||
export const RedirectToMobileComposeIfNeeded: React.FC = () => {
|
||||
const history = useAppHistory();
|
||||
|
||||
const shouldRedirect = useAppSelector((state) =>
|
||||
state.compose.get('should_redirect_to_compose_page'),
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (shouldRedirect) {
|
||||
history.push('/publish');
|
||||
}
|
||||
}, [history, shouldRedirect]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ export const ConfirmationModal: React.FC<
|
||||
title: React.ReactNode;
|
||||
message: React.ReactNode;
|
||||
confirm: React.ReactNode;
|
||||
cancel?: React.ReactNode;
|
||||
secondary?: React.ReactNode;
|
||||
onSecondary?: () => void;
|
||||
onConfirm: () => void;
|
||||
@@ -30,6 +31,7 @@ export const ConfirmationModal: React.FC<
|
||||
title,
|
||||
message,
|
||||
confirm,
|
||||
cancel,
|
||||
onClose,
|
||||
onConfirm,
|
||||
secondary,
|
||||
@@ -65,10 +67,12 @@ export const ConfirmationModal: React.FC<
|
||||
<div className='safety-action-modal__bottom'>
|
||||
<div className='safety-action-modal__actions'>
|
||||
<button onClick={handleCancel} className='link-button'>
|
||||
<FormattedMessage
|
||||
id='confirmation_modal.cancel'
|
||||
defaultMessage='Cancel'
|
||||
/>
|
||||
{cancel ?? (
|
||||
<FormattedMessage
|
||||
id='confirmation_modal.cancel'
|
||||
defaultMessage='Cancel'
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{secondary && (
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { replyCompose } from 'flavours/glitch/actions/compose';
|
||||
import { editStatus } from 'flavours/glitch/actions/statuses';
|
||||
import type { Status } from 'flavours/glitch/models/status';
|
||||
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
||||
|
||||
import type { BaseConfirmationModalProps } from './confirmation_modal';
|
||||
import { ConfirmationModal } from './confirmation_modal';
|
||||
|
||||
const editMessages = defineMessages({
|
||||
title: {
|
||||
id: 'confirmations.discard_draft.edit.title',
|
||||
defaultMessage: 'Discard changes to your post?',
|
||||
},
|
||||
message: {
|
||||
id: 'confirmations.discard_draft.edit.message',
|
||||
defaultMessage:
|
||||
'Continuing will discard any changes you have made to the post you are currently editing.',
|
||||
},
|
||||
cancel: {
|
||||
id: 'confirmations.discard_draft.edit.cancel',
|
||||
defaultMessage: 'Resume editing',
|
||||
},
|
||||
});
|
||||
|
||||
const postMessages = defineMessages({
|
||||
title: {
|
||||
id: 'confirmations.discard_draft.post.title',
|
||||
defaultMessage: 'Discard your draft post?',
|
||||
},
|
||||
message: {
|
||||
id: 'confirmations.discard_draft.post.message',
|
||||
defaultMessage:
|
||||
'Continuing will discard the post you are currently composing.',
|
||||
},
|
||||
cancel: {
|
||||
id: 'confirmations.discard_draft.post.cancel',
|
||||
defaultMessage: 'Resume draft',
|
||||
},
|
||||
});
|
||||
|
||||
const messages = defineMessages({
|
||||
confirm: {
|
||||
id: 'confirmations.discard_draft.confirm',
|
||||
defaultMessage: 'Discard and continue',
|
||||
},
|
||||
});
|
||||
|
||||
const DiscardDraftConfirmationModal: React.FC<
|
||||
{
|
||||
onConfirm: () => void;
|
||||
} & BaseConfirmationModalProps
|
||||
> = ({ onConfirm, onClose }) => {
|
||||
const intl = useIntl();
|
||||
const isEditing = useAppSelector((state) => !!state.compose.get('id'));
|
||||
|
||||
const contextualMessages = isEditing ? editMessages : postMessages;
|
||||
|
||||
return (
|
||||
<ConfirmationModal
|
||||
title={intl.formatMessage(contextualMessages.title)}
|
||||
message={intl.formatMessage(contextualMessages.message)}
|
||||
cancel={intl.formatMessage(contextualMessages.cancel)}
|
||||
confirm={intl.formatMessage(messages.confirm)}
|
||||
onConfirm={onConfirm}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const ConfirmReplyModal: React.FC<
|
||||
{
|
||||
status: Status;
|
||||
} & BaseConfirmationModalProps
|
||||
> = ({ status, onClose }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const onConfirm = useCallback(() => {
|
||||
dispatch(replyCompose(status));
|
||||
}, [dispatch, status]);
|
||||
|
||||
return (
|
||||
<DiscardDraftConfirmationModal onConfirm={onConfirm} onClose={onClose} />
|
||||
);
|
||||
};
|
||||
|
||||
export const ConfirmEditStatusModal: React.FC<
|
||||
{
|
||||
statusId: string;
|
||||
} & BaseConfirmationModalProps
|
||||
> = ({ statusId, onClose }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const onConfirm = useCallback(() => {
|
||||
dispatch(editStatus(statusId));
|
||||
}, [dispatch, statusId]);
|
||||
|
||||
return (
|
||||
<DiscardDraftConfirmationModal onConfirm={onConfirm} onClose={onClose} />
|
||||
);
|
||||
};
|
||||
@@ -1,45 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { editStatus } from 'flavours/glitch/actions/statuses';
|
||||
import { useAppDispatch } from 'flavours/glitch/store';
|
||||
|
||||
import type { BaseConfirmationModalProps } from './confirmation_modal';
|
||||
import { ConfirmationModal } from './confirmation_modal';
|
||||
|
||||
const messages = defineMessages({
|
||||
editTitle: {
|
||||
id: 'confirmations.edit.title',
|
||||
defaultMessage: 'Overwrite post?',
|
||||
},
|
||||
editConfirm: { id: 'confirmations.edit.confirm', defaultMessage: 'Edit' },
|
||||
editMessage: {
|
||||
id: 'confirmations.edit.message',
|
||||
defaultMessage:
|
||||
'Editing now will overwrite the message you are currently composing. Are you sure you want to proceed?',
|
||||
},
|
||||
});
|
||||
|
||||
export const ConfirmEditStatusModal: React.FC<
|
||||
{
|
||||
statusId: string;
|
||||
} & BaseConfirmationModalProps
|
||||
> = ({ statusId, onClose }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const onConfirm = useCallback(() => {
|
||||
dispatch(editStatus(statusId));
|
||||
}, [dispatch, statusId]);
|
||||
|
||||
return (
|
||||
<ConfirmationModal
|
||||
title={intl.formatMessage(messages.editTitle)}
|
||||
message={intl.formatMessage(messages.editMessage)}
|
||||
confirm={intl.formatMessage(messages.editConfirm)}
|
||||
onConfirm={onConfirm}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,10 @@
|
||||
export { ConfirmationModal } from './confirmation_modal';
|
||||
export { ConfirmDeleteStatusModal } from './delete_status';
|
||||
export { ConfirmDeleteListModal } from './delete_list';
|
||||
export { ConfirmReplyModal } from './reply';
|
||||
export { ConfirmEditStatusModal } from './edit_status';
|
||||
export {
|
||||
ConfirmReplyModal,
|
||||
ConfirmEditStatusModal,
|
||||
} from './discard_draft_confirmation';
|
||||
export { ConfirmUnfollowModal } from './unfollow';
|
||||
export { ConfirmClearNotificationsModal } from './clear_notifications';
|
||||
export { ConfirmLogOutModal } from './log_out';
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { replyCompose } from 'flavours/glitch/actions/compose';
|
||||
import type { Status } from 'flavours/glitch/models/status';
|
||||
import { useAppDispatch } from 'flavours/glitch/store';
|
||||
|
||||
import type { BaseConfirmationModalProps } from './confirmation_modal';
|
||||
import { ConfirmationModal } from './confirmation_modal';
|
||||
|
||||
const messages = defineMessages({
|
||||
replyTitle: {
|
||||
id: 'confirmations.reply.title',
|
||||
defaultMessage: 'Overwrite post?',
|
||||
},
|
||||
replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
|
||||
replyMessage: {
|
||||
id: 'confirmations.reply.message',
|
||||
defaultMessage:
|
||||
'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?',
|
||||
},
|
||||
});
|
||||
|
||||
export const ConfirmReplyModal: React.FC<
|
||||
{
|
||||
status: Status;
|
||||
} & BaseConfirmationModalProps
|
||||
> = ({ status, onClose }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const onConfirm = useCallback(() => {
|
||||
dispatch(replyCompose(status));
|
||||
}, [dispatch, status]);
|
||||
|
||||
return (
|
||||
<ConfirmationModal
|
||||
title={intl.formatMessage(messages.replyTitle)}
|
||||
message={intl.formatMessage(messages.replyMessage)}
|
||||
confirm={intl.formatMessage(messages.replyConfirm)}
|
||||
onConfirm={onConfirm}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -38,7 +38,7 @@ class FilterModal extends ImmutablePureComponent {
|
||||
|
||||
handleSuccess = () => {
|
||||
const { dispatch, statusId } = this.props;
|
||||
dispatch(fetchStatus(statusId, true));
|
||||
dispatch(fetchStatus(statusId, {forceFetch: true}));
|
||||
this.setState({ isSubmitting: false, isSubmitted: true, step: 'submitted' });
|
||||
};
|
||||
|
||||
|
||||
@@ -201,8 +201,6 @@ class MediaModal extends ImmutablePureComponent {
|
||||
preview={image.get('preview_url')}
|
||||
blurhash={image.get('blurhash')}
|
||||
src={image.get('url')}
|
||||
width={image.get('width')}
|
||||
height={image.get('height')}
|
||||
frameRate={image.getIn(['meta', 'original', 'frame_rate'])}
|
||||
startTime={currentTime || 0}
|
||||
startPlaying={autoPlay || false}
|
||||
@@ -218,8 +216,6 @@ class MediaModal extends ImmutablePureComponent {
|
||||
return (
|
||||
<GIFV
|
||||
src={image.get('url')}
|
||||
width={width}
|
||||
height={height}
|
||||
key={image.get('url')}
|
||||
alt={description}
|
||||
lang={lang}
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
IgnoreNotificationsModal,
|
||||
AnnualReportModal,
|
||||
} from 'flavours/glitch/features/ui/util/async-components';
|
||||
import { getScrollbarWidth } from 'flavours/glitch/utils/scrollbar';
|
||||
|
||||
import BundleContainer from '../containers/bundle_container';
|
||||
|
||||
@@ -98,16 +97,6 @@ export default class ModalRoot extends PureComponent {
|
||||
backgroundColor: null,
|
||||
};
|
||||
|
||||
componentDidUpdate () {
|
||||
if (this.props.type) {
|
||||
document.body.classList.add('with-modals--active');
|
||||
document.documentElement.style.marginRight = `${getScrollbarWidth()}px`;
|
||||
} else {
|
||||
document.body.classList.remove('with-modals--active');
|
||||
document.documentElement.style.marginRight = '0';
|
||||
}
|
||||
}
|
||||
|
||||
setBackgroundColor = color => {
|
||||
this.setState({ backgroundColor: color });
|
||||
};
|
||||
|
||||
@@ -22,7 +22,7 @@ import { registrationsOpen, sso_redirect } from 'flavours/glitch/initial_state';
|
||||
import { selectUnreadNotificationGroupsCount } from 'flavours/glitch/selectors/notifications';
|
||||
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
export const messages = defineMessages({
|
||||
home: { id: 'tabs_bar.home', defaultMessage: 'Home' },
|
||||
search: { id: 'tabs_bar.search', defaultMessage: 'Search' },
|
||||
publish: { id: 'tabs_bar.publish', defaultMessage: 'New Post' },
|
||||
|
||||
@@ -51,7 +51,7 @@ const makeGetStatusIds = (pending = false) => createSelector([
|
||||
}
|
||||
|
||||
const searchIndex = statusForId.get('reblog') ? statuses.getIn([statusForId.get('reblog'), 'search_index']) : statusForId.get('search_index');
|
||||
if (regex && !regex.test(searchIndex)) {
|
||||
if (regex && regex.test(searchIndex)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -69,7 +69,6 @@ import {
|
||||
DomainBlocks,
|
||||
Mutes,
|
||||
PinnedStatuses,
|
||||
GettingStartedMisc,
|
||||
Directory,
|
||||
OnboardingProfile,
|
||||
OnboardingFollows,
|
||||
@@ -93,8 +92,7 @@ const messages = defineMessages({
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
layout: state.getIn(['meta', 'layout']),
|
||||
hasComposingText: state.getIn(['compose', 'text']).trim().length !== 0,
|
||||
hasMediaAttachments: state.getIn(['compose', 'media_attachments']).size > 0,
|
||||
hasComposingContents: state.getIn(['compose', 'text']).trim().length !== 0 || state.getIn(['compose', 'media_attachments']).size > 0 || state.getIn(['compose', 'poll']) !== null,
|
||||
canUploadMore: !state.getIn(['compose', 'media_attachments']).some(x => ['audio', 'video'].includes(x.get('type'))) && state.getIn(['compose', 'media_attachments']).size < 4,
|
||||
isWide: state.getIn(['local_settings', 'stretch']),
|
||||
fullWidthColumns: state.getIn(['local_settings', 'fullwidth_columns']),
|
||||
@@ -152,13 +150,8 @@ class SwitchingColumnsArea extends PureComponent {
|
||||
};
|
||||
|
||||
UNSAFE_componentWillMount () {
|
||||
if (this.props.singleColumn) {
|
||||
document.body.classList.toggle('layout-single-column', true);
|
||||
document.body.classList.toggle('layout-multiple-columns', false);
|
||||
} else {
|
||||
document.body.classList.toggle('layout-single-column', false);
|
||||
document.body.classList.toggle('layout-multiple-columns', true);
|
||||
}
|
||||
document.body.classList.toggle('layout-single-column', this.props.singleColumn);
|
||||
document.body.classList.toggle('layout-multiple-columns', !this.props.singleColumn);
|
||||
}
|
||||
|
||||
componentDidUpdate (prevProps) {
|
||||
@@ -210,8 +203,8 @@ class SwitchingColumnsArea extends PureComponent {
|
||||
{singleColumn ? <Redirect from='/deck' to='/home' exact /> : null}
|
||||
{singleColumn && pathName.startsWith('/deck/') ? <Redirect from={pathName} to={{...this.props.location, pathname: pathName.slice(5)}} /> : null}
|
||||
{/* Redirect old bookmarks (without /deck) with home-like routes to the advanced interface */}
|
||||
{!singleColumn && pathName === '/getting-started' ? <Redirect from='/getting-started' to='/deck/getting-started' exact /> : null}
|
||||
{!singleColumn && pathName === '/home' ? <Redirect from='/home' to='/deck/getting-started' exact /> : null}
|
||||
{pathName === '/getting-started' ? <Redirect from='/getting-started' to={singleColumn ? '/home' : '/deck/getting-started'} exact /> : null}
|
||||
|
||||
<WrappedRoute path='/getting-started' component={GettingStarted} content={children} />
|
||||
<WrappedRoute path='/keyboard-shortcuts' component={KeyboardShortcuts} content={children} />
|
||||
@@ -271,7 +264,6 @@ class SwitchingColumnsArea extends PureComponent {
|
||||
<WrappedRoute path='/followed_tags' component={FollowedTags} content={children} />
|
||||
<WrappedRoute path='/mutes' component={Mutes} content={children} />
|
||||
<WrappedRoute path='/lists' component={Lists} content={children} />
|
||||
<WrappedRoute path='/getting-started-misc' component={GettingStartedMisc} content={children} />
|
||||
|
||||
<Route component={BundleColumnError} />
|
||||
</WrappedSwitch>
|
||||
@@ -291,8 +283,7 @@ class UI extends PureComponent {
|
||||
fullWidthColumns: PropTypes.bool,
|
||||
systemFontUi: PropTypes.bool,
|
||||
isComposing: PropTypes.bool,
|
||||
hasComposingText: PropTypes.bool,
|
||||
hasMediaAttachments: PropTypes.bool,
|
||||
hasComposingContents: PropTypes.bool,
|
||||
canUploadMore: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
unreadNotifications: PropTypes.number,
|
||||
@@ -311,11 +302,11 @@ class UI extends PureComponent {
|
||||
};
|
||||
|
||||
handleBeforeUnload = e => {
|
||||
const { intl, dispatch, hasComposingText, hasMediaAttachments } = this.props;
|
||||
const { intl, dispatch, hasComposingContents } = this.props;
|
||||
|
||||
dispatch(synchronouslySubmitMarkers());
|
||||
|
||||
if (hasComposingText || hasMediaAttachments) {
|
||||
if (hasComposingContents) {
|
||||
// Setting returnValue to any string causes confirmation dialog.
|
||||
// Many browsers no longer display this text to users,
|
||||
// but we set user-friendly message for other browsers, e.g. Edge.
|
||||
|
||||
@@ -50,10 +50,6 @@ export function GettingStarted () {
|
||||
return import('../../getting_started');
|
||||
}
|
||||
|
||||
export function GettingStartedMisc () {
|
||||
return import('../../getting_started_misc');
|
||||
}
|
||||
|
||||
export function KeyboardShortcuts () {
|
||||
return import('../../keyboard_shortcuts');
|
||||
}
|
||||
|
||||
@@ -8,13 +8,14 @@ import { openURL } from 'flavours/glitch/actions/search';
|
||||
import { useAppDispatch } from 'flavours/glitch/store';
|
||||
|
||||
const isMentionClick = (element: HTMLAnchorElement) =>
|
||||
element.classList.contains('mention');
|
||||
element.classList.contains('mention') &&
|
||||
!element.classList.contains('hashtag');
|
||||
|
||||
const isHashtagClick = (element: HTMLAnchorElement) =>
|
||||
element.textContent?.[0] === '#' ||
|
||||
element.previousSibling?.textContent?.endsWith('#');
|
||||
|
||||
export const useLinks = () => {
|
||||
export const useLinks = (skipHashtags?: boolean) => {
|
||||
const history = useHistory();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
@@ -61,12 +62,12 @@ export const useLinks = () => {
|
||||
if (isMentionClick(target)) {
|
||||
e.preventDefault();
|
||||
void handleMentionClick(target);
|
||||
} else if (isHashtagClick(target)) {
|
||||
} else if (isHashtagClick(target) && !skipHashtags) {
|
||||
e.preventDefault();
|
||||
handleHashtagClick(target);
|
||||
}
|
||||
},
|
||||
[handleMentionClick, handleHashtagClick],
|
||||
[skipHashtags, handleMentionClick, handleHashtagClick],
|
||||
);
|
||||
|
||||
return handleClick;
|
||||
|
||||
@@ -6,12 +6,8 @@
|
||||
"account.view_full_profile": "عرض الملف الشخصي كاملاً",
|
||||
"boost_modal.missing_description": "هذا المنشور يحتوي على وسائط بلا وصف",
|
||||
"column.favourited_by": "المفضلة من قبل",
|
||||
"column.heading": "متنوعة",
|
||||
"column.reblogged_by": "المرقى من قبل",
|
||||
"column.subheading": "خيارات متنوعة",
|
||||
"column_header.profile": "الملف الشخصي",
|
||||
"column_subheading.lists": "القوائم",
|
||||
"column_subheading.navigation": "التنقل",
|
||||
"community.column_settings.allow_local_only": "إظهار المنشورات المحلية فقط",
|
||||
"compose.attach.doodle": "الرسوم و التخمين",
|
||||
"compose.change_federation": "تغيير اعدادات الفيديرالية",
|
||||
@@ -36,8 +32,6 @@
|
||||
"keyboard_shortcuts.secondary_toot": "لإرسال التبويق باستخدام إعدادات الخصوصية الثانوية",
|
||||
"moved_to_warning": "عُلِّم هذا الحساب بأنه انتقل إلى {moved_to_link}، لذا قد لا يقبل متابعات جديدة.",
|
||||
"navigation_bar.app_settings": "إعدادات التطبيق",
|
||||
"navigation_bar.keyboard_shortcuts": "اختصارات لوحة المفاتيح",
|
||||
"navigation_bar.misc": "متنوع",
|
||||
"settings.always_show_spoilers_field": "تمكين دائما حقل تحذير المحتوى",
|
||||
"settings.close": "إغلاق",
|
||||
"settings.content_warnings": "Content warnings",
|
||||
|
||||
@@ -6,12 +6,8 @@
|
||||
"account.view_full_profile": "Zobrazit celý profil",
|
||||
"boost_modal.missing_description": "Příspěvek obsahuje obrázky bez popisků",
|
||||
"column.favourited_by": "Oblíbeno uživatelem",
|
||||
"column.heading": "Různé",
|
||||
"column.reblogged_by": "Boostnuto uživatelem",
|
||||
"column.subheading": "Různé",
|
||||
"column_header.profile": "Profil",
|
||||
"column_subheading.lists": "Seznamy",
|
||||
"column_subheading.navigation": "Navigace",
|
||||
"community.column_settings.allow_local_only": "Zobrazit pouze místní tooty",
|
||||
"compose.attach.doodle": "Nakreslete něco",
|
||||
"compose.change_federation": "Změnit nastavení federace",
|
||||
@@ -45,8 +41,6 @@
|
||||
"keyboard_shortcuts.secondary_toot": "pro odeslání příspěvku s sekundárním nastavením soukromí",
|
||||
"moved_to_warning": "Tento účet je označen jako přesunut na {moved_to_link}, a proto nemusí přijímat nové sledování.",
|
||||
"navigation_bar.app_settings": "Nastavení aplikace",
|
||||
"navigation_bar.keyboard_shortcuts": "Klávesové zkratky",
|
||||
"navigation_bar.misc": "Různé",
|
||||
"notifications.column_settings.filter_bar.show_bar": "Zobrazit panel filtrů",
|
||||
"settings.always_show_spoilers_field": "Vždy zobrazit pole pro varování o obsahu",
|
||||
"settings.close": "Zavřít",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user