diff --git a/.env.test b/.env.test
index 761d0d9210690d..2f8c1afd6e26e7 100644
--- a/.env.test
+++ b/.env.test
@@ -1,5 +1,5 @@
-# Node.js
-NODE_ENV=tests
+# In test, compile the NodeJS code as if we are in production
+NODE_ENV=production
# Federation
LOCAL_DOMAIN=cb6e6126.ngrok.io
LOCAL_HTTPS=true
diff --git a/.github/workflows/test-ruby.yml b/.github/workflows/test-ruby.yml
index 117e751454c6db..07fd25fb1bffe5 100644
--- a/.github/workflows/test-ruby.yml
+++ b/.github/workflows/test-ruby.yml
@@ -48,12 +48,15 @@ jobs:
run: |-
./bin/rails assets:precompile
+ - name: Archive asset artifacts
+ run: |
+ tar --exclude={"*.br","*.gz"} -zcf artifacts.tar.gz public/assets public/packs*
+
- uses: actions/upload-artifact@v3
if: matrix.mode == 'test'
with:
path: |-
- ./public/assets
- ./public/packs-test
+ ./artifacts.tar.gz
name: ${{ github.sha }}
retention-days: 0
@@ -102,7 +105,6 @@ jobs:
SAML_ENABLED: true
CAS_ENABLED: true
BUNDLE_WITH: 'pam_authentication test'
- CI_JOBS: ${{ matrix.ci_job }}/4
GITHUB_RSPEC: ${{ matrix.ruby-version == '.ruby-version' && github.event.pull_request && 'true' }}
strategy:
@@ -112,19 +114,18 @@ jobs:
- '3.0'
- '3.1'
- '.ruby-version'
- ci_job:
- - 1
- - 2
- - 3
- - 4
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v3
with:
- path: './public'
+ path: './'
name: ${{ github.sha }}
+ - name: Expand archived asset artifacts
+ run: |
+ tar xvzf artifacts.tar.gz
+
- name: Set up Ruby environment
uses: ./.github/actions/setup-ruby
with:
@@ -134,7 +135,7 @@ jobs:
- name: Load database schema
run: './bin/rails db:create db:schema:load db:seed'
- - run: bundle exec rake rspec_chunked
+ - run: bin/rspec
test-e2e:
name: End to End testing
diff --git a/.rubocop.yml b/.rubocop.yml
index 64ec766b223732..63de5e17c785f9 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -27,7 +27,7 @@ AllCops:
- 'node_modules/**/*'
- 'Vagrantfile'
- 'vendor/**/*'
- - 'lib/json_ld/*' # Generated files
+ - 'config/initializers/json_ld*' # Generated files
- 'lib/mastodon/migration_helpers.rb' # Vendored from GitLab
- 'lib/templates/**/*'
diff --git a/Gemfile b/Gemfile
index 1e84fff52d3654..2c355b416067df 100644
--- a/Gemfile
+++ b/Gemfile
@@ -88,7 +88,7 @@ gem 'simple-navigation', '~> 4.4'
gem 'simple_form', '~> 5.2'
gem 'sprockets-rails', '~> 3.4', require: 'sprockets/railtie'
gem 'stoplight', '~> 3.0.1'
-gem 'strong_migrations', '~> 0.8'
+gem 'strong_migrations', '1.3.0'
gem 'tty-prompt', '~> 0.23', require: false
gem 'twitter-text', '~> 3.1.0'
gem 'tzinfo-data', '~> 1.2023'
@@ -103,9 +103,6 @@ gem 'rdf-normalize', '~> 0.5'
gem 'private_address_check', '~> 0.5'
group :test do
- # Used to split testing into chunks in CI
- gem 'rspec_chunked', '~> 0.6'
-
# Adds RSpec Error/Warning annotations to GitHub PRs on the Files tab
gem 'rspec-github', '~> 2.4', require: false
diff --git a/Gemfile.lock b/Gemfile.lock
index 8e1d7e43c4639d..c38666f91e32f4 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -236,7 +236,7 @@ GEM
devise (>= 4.0.0)
rpam2 (~> 4.0)
diff-lcs (1.5.0)
- discard (1.2.1)
+ discard (1.3.0)
activerecord (>= 4.2, < 8)
docile (1.4.0)
domain_name (0.5.20190701)
@@ -265,7 +265,7 @@ GEM
tzinfo
excon (0.100.0)
fabrication (2.30.0)
- faker (3.2.1)
+ faker (3.2.2)
i18n (>= 1.8.11, < 2)
faraday (1.10.3)
faraday-em_http (~> 1.0)
@@ -536,7 +536,7 @@ GEM
pundit (2.3.1)
activesupport (>= 3.0.0)
raabro (1.4.0)
- racc (1.7.1)
+ racc (1.7.3)
rack (2.2.8)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
@@ -650,9 +650,7 @@ GEM
rspec-mocks (~> 3.0)
sidekiq (>= 5, < 8)
rspec-support (3.12.1)
- rspec_chunked (0.6)
- rubocop (1.57.1)
- base64 (~> 0.1.1)
+ rubocop (1.57.2)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
@@ -742,7 +740,7 @@ GEM
stoplight (3.0.2)
redlock (~> 1.0)
stringio (3.0.8)
- strong_migrations (0.8.0)
+ strong_migrations (1.3.0)
activerecord (>= 5.2)
swd (1.3.0)
activesupport (>= 3)
@@ -921,7 +919,6 @@ DEPENDENCIES
rspec-github (~> 2.4)
rspec-rails (~> 6.0)
rspec-sidekiq (~> 4.0)
- rspec_chunked (~> 0.6)
rubocop
rubocop-capybara
rubocop-performance
@@ -944,7 +941,7 @@ DEPENDENCIES
sprockets-rails (~> 3.4)
stackprof
stoplight (~> 3.0.1)
- strong_migrations (~> 0.8)
+ strong_migrations (= 1.3.0)
test-prof
thor (~> 1.2)
tty-prompt (~> 0.23)
diff --git a/SECURITY.md b/SECURITY.md
index 3e13377db63eb4..954ff73a247425 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -17,6 +17,6 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through
| ------- | ---------------- |
| 4.2.x | Yes |
| 4.1.x | Yes |
-| 4.0.x | Until 2023-10-31 |
+| 4.0.x | No |
| 3.5.x | Until 2023-12-31 |
| < 3.5 | No |
diff --git a/app/javascript/flavours/glitch/components/autosuggest_textarea.jsx b/app/javascript/flavours/glitch/components/autosuggest_textarea.jsx
index c023b99f81bc7c..28384075c3c16e 100644
--- a/app/javascript/flavours/glitch/components/autosuggest_textarea.jsx
+++ b/app/javascript/flavours/glitch/components/autosuggest_textarea.jsx
@@ -1,9 +1,9 @@
import PropTypes from 'prop-types';
+import { useCallback, useRef, useState, useEffect, forwardRef } from 'react';
import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
import Textarea from 'react-textarea-autosize';
@@ -37,54 +37,46 @@ const textAtCursorMatchesToken = (str, caretPosition) => {
}
};
-export default class AutosuggestTextarea extends ImmutablePureComponent {
-
- static propTypes = {
- value: PropTypes.string,
- suggestions: ImmutablePropTypes.list,
- disabled: PropTypes.bool,
- placeholder: PropTypes.string,
- onSuggestionSelected: PropTypes.func.isRequired,
- onSuggestionsClearRequested: PropTypes.func.isRequired,
- onSuggestionsFetchRequested: PropTypes.func.isRequired,
- onChange: PropTypes.func.isRequired,
- onKeyUp: PropTypes.func,
- onKeyDown: PropTypes.func,
- onPaste: PropTypes.func.isRequired,
- autoFocus: PropTypes.bool,
- lang: PropTypes.string,
- };
-
- static defaultProps = {
- autoFocus: true,
- };
-
- state = {
- suggestionsHidden: true,
- focused: false,
- selectedSuggestion: 0,
- lastToken: null,
- tokenStart: 0,
- };
-
- onChange = (e) => {
+const AutosuggestTextarea = forwardRef(({
+ value,
+ suggestions,
+ disabled,
+ placeholder,
+ onSuggestionSelected,
+ onSuggestionsClearRequested,
+ onSuggestionsFetchRequested,
+ onChange,
+ onKeyUp,
+ onKeyDown,
+ onPaste,
+ onFocus,
+ autoFocus = true,
+ lang,
+ children,
+}, textareaRef) => {
+
+ const [suggestionsHidden, setSuggestionsHidden] = useState(true);
+ const [selectedSuggestion, setSelectedSuggestion] = useState(0);
+ const lastTokenRef = useRef(null);
+ const tokenStartRef = useRef(0);
+
+ const handleChange = useCallback((e) => {
const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart);
- if (token !== null && this.state.lastToken !== token) {
- this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
- this.props.onSuggestionsFetchRequested(token);
+ if (token !== null && lastTokenRef.current !== token) {
+ tokenStartRef.current = tokenStart;
+ lastTokenRef.current = token;
+ setSelectedSuggestion(0);
+ onSuggestionsFetchRequested(token);
} else if (token === null) {
- this.setState({ lastToken: null });
- this.props.onSuggestionsClearRequested();
+ lastTokenRef.current = null;
+ onSuggestionsClearRequested();
}
- this.props.onChange(e);
- };
-
- onKeyDown = (e) => {
- const { suggestions, disabled } = this.props;
- const { selectedSuggestion, suggestionsHidden } = this.state;
+ onChange(e);
+ }, [onSuggestionsFetchRequested, onSuggestionsClearRequested, onChange, setSelectedSuggestion]);
+ const handleKeyDown = useCallback((e) => {
if (disabled) {
e.preventDefault();
return;
@@ -102,80 +94,75 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
document.querySelector('.ui').parentElement.focus();
} else {
e.preventDefault();
- this.setState({ suggestionsHidden: true });
+ setSuggestionsHidden(true);
}
break;
case 'ArrowDown':
if (suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
- this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
+ setSelectedSuggestion(Math.min(selectedSuggestion + 1, suggestions.size - 1));
}
break;
case 'ArrowUp':
if (suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
- this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
+ setSelectedSuggestion(Math.max(selectedSuggestion - 1, 0));
}
break;
case 'Enter':
case 'Tab':
// Select suggestion
- if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) {
+ if (lastTokenRef.current !== null && suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
e.stopPropagation();
- this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion));
+ onSuggestionSelected(tokenStartRef.current, lastTokenRef.current, suggestions.get(selectedSuggestion));
}
break;
}
- if (e.defaultPrevented || !this.props.onKeyDown) {
+ if (e.defaultPrevented || !onKeyDown) {
return;
}
- this.props.onKeyDown(e);
- };
+ onKeyDown(e);
+ }, [disabled, suggestions, suggestionsHidden, selectedSuggestion, setSelectedSuggestion, setSuggestionsHidden, onSuggestionSelected, onKeyDown]);
- onBlur = () => {
- this.setState({ suggestionsHidden: true, focused: false });
- };
+ const handleBlur = useCallback(() => {
+ setSuggestionsHidden(true);
+ }, [setSuggestionsHidden]);
- onFocus = (e) => {
- this.setState({ focused: true });
- if (this.props.onFocus) {
- this.props.onFocus(e);
+ const handleFocus = useCallback((e) => {
+ if (onFocus) {
+ onFocus(e);
}
- };
+ }, [onFocus]);
- onSuggestionClick = (e) => {
- const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index'));
+ const handleSuggestionClick = useCallback((e) => {
+ const suggestion = suggestions.get(e.currentTarget.getAttribute('data-index'));
e.preventDefault();
- this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
- this.textarea.focus();
- };
+ onSuggestionSelected(tokenStartRef.current, lastTokenRef.current, suggestion);
+ textareaRef.current?.focus();
+ }, [suggestions, onSuggestionSelected, textareaRef]);
- UNSAFE_componentWillReceiveProps (nextProps) {
- if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) {
- this.setState({ suggestionsHidden: false });
- }
- }
-
- setTextarea = (c) => {
- this.textarea = c;
- };
-
- onPaste = (e) => {
+ const handlePaste = useCallback((e) => {
if (e.clipboardData && e.clipboardData.files.length === 1) {
- this.props.onPaste(e.clipboardData.files);
+ onPaste(e.clipboardData.files);
e.preventDefault();
}
- };
+ }, [onPaste]);
+
+ // Show the suggestions again whenever they change and the textarea is focused
+ useEffect(() => {
+ if (suggestions.size > 0 && textareaRef.current === document.activeElement) {
+ setSuggestionsHidden(false);
+ }
+ }, [suggestions, textareaRef, setSuggestionsHidden]);
- renderSuggestion = (suggestion, i) => {
- const { selectedSuggestion } = this.state;
+ const renderSuggestion = (suggestion, i) => {
let inner, key;
if (suggestion.type === 'emoji') {
@@ -190,50 +177,64 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
}
return (
-
+
{inner}
);
};
- render () {
- const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, lang, children } = this.props;
- const { suggestionsHidden } = this.state;
-
- return [
-
-
-
-
- {children}
-
,
-
-
-
- {suggestions.map(this.renderSuggestion)}
-
-
,
- ];
- }
+ return [
+
+
+
+
+ {children}
+
,
+
+
+
+ {suggestions.map(renderSuggestion)}
+
+
,
+ ];
+});
+
+AutosuggestTextarea.propTypes = {
+ value: PropTypes.string,
+ suggestions: ImmutablePropTypes.list,
+ disabled: PropTypes.bool,
+ placeholder: PropTypes.string,
+ onSuggestionSelected: PropTypes.func.isRequired,
+ onSuggestionsClearRequested: PropTypes.func.isRequired,
+ onSuggestionsFetchRequested: PropTypes.func.isRequired,
+ onChange: PropTypes.func.isRequired,
+ onKeyUp: PropTypes.func,
+ onKeyDown: PropTypes.func,
+ onPaste: PropTypes.func.isRequired,
+ onFocus:PropTypes.func,
+ children: PropTypes.node,
+ autoFocus: PropTypes.bool,
+ lang: PropTypes.string,
+};
-}
+export default AutosuggestTextarea;
diff --git a/app/javascript/flavours/glitch/features/compose/components/compose_form.jsx b/app/javascript/flavours/glitch/features/compose/components/compose_form.jsx
index c4cba922f5bb56..47b070da24290f 100644
--- a/app/javascript/flavours/glitch/features/compose/components/compose_form.jsx
+++ b/app/javascript/flavours/glitch/features/compose/components/compose_form.jsx
@@ -1,4 +1,5 @@
import PropTypes from 'prop-types';
+import { createRef } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
@@ -90,6 +91,11 @@ class ComposeForm extends ImmutablePureComponent {
highlighted: false,
};
+ constructor(props) {
+ super(props);
+ this.textareaRef = createRef(null);
+ }
+
handleChange = (e) => {
this.props.onChange(e.target.value);
};
@@ -118,10 +124,10 @@ class ComposeForm extends ImmutablePureComponent {
onChangeVisibility,
} = this.props;
- if (this.props.text !== this.textarea.value) {
+ if (this.props.text !== this.textareaRef.current.value) {
// Something changed the text inside the textarea (e.g. browser extensions like Grammarly)
// Update the state to match the current text
- this.props.onChange(this.textarea.value);
+ this.props.onChange(this.textareaRef.current.value);
}
if (!this.canSubmit()) {
@@ -154,10 +160,10 @@ class ComposeForm extends ImmutablePureComponent {
// Inserts an emoji at the caret.
handleEmojiPick = (data) => {
- const { textarea: { selectionStart } } = this;
- const { onPickEmoji } = this.props;
- if (onPickEmoji) {
- onPickEmoji(selectionStart, data);
+ const position = this.textareaRef.current.selectionStart;
+
+ if (this.props.onPickEmoji) {
+ this.props.onPickEmoji(position, data);
}
};
@@ -188,13 +194,6 @@ class ComposeForm extends ImmutablePureComponent {
}
};
- // Sets a reference to the textarea.
- setAutosuggestTextarea = (textareaComponent) => {
- if (textareaComponent) {
- this.textarea = textareaComponent.textarea;
- }
- };
-
// Sets a reference to the CW field.
handleRefSpoilerText = (spoilerComponent) => {
if (spoilerComponent) {
@@ -232,7 +231,6 @@ class ComposeForm extends ImmutablePureComponent {
// everyone else from the conversation.
_updateFocusAndSelection = (prevProps) => {
const {
- textarea,
spoilerText,
} = this;
const {
@@ -259,30 +257,30 @@ class ComposeForm extends ImmutablePureComponent {
default:
selectionStart = selectionEnd = text.length;
}
- if (textarea) {
+ if (this.textareaRef.current) {
// Because of the wicg-inert polyfill, the activeElement may not be
// immediately selectable, we have to wait for observers to run, as
// described in https://github.com/WICG/inert#performance-and-gotchas
Promise.resolve().then(() => {
- textarea.setSelectionRange(selectionStart, selectionEnd);
- textarea.focus();
- if (!singleColumn) textarea.scrollIntoView();
+ this.textareaRef.current.setSelectionRange(selectionStart, selectionEnd);
+ this.textareaRef.current.focus();
+ if (!singleColumn) this.textareaRef.current.scrollIntoView();
this.setState({ highlighted: true });
this.timeout = setTimeout(() => this.setState({ highlighted: false }), 700);
}).catch(console.error);
}
// Refocuses the textarea after submitting.
- } else if (textarea && prevProps.isSubmitting && !isSubmitting) {
- textarea.focus();
+ } else if (this.textareaRef.current && prevProps.isSubmitting && !isSubmitting) {
+ this.textareaRef.current.focus();
} else if (this.props.spoiler !== prevProps.spoiler) {
if (this.props.spoiler) {
if (spoilerText) {
spoilerText.focus();
}
} else {
- if (textarea) {
- textarea.focus();
+ if (this.textareaRef.current) {
+ this.textareaRef.current.focus();
}
}
}
@@ -347,7 +345,7 @@ class ComposeForm extends ImmutablePureComponent {
{
- // TODO: replace `unknown` with `ApiRelationshipJSON` when it is merged
- const response = await api(getState).post(
+ const response = await api(getState).post(
`/api/v1/accounts/${args.id}/note`,
{
comment: args.value,
diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js
index 3a85393d6cf273..4a985a41eff0f3 100644
--- a/app/javascript/mastodon/actions/accounts.js
+++ b/app/javascript/mastodon/actions/accounts.js
@@ -1,5 +1,15 @@
import api, { getLinks } from '../api';
+import {
+ followAccountSuccess, unfollowAccountSuccess,
+ authorizeFollowRequestSuccess, rejectFollowRequestSuccess,
+ followAccountRequest, followAccountFail,
+ unfollowAccountRequest, unfollowAccountFail,
+ muteAccountSuccess, unmuteAccountSuccess,
+ blockAccountSuccess, unblockAccountSuccess,
+ pinAccountSuccess, unpinAccountSuccess,
+ fetchRelationshipsSuccess,
+} from './accounts_typed';
import { importFetchedAccount, importFetchedAccounts } from './importer';
export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST';
@@ -10,36 +20,22 @@ export const ACCOUNT_LOOKUP_REQUEST = 'ACCOUNT_LOOKUP_REQUEST';
export const ACCOUNT_LOOKUP_SUCCESS = 'ACCOUNT_LOOKUP_SUCCESS';
export const ACCOUNT_LOOKUP_FAIL = 'ACCOUNT_LOOKUP_FAIL';
-export const ACCOUNT_FOLLOW_REQUEST = 'ACCOUNT_FOLLOW_REQUEST';
-export const ACCOUNT_FOLLOW_SUCCESS = 'ACCOUNT_FOLLOW_SUCCESS';
-export const ACCOUNT_FOLLOW_FAIL = 'ACCOUNT_FOLLOW_FAIL';
-
-export const ACCOUNT_UNFOLLOW_REQUEST = 'ACCOUNT_UNFOLLOW_REQUEST';
-export const ACCOUNT_UNFOLLOW_SUCCESS = 'ACCOUNT_UNFOLLOW_SUCCESS';
-export const ACCOUNT_UNFOLLOW_FAIL = 'ACCOUNT_UNFOLLOW_FAIL';
-
export const ACCOUNT_BLOCK_REQUEST = 'ACCOUNT_BLOCK_REQUEST';
-export const ACCOUNT_BLOCK_SUCCESS = 'ACCOUNT_BLOCK_SUCCESS';
export const ACCOUNT_BLOCK_FAIL = 'ACCOUNT_BLOCK_FAIL';
export const ACCOUNT_UNBLOCK_REQUEST = 'ACCOUNT_UNBLOCK_REQUEST';
-export const ACCOUNT_UNBLOCK_SUCCESS = 'ACCOUNT_UNBLOCK_SUCCESS';
export const ACCOUNT_UNBLOCK_FAIL = 'ACCOUNT_UNBLOCK_FAIL';
export const ACCOUNT_MUTE_REQUEST = 'ACCOUNT_MUTE_REQUEST';
-export const ACCOUNT_MUTE_SUCCESS = 'ACCOUNT_MUTE_SUCCESS';
export const ACCOUNT_MUTE_FAIL = 'ACCOUNT_MUTE_FAIL';
export const ACCOUNT_UNMUTE_REQUEST = 'ACCOUNT_UNMUTE_REQUEST';
-export const ACCOUNT_UNMUTE_SUCCESS = 'ACCOUNT_UNMUTE_SUCCESS';
export const ACCOUNT_UNMUTE_FAIL = 'ACCOUNT_UNMUTE_FAIL';
export const ACCOUNT_PIN_REQUEST = 'ACCOUNT_PIN_REQUEST';
-export const ACCOUNT_PIN_SUCCESS = 'ACCOUNT_PIN_SUCCESS';
export const ACCOUNT_PIN_FAIL = 'ACCOUNT_PIN_FAIL';
export const ACCOUNT_UNPIN_REQUEST = 'ACCOUNT_UNPIN_REQUEST';
-export const ACCOUNT_UNPIN_SUCCESS = 'ACCOUNT_UNPIN_SUCCESS';
export const ACCOUNT_UNPIN_FAIL = 'ACCOUNT_UNPIN_FAIL';
export const FOLLOWERS_FETCH_REQUEST = 'FOLLOWERS_FETCH_REQUEST';
@@ -59,7 +55,6 @@ export const FOLLOWING_EXPAND_SUCCESS = 'FOLLOWING_EXPAND_SUCCESS';
export const FOLLOWING_EXPAND_FAIL = 'FOLLOWING_EXPAND_FAIL';
export const RELATIONSHIPS_FETCH_REQUEST = 'RELATIONSHIPS_FETCH_REQUEST';
-export const RELATIONSHIPS_FETCH_SUCCESS = 'RELATIONSHIPS_FETCH_SUCCESS';
export const RELATIONSHIPS_FETCH_FAIL = 'RELATIONSHIPS_FETCH_FAIL';
export const FOLLOW_REQUESTS_FETCH_REQUEST = 'FOLLOW_REQUESTS_FETCH_REQUEST';
@@ -71,15 +66,15 @@ export const FOLLOW_REQUESTS_EXPAND_SUCCESS = 'FOLLOW_REQUESTS_EXPAND_SUCCESS';
export const FOLLOW_REQUESTS_EXPAND_FAIL = 'FOLLOW_REQUESTS_EXPAND_FAIL';
export const FOLLOW_REQUEST_AUTHORIZE_REQUEST = 'FOLLOW_REQUEST_AUTHORIZE_REQUEST';
-export const FOLLOW_REQUEST_AUTHORIZE_SUCCESS = 'FOLLOW_REQUEST_AUTHORIZE_SUCCESS';
export const FOLLOW_REQUEST_AUTHORIZE_FAIL = 'FOLLOW_REQUEST_AUTHORIZE_FAIL';
export const FOLLOW_REQUEST_REJECT_REQUEST = 'FOLLOW_REQUEST_REJECT_REQUEST';
-export const FOLLOW_REQUEST_REJECT_SUCCESS = 'FOLLOW_REQUEST_REJECT_SUCCESS';
export const FOLLOW_REQUEST_REJECT_FAIL = 'FOLLOW_REQUEST_REJECT_FAIL';
export const ACCOUNT_REVEAL = 'ACCOUNT_REVEAL';
+export * from './accounts_typed';
+
export function fetchAccount(id) {
return (dispatch, getState) => {
dispatch(fetchRelationships([id]));
@@ -149,12 +144,12 @@ export function followAccount(id, options = { reblogs: true }) {
const alreadyFollowing = getState().getIn(['relationships', id, 'following']);
const locked = getState().getIn(['accounts', id, 'locked'], false);
- dispatch(followAccountRequest(id, locked));
+ dispatch(followAccountRequest({ id, locked }));
api(getState).post(`/api/v1/accounts/${id}/follow`, options).then(response => {
- dispatch(followAccountSuccess(response.data, alreadyFollowing));
+ dispatch(followAccountSuccess({relationship: response.data, alreadyFollowing}));
}).catch(error => {
- dispatch(followAccountFail(error, locked));
+ dispatch(followAccountFail({ id, error, locked }));
});
};
}
@@ -164,74 +159,22 @@ export function unfollowAccount(id) {
dispatch(unfollowAccountRequest(id));
api(getState).post(`/api/v1/accounts/${id}/unfollow`).then(response => {
- dispatch(unfollowAccountSuccess(response.data, getState().get('statuses')));
+ dispatch(unfollowAccountSuccess({relationship: response.data, statuses: getState().get('statuses')}));
}).catch(error => {
- dispatch(unfollowAccountFail(error));
+ dispatch(unfollowAccountFail({ id, error }));
});
};
}
-export function followAccountRequest(id, locked) {
- return {
- type: ACCOUNT_FOLLOW_REQUEST,
- id,
- locked,
- skipLoading: true,
- };
-}
-
-export function followAccountSuccess(relationship, alreadyFollowing) {
- return {
- type: ACCOUNT_FOLLOW_SUCCESS,
- relationship,
- alreadyFollowing,
- skipLoading: true,
- };
-}
-
-export function followAccountFail(error, locked) {
- return {
- type: ACCOUNT_FOLLOW_FAIL,
- error,
- locked,
- skipLoading: true,
- };
-}
-
-export function unfollowAccountRequest(id) {
- return {
- type: ACCOUNT_UNFOLLOW_REQUEST,
- id,
- skipLoading: true,
- };
-}
-
-export function unfollowAccountSuccess(relationship, statuses) {
- return {
- type: ACCOUNT_UNFOLLOW_SUCCESS,
- relationship,
- statuses,
- skipLoading: true,
- };
-}
-
-export function unfollowAccountFail(error) {
- return {
- type: ACCOUNT_UNFOLLOW_FAIL,
- error,
- skipLoading: true,
- };
-}
-
export function blockAccount(id) {
return (dispatch, getState) => {
dispatch(blockAccountRequest(id));
api(getState).post(`/api/v1/accounts/${id}/block`).then(response => {
// Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers
- dispatch(blockAccountSuccess(response.data, getState().get('statuses')));
+ dispatch(blockAccountSuccess({ relationship: response.data, statuses: getState().get('statuses') }));
}).catch(error => {
- dispatch(blockAccountFail(id, error));
+ dispatch(blockAccountFail({ id, error }));
});
};
}
@@ -241,9 +184,9 @@ export function unblockAccount(id) {
dispatch(unblockAccountRequest(id));
api(getState).post(`/api/v1/accounts/${id}/unblock`).then(response => {
- dispatch(unblockAccountSuccess(response.data));
+ dispatch(unblockAccountSuccess({ relationship: response.data }));
}).catch(error => {
- dispatch(unblockAccountFail(id, error));
+ dispatch(unblockAccountFail({ id, error }));
});
};
}
@@ -254,15 +197,6 @@ export function blockAccountRequest(id) {
id,
};
}
-
-export function blockAccountSuccess(relationship, statuses) {
- return {
- type: ACCOUNT_BLOCK_SUCCESS,
- relationship,
- statuses,
- };
-}
-
export function blockAccountFail(error) {
return {
type: ACCOUNT_BLOCK_FAIL,
@@ -277,13 +211,6 @@ export function unblockAccountRequest(id) {
};
}
-export function unblockAccountSuccess(relationship) {
- return {
- type: ACCOUNT_UNBLOCK_SUCCESS,
- relationship,
- };
-}
-
export function unblockAccountFail(error) {
return {
type: ACCOUNT_UNBLOCK_FAIL,
@@ -298,9 +225,9 @@ export function muteAccount(id, notifications, duration=0) {
api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications, duration }).then(response => {
// Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers
- dispatch(muteAccountSuccess(response.data, getState().get('statuses')));
+ dispatch(muteAccountSuccess({ relationship: response.data, statuses: getState().get('statuses') }));
}).catch(error => {
- dispatch(muteAccountFail(id, error));
+ dispatch(muteAccountFail({ id, error }));
});
};
}
@@ -310,9 +237,9 @@ export function unmuteAccount(id) {
dispatch(unmuteAccountRequest(id));
api(getState).post(`/api/v1/accounts/${id}/unmute`).then(response => {
- dispatch(unmuteAccountSuccess(response.data));
+ dispatch(unmuteAccountSuccess({ relationship: response.data }));
}).catch(error => {
- dispatch(unmuteAccountFail(id, error));
+ dispatch(unmuteAccountFail({ id, error }));
});
};
}
@@ -324,14 +251,6 @@ export function muteAccountRequest(id) {
};
}
-export function muteAccountSuccess(relationship, statuses) {
- return {
- type: ACCOUNT_MUTE_SUCCESS,
- relationship,
- statuses,
- };
-}
-
export function muteAccountFail(error) {
return {
type: ACCOUNT_MUTE_FAIL,
@@ -346,13 +265,6 @@ export function unmuteAccountRequest(id) {
};
}
-export function unmuteAccountSuccess(relationship) {
- return {
- type: ACCOUNT_UNMUTE_SUCCESS,
- relationship,
- };
-}
-
export function unmuteAccountFail(error) {
return {
type: ACCOUNT_UNMUTE_FAIL,
@@ -549,7 +461,7 @@ export function fetchRelationships(accountIds) {
dispatch(fetchRelationshipsRequest(newAccountIds));
api(getState).get(`/api/v1/accounts/relationships?${newAccountIds.map(id => `id[]=${id}`).join('&')}`).then(response => {
- dispatch(fetchRelationshipsSuccess(response.data));
+ dispatch(fetchRelationshipsSuccess({ relationships: response.data }));
}).catch(error => {
dispatch(fetchRelationshipsFail(error));
});
@@ -564,14 +476,6 @@ export function fetchRelationshipsRequest(ids) {
};
}
-export function fetchRelationshipsSuccess(relationships) {
- return {
- type: RELATIONSHIPS_FETCH_SUCCESS,
- relationships,
- skipLoading: true,
- };
-}
-
export function fetchRelationshipsFail(error) {
return {
type: RELATIONSHIPS_FETCH_FAIL,
@@ -659,7 +563,7 @@ export function authorizeFollowRequest(id) {
api(getState)
.post(`/api/v1/follow_requests/${id}/authorize`)
- .then(() => dispatch(authorizeFollowRequestSuccess(id)))
+ .then(() => dispatch(authorizeFollowRequestSuccess({ id })))
.catch(error => dispatch(authorizeFollowRequestFail(id, error)));
};
}
@@ -671,13 +575,6 @@ export function authorizeFollowRequestRequest(id) {
};
}
-export function authorizeFollowRequestSuccess(id) {
- return {
- type: FOLLOW_REQUEST_AUTHORIZE_SUCCESS,
- id,
- };
-}
-
export function authorizeFollowRequestFail(id, error) {
return {
type: FOLLOW_REQUEST_AUTHORIZE_FAIL,
@@ -693,7 +590,7 @@ export function rejectFollowRequest(id) {
api(getState)
.post(`/api/v1/follow_requests/${id}/reject`)
- .then(() => dispatch(rejectFollowRequestSuccess(id)))
+ .then(() => dispatch(rejectFollowRequestSuccess({ id })))
.catch(error => dispatch(rejectFollowRequestFail(id, error)));
};
}
@@ -705,13 +602,6 @@ export function rejectFollowRequestRequest(id) {
};
}
-export function rejectFollowRequestSuccess(id) {
- return {
- type: FOLLOW_REQUEST_REJECT_SUCCESS,
- id,
- };
-}
-
export function rejectFollowRequestFail(id, error) {
return {
type: FOLLOW_REQUEST_REJECT_FAIL,
@@ -725,7 +615,7 @@ export function pinAccount(id) {
dispatch(pinAccountRequest(id));
api(getState).post(`/api/v1/accounts/${id}/pin`).then(response => {
- dispatch(pinAccountSuccess(response.data));
+ dispatch(pinAccountSuccess({ relationship: response.data }));
}).catch(error => {
dispatch(pinAccountFail(error));
});
@@ -737,7 +627,7 @@ export function unpinAccount(id) {
dispatch(unpinAccountRequest(id));
api(getState).post(`/api/v1/accounts/${id}/unpin`).then(response => {
- dispatch(unpinAccountSuccess(response.data));
+ dispatch(unpinAccountSuccess({ relationship: response.data }));
}).catch(error => {
dispatch(unpinAccountFail(error));
});
@@ -751,13 +641,6 @@ export function pinAccountRequest(id) {
};
}
-export function pinAccountSuccess(relationship) {
- return {
- type: ACCOUNT_PIN_SUCCESS,
- relationship,
- };
-}
-
export function pinAccountFail(error) {
return {
type: ACCOUNT_PIN_FAIL,
@@ -772,21 +655,9 @@ export function unpinAccountRequest(id) {
};
}
-export function unpinAccountSuccess(relationship) {
- return {
- type: ACCOUNT_UNPIN_SUCCESS,
- relationship,
- };
-}
-
export function unpinAccountFail(error) {
return {
type: ACCOUNT_UNPIN_FAIL,
error,
};
}
-
-export const revealAccount = id => ({
- type: ACCOUNT_REVEAL,
- id,
-});
diff --git a/app/javascript/mastodon/actions/accounts_typed.ts b/app/javascript/mastodon/actions/accounts_typed.ts
new file mode 100644
index 00000000000000..b908e7528eeb60
--- /dev/null
+++ b/app/javascript/mastodon/actions/accounts_typed.ts
@@ -0,0 +1,97 @@
+import { createAction } from '@reduxjs/toolkit';
+
+import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
+import type { ApiRelationshipJSON } from 'mastodon/api_types/relationships';
+
+export const revealAccount = createAction<{
+ id: string;
+}>('accounts/revealAccount');
+
+export const importAccounts = createAction<{ accounts: ApiAccountJSON[] }>(
+ 'accounts/importAccounts',
+);
+
+function actionWithSkipLoadingTrue(args: Args) {
+ return {
+ payload: {
+ ...args,
+ skipLoading: true,
+ },
+ };
+}
+
+export const followAccountSuccess = createAction(
+ 'accounts/followAccountSuccess',
+ actionWithSkipLoadingTrue<{
+ relationship: ApiRelationshipJSON;
+ alreadyFollowing: boolean;
+ }>,
+);
+
+export const unfollowAccountSuccess = createAction(
+ 'accounts/unfollowAccountSuccess',
+ actionWithSkipLoadingTrue<{
+ relationship: ApiRelationshipJSON;
+ statuses: unknown;
+ alreadyFollowing?: boolean;
+ }>,
+);
+
+export const authorizeFollowRequestSuccess = createAction<{ id: string }>(
+ 'accounts/followRequestAuthorizeSuccess',
+);
+
+export const rejectFollowRequestSuccess = createAction<{ id: string }>(
+ 'accounts/followRequestRejectSuccess',
+);
+
+export const followAccountRequest = createAction(
+ 'accounts/followRequest',
+ actionWithSkipLoadingTrue<{ id: string; locked: boolean }>,
+);
+
+export const followAccountFail = createAction(
+ 'accounts/followFail',
+ actionWithSkipLoadingTrue<{ id: string; error: string; locked: boolean }>,
+);
+
+export const unfollowAccountRequest = createAction(
+ 'accounts/unfollowRequest',
+ actionWithSkipLoadingTrue<{ id: string }>,
+);
+
+export const unfollowAccountFail = createAction(
+ 'accounts/unfollowFail',
+ actionWithSkipLoadingTrue<{ id: string; error: string }>,
+);
+
+export const blockAccountSuccess = createAction<{
+ relationship: ApiRelationshipJSON;
+ statuses: unknown;
+}>('accounts/blockSuccess');
+
+export const unblockAccountSuccess = createAction<{
+ relationship: ApiRelationshipJSON;
+}>('accounts/unblockSuccess');
+
+export const muteAccountSuccess = createAction<{
+ relationship: ApiRelationshipJSON;
+ statuses: unknown;
+}>('accounts/muteSuccess');
+
+export const unmuteAccountSuccess = createAction<{
+ relationship: ApiRelationshipJSON;
+}>('accounts/unmuteSuccess');
+
+export const pinAccountSuccess = createAction<{
+ relationship: ApiRelationshipJSON;
+}>('accounts/pinSuccess');
+
+export const unpinAccountSuccess = createAction<{
+ relationship: ApiRelationshipJSON;
+}>('accounts/unpinSuccess');
+
+export const fetchRelationshipsSuccess = createAction(
+ 'relationships/fetchSuccess',
+ actionWithSkipLoadingTrue<{ relationships: ApiRelationshipJSON[] }>,
+);
diff --git a/app/javascript/mastodon/actions/domain_blocks.js b/app/javascript/mastodon/actions/domain_blocks.js
index d06de20a2d17f5..718002613f4053 100644
--- a/app/javascript/mastodon/actions/domain_blocks.js
+++ b/app/javascript/mastodon/actions/domain_blocks.js
@@ -1,11 +1,13 @@
import api, { getLinks } from '../api';
+import { blockDomainSuccess, unblockDomainSuccess } from "./domain_blocks_typed";
+
+export * from "./domain_blocks_typed";
+
export const DOMAIN_BLOCK_REQUEST = 'DOMAIN_BLOCK_REQUEST';
-export const DOMAIN_BLOCK_SUCCESS = 'DOMAIN_BLOCK_SUCCESS';
export const DOMAIN_BLOCK_FAIL = 'DOMAIN_BLOCK_FAIL';
export const DOMAIN_UNBLOCK_REQUEST = 'DOMAIN_UNBLOCK_REQUEST';
-export const DOMAIN_UNBLOCK_SUCCESS = 'DOMAIN_UNBLOCK_SUCCESS';
export const DOMAIN_UNBLOCK_FAIL = 'DOMAIN_UNBLOCK_FAIL';
export const DOMAIN_BLOCKS_FETCH_REQUEST = 'DOMAIN_BLOCKS_FETCH_REQUEST';
@@ -24,7 +26,7 @@ export function blockDomain(domain) {
const at_domain = '@' + domain;
const accounts = getState().get('accounts').filter(item => item.get('acct').endsWith(at_domain)).valueSeq().map(item => item.get('id'));
- dispatch(blockDomainSuccess(domain, accounts));
+ dispatch(blockDomainSuccess({ domain, accounts }));
}).catch(err => {
dispatch(blockDomainFail(domain, err));
});
@@ -38,14 +40,6 @@ export function blockDomainRequest(domain) {
};
}
-export function blockDomainSuccess(domain, accounts) {
- return {
- type: DOMAIN_BLOCK_SUCCESS,
- domain,
- accounts,
- };
-}
-
export function blockDomainFail(domain, error) {
return {
type: DOMAIN_BLOCK_FAIL,
@@ -61,7 +55,7 @@ export function unblockDomain(domain) {
api(getState).delete('/api/v1/domain_blocks', { params: { domain } }).then(() => {
const at_domain = '@' + domain;
const accounts = getState().get('accounts').filter(item => item.get('acct').endsWith(at_domain)).valueSeq().map(item => item.get('id'));
- dispatch(unblockDomainSuccess(domain, accounts));
+ dispatch(unblockDomainSuccess({ domain, accounts }));
}).catch(err => {
dispatch(unblockDomainFail(domain, err));
});
@@ -75,14 +69,6 @@ export function unblockDomainRequest(domain) {
};
}
-export function unblockDomainSuccess(domain, accounts) {
- return {
- type: DOMAIN_UNBLOCK_SUCCESS,
- domain,
- accounts,
- };
-}
-
export function unblockDomainFail(domain, error) {
return {
type: DOMAIN_UNBLOCK_FAIL,
diff --git a/app/javascript/mastodon/actions/domain_blocks_typed.ts b/app/javascript/mastodon/actions/domain_blocks_typed.ts
new file mode 100644
index 00000000000000..08e0b4a1788e60
--- /dev/null
+++ b/app/javascript/mastodon/actions/domain_blocks_typed.ts
@@ -0,0 +1,13 @@
+import { createAction } from '@reduxjs/toolkit';
+
+import type { Account } from 'mastodon/models/account';
+
+export const blockDomainSuccess = createAction<{
+ domain: string;
+ accounts: Account[];
+}>('domain_blocks/blockSuccess');
+
+export const unblockDomainSuccess = createAction<{
+ domain: string;
+ accounts: Account[];
+}>('domain_blocks/unblockSuccess');
diff --git a/app/javascript/mastodon/actions/importer/index.js b/app/javascript/mastodon/actions/importer/index.js
index 369be6b8fbc0ca..16f191b5846f63 100644
--- a/app/javascript/mastodon/actions/importer/index.js
+++ b/app/javascript/mastodon/actions/importer/index.js
@@ -1,7 +1,7 @@
-import { normalizeAccount, normalizeStatus, normalizePoll } from './normalizer';
+import { importAccounts } from '../accounts_typed';
+
+import { normalizeStatus, normalizePoll } from './normalizer';
-export const ACCOUNT_IMPORT = 'ACCOUNT_IMPORT';
-export const ACCOUNTS_IMPORT = 'ACCOUNTS_IMPORT';
export const STATUS_IMPORT = 'STATUS_IMPORT';
export const STATUSES_IMPORT = 'STATUSES_IMPORT';
export const POLLS_IMPORT = 'POLLS_IMPORT';
@@ -13,14 +13,6 @@ function pushUnique(array, object) {
}
}
-export function importAccount(account) {
- return { type: ACCOUNT_IMPORT, account };
-}
-
-export function importAccounts(accounts) {
- return { type: ACCOUNTS_IMPORT, accounts };
-}
-
export function importStatus(status) {
return { type: STATUS_IMPORT, status };
}
@@ -45,7 +37,7 @@ export function importFetchedAccounts(accounts) {
const normalAccounts = [];
function processAccount(account) {
- pushUnique(normalAccounts, normalizeAccount(account));
+ pushUnique(normalAccounts, account);
if (account.moved) {
processAccount(account.moved);
@@ -54,7 +46,7 @@ export function importFetchedAccounts(accounts) {
accounts.forEach(processAccount);
- return importAccounts(normalAccounts);
+ return importAccounts({ accounts: normalAccounts });
}
export function importFetchedStatus(status) {
diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js
index a72142a86f70bb..b5a30343e488c4 100644
--- a/app/javascript/mastodon/actions/importer/normalizer.js
+++ b/app/javascript/mastodon/actions/importer/normalizer.js
@@ -2,7 +2,6 @@ import escapeTextContentForBrowser from 'escape-html';
import emojify from '../../features/emoji/emoji';
import { expandSpoilers } from '../../initial_state';
-import { unescapeHTML } from '../../utils/html';
const domParser = new DOMParser();
@@ -17,32 +16,6 @@ export function searchTextFromRawStatus (status) {
return domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
}
-export function normalizeAccount(account) {
- account = { ...account };
-
- const emojiMap = makeEmojiMap(account.emojis);
- const displayName = account.display_name.trim().length === 0 ? account.username : account.display_name;
-
- account.display_name_html = emojify(escapeTextContentForBrowser(displayName), emojiMap);
- account.note_emojified = emojify(account.note, emojiMap);
- account.note_plain = unescapeHTML(account.note);
-
- if (account.fields) {
- account.fields = account.fields.map(pair => ({
- ...pair,
- name_emojified: emojify(escapeTextContentForBrowser(pair.name), emojiMap),
- value_emojified: emojify(pair.value, emojiMap),
- value_plain: unescapeHTML(pair.value),
- }));
- }
-
- if (account.moved) {
- account.moved = account.moved.id;
- }
-
- return account;
-}
-
export function normalizeFilterResult(result) {
const normalResult = { ...result };
diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js
index 02fe10ba56e4a0..878ff5d89d8946 100644
--- a/app/javascript/mastodon/actions/notifications.js
+++ b/app/javascript/mastodon/actions/notifications.js
@@ -18,10 +18,12 @@ import {
importFetchedStatuses,
} from './importer';
import { submitMarkers } from './markers';
+import { notificationsUpdate } from "./notifications_typed";
import { register as registerPushNotifications } from './push_notifications';
import { saveSettings } from './settings';
-export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
+export * from "./notifications_typed";
+
export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP';
export const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST';
@@ -95,12 +97,8 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
dispatch(importFetchedAccount(notification.report.target_account));
}
- dispatch({
- type: NOTIFICATIONS_UPDATE,
- notification,
- usePendingItems: preferPendingItems,
- meta: (playSound && !filtered) ? { sound: 'boop' } : undefined,
- });
+
+ dispatch(notificationsUpdate(notification, preferPendingItems, playSound && !filtered));
fetchRelatedRelationships(dispatch, [notification]);
} else if (playSound && !filtered) {
diff --git a/app/javascript/mastodon/actions/notifications_typed.ts b/app/javascript/mastodon/actions/notifications_typed.ts
new file mode 100644
index 00000000000000..7e51fa51e7b7e8
--- /dev/null
+++ b/app/javascript/mastodon/actions/notifications_typed.ts
@@ -0,0 +1,23 @@
+import { createAction } from '@reduxjs/toolkit';
+
+import type { ApiAccountJSON } from '../api_types/accounts';
+// To be replaced once ApiNotificationJSON type exists
+interface FakeApiNotificationJSON {
+ type: string;
+ account: ApiAccountJSON;
+}
+
+export const notificationsUpdate = createAction(
+ 'notifications/update',
+ ({
+ playSound,
+ ...args
+ }: {
+ notification: FakeApiNotificationJSON;
+ usePendingItems: boolean;
+ playSound: boolean;
+ }) => ({
+ payload: args,
+ meta: { playSound: playSound ? { sound: 'boop' } : undefined },
+ }),
+);
diff --git a/app/javascript/mastodon/actions/store.js b/app/javascript/mastodon/actions/store.js
index 682b0f5db7e4a0..8ab75cdc444620 100644
--- a/app/javascript/mastodon/actions/store.js
+++ b/app/javascript/mastodon/actions/store.js
@@ -11,6 +11,7 @@ const convertState = rawState =>
fromJS(rawState, (k, v) =>
Iterable.isIndexed(v) ? v.toList() : v.toMap());
+
export function hydrateStore(rawState) {
return dispatch => {
const state = convertState(rawState);
diff --git a/app/javascript/mastodon/api_types/accounts.ts b/app/javascript/mastodon/api_types/accounts.ts
index 9d740c96de6977..ce55dc604ad001 100644
--- a/app/javascript/mastodon/api_types/accounts.ts
+++ b/app/javascript/mastodon/api_types/accounts.ts
@@ -31,9 +31,9 @@ export interface ApiAccountJSON {
id: string;
last_status_at: string;
locked: boolean;
- noindex: boolean;
+ noindex?: boolean;
note: string;
- roles: ApiAccountJSON[];
+ roles?: ApiAccountJSON[];
statuses_count: number;
uri: string;
url: string;
diff --git a/app/javascript/mastodon/components/account.jsx b/app/javascript/mastodon/components/account.jsx
index e4e3c88b6f2fae..aa18ce79a5e52d 100644
--- a/app/javascript/mastodon/components/account.jsx
+++ b/app/javascript/mastodon/components/account.jsx
@@ -36,7 +36,7 @@ class Account extends ImmutablePureComponent {
static propTypes = {
size: PropTypes.number,
- account: ImmutablePropTypes.map,
+ account: ImmutablePropTypes.record,
onFollow: PropTypes.func.isRequired,
onBlock: PropTypes.func.isRequired,
onMute: PropTypes.func.isRequired,
diff --git a/app/javascript/mastodon/components/autosuggest_textarea.jsx b/app/javascript/mastodon/components/autosuggest_textarea.jsx
index 230e4f65721077..4d173af59d47c9 100644
--- a/app/javascript/mastodon/components/autosuggest_textarea.jsx
+++ b/app/javascript/mastodon/components/autosuggest_textarea.jsx
@@ -1,9 +1,9 @@
import PropTypes from 'prop-types';
+import { useCallback, useRef, useState, useEffect, forwardRef } from 'react';
import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
import Textarea from 'react-textarea-autosize';
@@ -37,54 +37,46 @@ const textAtCursorMatchesToken = (str, caretPosition) => {
}
};
-export default class AutosuggestTextarea extends ImmutablePureComponent {
-
- static propTypes = {
- value: PropTypes.string,
- suggestions: ImmutablePropTypes.list,
- disabled: PropTypes.bool,
- placeholder: PropTypes.string,
- onSuggestionSelected: PropTypes.func.isRequired,
- onSuggestionsClearRequested: PropTypes.func.isRequired,
- onSuggestionsFetchRequested: PropTypes.func.isRequired,
- onChange: PropTypes.func.isRequired,
- onKeyUp: PropTypes.func,
- onKeyDown: PropTypes.func,
- onPaste: PropTypes.func.isRequired,
- autoFocus: PropTypes.bool,
- lang: PropTypes.string,
- };
-
- static defaultProps = {
- autoFocus: true,
- };
-
- state = {
- suggestionsHidden: true,
- focused: false,
- selectedSuggestion: 0,
- lastToken: null,
- tokenStart: 0,
- };
-
- onChange = (e) => {
+const AutosuggestTextarea = forwardRef(({
+ value,
+ suggestions,
+ disabled,
+ placeholder,
+ onSuggestionSelected,
+ onSuggestionsClearRequested,
+ onSuggestionsFetchRequested,
+ onChange,
+ onKeyUp,
+ onKeyDown,
+ onPaste,
+ onFocus,
+ autoFocus = true,
+ lang,
+ children,
+}, textareaRef) => {
+
+ const [suggestionsHidden, setSuggestionsHidden] = useState(true);
+ const [selectedSuggestion, setSelectedSuggestion] = useState(0);
+ const lastTokenRef = useRef(null);
+ const tokenStartRef = useRef(0);
+
+ const handleChange = useCallback((e) => {
const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart);
- if (token !== null && this.state.lastToken !== token) {
- this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
- this.props.onSuggestionsFetchRequested(token);
+ if (token !== null && lastTokenRef.current !== token) {
+ tokenStartRef.current = tokenStart;
+ lastTokenRef.current = token;
+ setSelectedSuggestion(0);
+ onSuggestionsFetchRequested(token);
} else if (token === null) {
- this.setState({ lastToken: null });
- this.props.onSuggestionsClearRequested();
+ lastTokenRef.current = null;
+ onSuggestionsClearRequested();
}
- this.props.onChange(e);
- };
-
- onKeyDown = (e) => {
- const { suggestions, disabled } = this.props;
- const { selectedSuggestion, suggestionsHidden } = this.state;
+ onChange(e);
+ }, [onSuggestionsFetchRequested, onSuggestionsClearRequested, onChange, setSelectedSuggestion]);
+ const handleKeyDown = useCallback((e) => {
if (disabled) {
e.preventDefault();
return;
@@ -102,80 +94,75 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
document.querySelector('.ui').parentElement.focus();
} else {
e.preventDefault();
- this.setState({ suggestionsHidden: true });
+ setSuggestionsHidden(true);
}
break;
case 'ArrowDown':
if (suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
- this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
+ setSelectedSuggestion(Math.min(selectedSuggestion + 1, suggestions.size - 1));
}
break;
case 'ArrowUp':
if (suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
- this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
+ setSelectedSuggestion(Math.max(selectedSuggestion - 1, 0));
}
break;
case 'Enter':
case 'Tab':
// Select suggestion
- if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) {
+ if (lastTokenRef.current !== null && suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
e.stopPropagation();
- this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion));
+ onSuggestionSelected(tokenStartRef.current, lastTokenRef.current, suggestions.get(selectedSuggestion));
}
break;
}
- if (e.defaultPrevented || !this.props.onKeyDown) {
+ if (e.defaultPrevented || !onKeyDown) {
return;
}
- this.props.onKeyDown(e);
- };
+ onKeyDown(e);
+ }, [disabled, suggestions, suggestionsHidden, selectedSuggestion, setSelectedSuggestion, setSuggestionsHidden, onSuggestionSelected, onKeyDown]);
- onBlur = () => {
- this.setState({ suggestionsHidden: true, focused: false });
- };
+ const handleBlur = useCallback(() => {
+ setSuggestionsHidden(true);
+ }, [setSuggestionsHidden]);
- onFocus = (e) => {
- this.setState({ focused: true });
- if (this.props.onFocus) {
- this.props.onFocus(e);
+ const handleFocus = useCallback((e) => {
+ if (onFocus) {
+ onFocus(e);
}
- };
+ }, [onFocus]);
- onSuggestionClick = (e) => {
- const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index'));
+ const handleSuggestionClick = useCallback((e) => {
+ const suggestion = suggestions.get(e.currentTarget.getAttribute('data-index'));
e.preventDefault();
- this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
- this.textarea.focus();
- };
+ onSuggestionSelected(tokenStartRef.current, lastTokenRef.current, suggestion);
+ textareaRef.current?.focus();
+ }, [suggestions, onSuggestionSelected, textareaRef]);
- UNSAFE_componentWillReceiveProps (nextProps) {
- if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) {
- this.setState({ suggestionsHidden: false });
- }
- }
-
- setTextarea = (c) => {
- this.textarea = c;
- };
-
- onPaste = (e) => {
+ const handlePaste = useCallback((e) => {
if (e.clipboardData && e.clipboardData.files.length === 1) {
- this.props.onPaste(e.clipboardData.files);
+ onPaste(e.clipboardData.files);
e.preventDefault();
}
- };
+ }, [onPaste]);
+
+ // Show the suggestions again whenever they change and the textarea is focused
+ useEffect(() => {
+ if (suggestions.size > 0 && textareaRef.current === document.activeElement) {
+ setSuggestionsHidden(false);
+ }
+ }, [suggestions, textareaRef, setSuggestionsHidden]);
- renderSuggestion = (suggestion, i) => {
- const { selectedSuggestion } = this.state;
+ const renderSuggestion = (suggestion, i) => {
let inner, key;
if (suggestion.type === 'emoji') {
@@ -190,50 +177,64 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
}
return (
-
+
{inner}
);
};
- render () {
- const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, lang, children } = this.props;
- const { suggestionsHidden } = this.state;
-
- return [
-
-
-
-
- {children}
-
,
-
-
-
- {suggestions.map(this.renderSuggestion)}
-
-
,
- ];
- }
+ return [
+
+
+
+
+ {children}
+
,
+
+
+
+ {suggestions.map(renderSuggestion)}
+
+
,
+ ];
+});
+
+AutosuggestTextarea.propTypes = {
+ value: PropTypes.string,
+ suggestions: ImmutablePropTypes.list,
+ disabled: PropTypes.bool,
+ placeholder: PropTypes.string,
+ onSuggestionSelected: PropTypes.func.isRequired,
+ onSuggestionsClearRequested: PropTypes.func.isRequired,
+ onSuggestionsFetchRequested: PropTypes.func.isRequired,
+ onChange: PropTypes.func.isRequired,
+ onKeyUp: PropTypes.func,
+ onKeyDown: PropTypes.func,
+ onPaste: PropTypes.func.isRequired,
+ onFocus:PropTypes.func,
+ children: PropTypes.node,
+ autoFocus: PropTypes.bool,
+ lang: PropTypes.string,
+};
-}
+export default AutosuggestTextarea;
diff --git a/app/javascript/mastodon/components/avatar.tsx b/app/javascript/mastodon/components/avatar.tsx
index 5f9bb390e82ea5..77bd4234094766 100644
--- a/app/javascript/mastodon/components/avatar.tsx
+++ b/app/javascript/mastodon/components/avatar.tsx
@@ -1,7 +1,8 @@
import classNames from 'classnames';
+import type { Account } from 'mastodon/models/account';
+
import { useHovering } from '../../hooks/useHovering';
-import type { Account } from '../../types/resources';
import { autoPlayGif } from '../initial_state';
interface Props {
diff --git a/app/javascript/mastodon/components/avatar_overlay.tsx b/app/javascript/mastodon/components/avatar_overlay.tsx
index 61de9d0beba488..f98cfcc38b4eeb 100644
--- a/app/javascript/mastodon/components/avatar_overlay.tsx
+++ b/app/javascript/mastodon/components/avatar_overlay.tsx
@@ -1,5 +1,6 @@
+import type { Account } from 'mastodon/models/account';
+
import { useHovering } from '../../hooks/useHovering';
-import type { Account } from '../../types/resources';
import { autoPlayGif } from '../initial_state';
interface Props {
diff --git a/app/javascript/mastodon/components/display_name.tsx b/app/javascript/mastodon/components/display_name.tsx
index 82a42bb022636c..8409244827ebb4 100644
--- a/app/javascript/mastodon/components/display_name.tsx
+++ b/app/javascript/mastodon/components/display_name.tsx
@@ -2,7 +2,8 @@ import React from 'react';
import type { List } from 'immutable';
-import type { Account } from '../../types/resources';
+import type { Account } from 'mastodon/models/account';
+
import { autoPlayGif } from '../initial_state';
import { Skeleton } from './skeleton';
diff --git a/app/javascript/mastodon/components/icon.tsx b/app/javascript/mastodon/components/icon.tsx
index dbf5839606597b..f0af11f7f69f5f 100644
--- a/app/javascript/mastodon/components/icon.tsx
+++ b/app/javascript/mastodon/components/icon.tsx
@@ -2,6 +2,8 @@ import classNames from 'classnames';
import { ReactComponent as CheckBoxOutlineBlankIcon } from '@material-symbols/svg-600/outlined/check_box_outline_blank.svg';
+import { isProduction } from 'mastodon/utils/environment';
+
interface SVGPropsWithTitle extends React.SVGProps
{
title?: string;
}
@@ -24,7 +26,7 @@ export const Icon: React.FC = ({
}) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!IconComponent) {
- if (process.env.NODE_ENV !== 'production') {
+ if (!isProduction()) {
throw new Error(
` is missing an "icon" prop.`,
);
diff --git a/app/javascript/mastodon/components/inline_account.jsx b/app/javascript/mastodon/components/inline_account.jsx
index f9767c29d42539..792c412287759e 100644
--- a/app/javascript/mastodon/components/inline_account.jsx
+++ b/app/javascript/mastodon/components/inline_account.jsx
@@ -19,7 +19,7 @@ const makeMapStateToProps = () => {
class InlineAccount extends PureComponent {
static propTypes = {
- account: ImmutablePropTypes.map.isRequired,
+ account: ImmutablePropTypes.record.isRequired,
};
render () {
diff --git a/app/javascript/mastodon/components/router.tsx b/app/javascript/mastodon/components/router.tsx
index fe50fc2ba9014e..42b926659cb713 100644
--- a/app/javascript/mastodon/components/router.tsx
+++ b/app/javascript/mastodon/components/router.tsx
@@ -11,6 +11,7 @@ import type {
import { createBrowserHistory } from 'history';
import { layoutFromWindow } from 'mastodon/is_mobile';
+import { isDevelopment } from 'mastodon/utils/environment';
interface MastodonLocationState {
fromMastodon?: boolean;
@@ -40,7 +41,7 @@ function normalizePath(
} else if (
location.state !== undefined &&
state !== undefined &&
- process.env.NODE_ENV === 'development'
+ isDevelopment()
) {
// eslint-disable-next-line no-console
console.log(
diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx
index e065b4816ac480..c141ee2da3ef19 100644
--- a/app/javascript/mastodon/components/status.jsx
+++ b/app/javascript/mastodon/components/status.jsx
@@ -80,7 +80,7 @@ class Status extends ImmutablePureComponent {
static propTypes = {
status: ImmutablePropTypes.map,
- account: ImmutablePropTypes.map,
+ account: ImmutablePropTypes.record,
previousId: PropTypes.string,
nextInReplyToId: PropTypes.string,
rootId: PropTypes.string,
diff --git a/app/javascript/mastodon/containers/mastodon.jsx b/app/javascript/mastodon/containers/mastodon.jsx
index 59efc80570dc6c..87708da191bb56 100644
--- a/app/javascript/mastodon/containers/mastodon.jsx
+++ b/app/javascript/mastodon/containers/mastodon.jsx
@@ -17,8 +17,9 @@ import UI from 'mastodon/features/ui';
import initialState, { title as siteTitle } from 'mastodon/initial_state';
import { IntlProvider } from 'mastodon/locales';
import { store } from 'mastodon/store';
+import { isProduction } from 'mastodon/utils/environment';
-const title = process.env.NODE_ENV === 'production' ? siteTitle : `${siteTitle} (Dev)`;
+const title = isProduction() ? siteTitle : `${siteTitle} (Dev)`;
const hydrateAction = hydrateStore(initialState);
diff --git a/app/javascript/mastodon/features/account/components/account_note.jsx b/app/javascript/mastodon/features/account/components/account_note.jsx
index bab523acf63bfe..272a4ee312c08d 100644
--- a/app/javascript/mastodon/features/account/components/account_note.jsx
+++ b/app/javascript/mastodon/features/account/components/account_note.jsx
@@ -49,7 +49,7 @@ class InlineAlert extends PureComponent {
class AccountNote extends ImmutablePureComponent {
static propTypes = {
- account: ImmutablePropTypes.map.isRequired,
+ account: ImmutablePropTypes.record.isRequired,
value: PropTypes.string,
onSave: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
diff --git a/app/javascript/mastodon/features/account/components/featured_tags.jsx b/app/javascript/mastodon/features/account/components/featured_tags.jsx
index bbbec1b86470c2..4d7dd86560a34d 100644
--- a/app/javascript/mastodon/features/account/components/featured_tags.jsx
+++ b/app/javascript/mastodon/features/account/components/featured_tags.jsx
@@ -15,7 +15,7 @@ const messages = defineMessages({
class FeaturedTags extends ImmutablePureComponent {
static propTypes = {
- account: ImmutablePropTypes.map,
+ account: ImmutablePropTypes.record,
featuredTags: ImmutablePropTypes.list,
tagged: PropTypes.string,
intl: PropTypes.object.isRequired,
diff --git a/app/javascript/mastodon/features/account/components/follow_request_note.jsx b/app/javascript/mastodon/features/account/components/follow_request_note.jsx
index 0e597a70505771..685c282df27761 100644
--- a/app/javascript/mastodon/features/account/components/follow_request_note.jsx
+++ b/app/javascript/mastodon/features/account/components/follow_request_note.jsx
@@ -11,7 +11,7 @@ import { Icon } from 'mastodon/components/icon';
export default class FollowRequestNote extends ImmutablePureComponent {
static propTypes = {
- account: ImmutablePropTypes.map.isRequired,
+ account: ImmutablePropTypes.record.isRequired,
};
render () {
diff --git a/app/javascript/mastodon/features/account/components/header.jsx b/app/javascript/mastodon/features/account/components/header.jsx
index 76074225adf5c2..e546c756934656 100644
--- a/app/javascript/mastodon/features/account/components/header.jsx
+++ b/app/javascript/mastodon/features/account/components/header.jsx
@@ -91,7 +91,7 @@ const dateFormatOptions = {
class Header extends ImmutablePureComponent {
static propTypes = {
- account: ImmutablePropTypes.map,
+ account: ImmutablePropTypes.record,
identity_props: ImmutablePropTypes.list,
onFollow: PropTypes.func.isRequired,
onBlock: PropTypes.func.isRequired,
diff --git a/app/javascript/mastodon/features/account_timeline/components/header.jsx b/app/javascript/mastodon/features/account_timeline/components/header.jsx
index aede7e495744d0..7de8d3771b4683 100644
--- a/app/javascript/mastodon/features/account_timeline/components/header.jsx
+++ b/app/javascript/mastodon/features/account_timeline/components/header.jsx
@@ -17,7 +17,7 @@ import MovedNote from './moved_note';
class Header extends ImmutablePureComponent {
static propTypes = {
- account: ImmutablePropTypes.map,
+ account: ImmutablePropTypes.record,
onFollow: PropTypes.func.isRequired,
onBlock: PropTypes.func.isRequired,
onMention: PropTypes.func.isRequired,
diff --git a/app/javascript/mastodon/features/account_timeline/components/limited_account_hint.jsx b/app/javascript/mastodon/features/account_timeline/components/limited_account_hint.jsx
deleted file mode 100644
index 59b7358233e8bd..00000000000000
--- a/app/javascript/mastodon/features/account_timeline/components/limited_account_hint.jsx
+++ /dev/null
@@ -1,40 +0,0 @@
-import PropTypes from 'prop-types';
-import { PureComponent } from 'react';
-
-import { FormattedMessage } from 'react-intl';
-
-import { connect } from 'react-redux';
-
-import { revealAccount } from 'mastodon/actions/accounts';
-import { Button } from 'mastodon/components/button';
-import { domain } from 'mastodon/initial_state';
-
-const mapDispatchToProps = (dispatch, { accountId }) => ({
-
- reveal () {
- dispatch(revealAccount(accountId));
- },
-
-});
-
-class LimitedAccountHint extends PureComponent {
-
- static propTypes = {
- accountId: PropTypes.string.isRequired,
- reveal: PropTypes.func,
- };
-
- render () {
- const { reveal } = this.props;
-
- return (
-
- );
- }
-
-}
-
-export default connect(() => {}, mapDispatchToProps)(LimitedAccountHint);
diff --git a/app/javascript/mastodon/features/account_timeline/components/limited_account_hint.tsx b/app/javascript/mastodon/features/account_timeline/components/limited_account_hint.tsx
new file mode 100644
index 00000000000000..f06bf574a77810
--- /dev/null
+++ b/app/javascript/mastodon/features/account_timeline/components/limited_account_hint.tsx
@@ -0,0 +1,35 @@
+import { useCallback } from 'react';
+
+import { FormattedMessage } from 'react-intl';
+
+import { revealAccount } from 'mastodon/actions/accounts_typed';
+import { Button } from 'mastodon/components/button';
+import { domain } from 'mastodon/initial_state';
+import { useAppDispatch } from 'mastodon/store';
+
+export const LimitedAccountHint: React.FC<{ accountId: string }> = ({
+ accountId,
+}) => {
+ const dispatch = useAppDispatch();
+ const reveal = useCallback(() => {
+ dispatch(revealAccount({ id: accountId }));
+ }, [dispatch, accountId]);
+
+ return (
+
+ );
+};
diff --git a/app/javascript/mastodon/features/account_timeline/index.jsx b/app/javascript/mastodon/features/account_timeline/index.jsx
index 5dae66b46365b0..5ec029593d5344 100644
--- a/app/javascript/mastodon/features/account_timeline/index.jsx
+++ b/app/javascript/mastodon/features/account_timeline/index.jsx
@@ -21,7 +21,7 @@ import { LoadingIndicator } from '../../components/loading_indicator';
import StatusList from '../../components/status_list';
import Column from '../ui/components/column';
-import LimitedAccountHint from './components/limited_account_hint';
+import { LimitedAccountHint } from './components/limited_account_hint';
import HeaderContainer from './containers/header_container';
const emptyList = ImmutableList();
diff --git a/app/javascript/mastodon/features/compose/components/action_bar.jsx b/app/javascript/mastodon/features/compose/components/action_bar.jsx
index f7488cf5541857..3e2109092576f7 100644
--- a/app/javascript/mastodon/features/compose/components/action_bar.jsx
+++ b/app/javascript/mastodon/features/compose/components/action_bar.jsx
@@ -28,7 +28,7 @@ const messages = defineMessages({
class ActionBar extends PureComponent {
static propTypes = {
- account: ImmutablePropTypes.map.isRequired,
+ account: ImmutablePropTypes.record.isRequired,
onLogout: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
diff --git a/app/javascript/mastodon/features/compose/components/autosuggest_account.jsx b/app/javascript/mastodon/features/compose/components/autosuggest_account.jsx
index ebda0590e3eb20..0a73bc1020f4f6 100644
--- a/app/javascript/mastodon/features/compose/components/autosuggest_account.jsx
+++ b/app/javascript/mastodon/features/compose/components/autosuggest_account.jsx
@@ -7,7 +7,7 @@ import { DisplayName } from '../../../components/display_name';
export default class AutosuggestAccount extends ImmutablePureComponent {
static propTypes = {
- account: ImmutablePropTypes.map.isRequired,
+ account: ImmutablePropTypes.record.isRequired,
};
render () {
diff --git a/app/javascript/mastodon/features/compose/components/compose_form.jsx b/app/javascript/mastodon/features/compose/components/compose_form.jsx
index 5bd990a302c18d..3b752f252d0099 100644
--- a/app/javascript/mastodon/features/compose/components/compose_form.jsx
+++ b/app/javascript/mastodon/features/compose/components/compose_form.jsx
@@ -1,4 +1,5 @@
import PropTypes from 'prop-types';
+import { createRef } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
@@ -80,6 +81,11 @@ class ComposeForm extends ImmutablePureComponent {
highlighted: false,
};
+ constructor(props) {
+ super(props);
+ this.textareaRef = createRef(null);
+ }
+
handleChange = (e) => {
this.props.onChange(e.target.value);
};
@@ -103,10 +109,10 @@ class ComposeForm extends ImmutablePureComponent {
};
handleSubmit = (e) => {
- if (this.props.text !== this.autosuggestTextarea.textarea.value) {
+ if (this.props.text !== this.textareaRef.current.value) {
// Something changed the text inside the textarea (e.g. browser extensions like Grammarly)
// Update the state to match the current text
- this.props.onChange(this.autosuggestTextarea.textarea.value);
+ this.props.onChange(this.textareaRef.current.value);
}
if (!this.canSubmit()) {
@@ -185,26 +191,22 @@ class ComposeForm extends ImmutablePureComponent {
// immediately selectable, we have to wait for observers to run, as
// described in https://github.com/WICG/inert#performance-and-gotchas
Promise.resolve().then(() => {
- this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd);
- this.autosuggestTextarea.textarea.focus();
+ this.textareaRef.current.setSelectionRange(selectionStart, selectionEnd);
+ this.textareaRef.current.focus();
this.setState({ highlighted: true });
this.timeout = setTimeout(() => this.setState({ highlighted: false }), 700);
}).catch(console.error);
} else if(prevProps.isSubmitting && !this.props.isSubmitting) {
- this.autosuggestTextarea.textarea.focus();
+ this.textareaRef.current.focus();
} else if (this.props.spoiler !== prevProps.spoiler) {
if (this.props.spoiler) {
this.spoilerText.input.focus();
} else if (prevProps.spoiler) {
- this.autosuggestTextarea.textarea.focus();
+ this.textareaRef.current.focus();
}
}
};
- setAutosuggestTextarea = (c) => {
- this.autosuggestTextarea = c;
- };
-
setSpoilerText = (c) => {
this.spoilerText = c;
};
@@ -215,7 +217,7 @@ class ComposeForm extends ImmutablePureComponent {
handleEmojiPick = (data) => {
const { text } = this.props;
- const position = this.autosuggestTextarea.textarea.selectionStart;
+ const position = this.textareaRef.current.selectionStart;
const needsSpace = data.custom && position > 0 && !allowedAroundShortCode.includes(text[position - 1]);
this.props.onPickEmoji(position, data, needsSpace);
@@ -264,7 +266,7 @@ class ComposeForm extends ImmutablePureComponent {
({
class AccountCard extends ImmutablePureComponent {
static propTypes = {
- account: ImmutablePropTypes.map.isRequired,
+ account: ImmutablePropTypes.record.isRequired,
intl: PropTypes.object.isRequired,
onFollow: PropTypes.func.isRequired,
onBlock: PropTypes.func.isRequired,
diff --git a/app/javascript/mastodon/features/follow_requests/components/account_authorize.jsx b/app/javascript/mastodon/features/follow_requests/components/account_authorize.jsx
index b5dfe510e90946..ca2b454143a468 100644
--- a/app/javascript/mastodon/features/follow_requests/components/account_authorize.jsx
+++ b/app/javascript/mastodon/features/follow_requests/components/account_authorize.jsx
@@ -22,7 +22,7 @@ const messages = defineMessages({
class AccountAuthorize extends ImmutablePureComponent {
static propTypes = {
- account: ImmutablePropTypes.map.isRequired,
+ account: ImmutablePropTypes.record.isRequired,
onAuthorize: PropTypes.func.isRequired,
onReject: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
diff --git a/app/javascript/mastodon/features/followers/index.jsx b/app/javascript/mastodon/features/followers/index.jsx
index fc0ce8ab30119b..e50b2171a0a256 100644
--- a/app/javascript/mastodon/features/followers/index.jsx
+++ b/app/javascript/mastodon/features/followers/index.jsx
@@ -23,7 +23,7 @@ import { ColumnBackButton } from '../../components/column_back_button';
import { LoadingIndicator } from '../../components/loading_indicator';
import ScrollableList from '../../components/scrollable_list';
import AccountContainer from '../../containers/account_container';
-import LimitedAccountHint from '../account_timeline/components/limited_account_hint';
+import { LimitedAccountHint } from '../account_timeline/components/limited_account_hint';
import HeaderContainer from '../account_timeline/containers/header_container';
import Column from '../ui/components/column';
diff --git a/app/javascript/mastodon/features/following/index.jsx b/app/javascript/mastodon/features/following/index.jsx
index fb02d179505d94..73e77aadd78237 100644
--- a/app/javascript/mastodon/features/following/index.jsx
+++ b/app/javascript/mastodon/features/following/index.jsx
@@ -23,7 +23,7 @@ import { ColumnBackButton } from '../../components/column_back_button';
import { LoadingIndicator } from '../../components/loading_indicator';
import ScrollableList from '../../components/scrollable_list';
import AccountContainer from '../../containers/account_container';
-import LimitedAccountHint from '../account_timeline/components/limited_account_hint';
+import { LimitedAccountHint } from '../account_timeline/components/limited_account_hint';
import HeaderContainer from '../account_timeline/containers/header_container';
import Column from '../ui/components/column';
diff --git a/app/javascript/mastodon/features/list_adder/components/account.jsx b/app/javascript/mastodon/features/list_adder/components/account.jsx
index 31a2e963795e12..94a90726e33342 100644
--- a/app/javascript/mastodon/features/list_adder/components/account.jsx
+++ b/app/javascript/mastodon/features/list_adder/components/account.jsx
@@ -21,7 +21,7 @@ const makeMapStateToProps = () => {
class Account extends ImmutablePureComponent {
static propTypes = {
- account: ImmutablePropTypes.map.isRequired,
+ account: ImmutablePropTypes.record.isRequired,
};
render () {
diff --git a/app/javascript/mastodon/features/list_editor/components/account.jsx b/app/javascript/mastodon/features/list_editor/components/account.jsx
index f38c7d93a7c8f6..18d5e905cbc0b7 100644
--- a/app/javascript/mastodon/features/list_editor/components/account.jsx
+++ b/app/javascript/mastodon/features/list_editor/components/account.jsx
@@ -39,7 +39,7 @@ const mapDispatchToProps = (dispatch, { accountId }) => ({
class Account extends ImmutablePureComponent {
static propTypes = {
- account: ImmutablePropTypes.map.isRequired,
+ account: ImmutablePropTypes.record.isRequired,
intl: PropTypes.object.isRequired,
onRemove: PropTypes.func.isRequired,
onAdd: PropTypes.func.isRequired,
diff --git a/app/javascript/mastodon/features/notifications/components/follow_request.jsx b/app/javascript/mastodon/features/notifications/components/follow_request.jsx
index c10633beebcc76..03420b6c01e6e8 100644
--- a/app/javascript/mastodon/features/notifications/components/follow_request.jsx
+++ b/app/javascript/mastodon/features/notifications/components/follow_request.jsx
@@ -22,7 +22,7 @@ const messages = defineMessages({
class FollowRequest extends ImmutablePureComponent {
static propTypes = {
- account: ImmutablePropTypes.map.isRequired,
+ account: ImmutablePropTypes.record.isRequired,
onAuthorize: PropTypes.func.isRequired,
onReject: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
diff --git a/app/javascript/mastodon/features/notifications/components/report.jsx b/app/javascript/mastodon/features/notifications/components/report.jsx
index cb50b62cdc2ad1..52d6bfee9dbd84 100644
--- a/app/javascript/mastodon/features/notifications/components/report.jsx
+++ b/app/javascript/mastodon/features/notifications/components/report.jsx
@@ -20,7 +20,7 @@ const messages = defineMessages({
class Report extends ImmutablePureComponent {
static propTypes = {
- account: ImmutablePropTypes.map.isRequired,
+ account: ImmutablePropTypes.record.isRequired,
report: ImmutablePropTypes.map.isRequired,
hidden: PropTypes.bool,
intl: PropTypes.object.isRequired,
diff --git a/app/javascript/mastodon/features/onboarding/index.jsx b/app/javascript/mastodon/features/onboarding/index.jsx
index 7b8a41faa59f13..51d4b71f246404 100644
--- a/app/javascript/mastodon/features/onboarding/index.jsx
+++ b/app/javascript/mastodon/features/onboarding/index.jsx
@@ -46,7 +46,7 @@ const mapStateToProps = () => {
class Onboarding extends ImmutablePureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
- account: ImmutablePropTypes.map,
+ account: ImmutablePropTypes.record,
...WithRouterPropTypes,
};
diff --git a/app/javascript/mastodon/features/onboarding/share.jsx b/app/javascript/mastodon/features/onboarding/share.jsx
index 8e01701eb3f394..33492442238188 100644
--- a/app/javascript/mastodon/features/onboarding/share.jsx
+++ b/app/javascript/mastodon/features/onboarding/share.jsx
@@ -145,7 +145,7 @@ class Share extends PureComponent {
static propTypes = {
onBack: PropTypes.func,
- account: ImmutablePropTypes.map,
+ account: ImmutablePropTypes.record,
intl: PropTypes.object,
};
diff --git a/app/javascript/mastodon/features/picture_in_picture/components/header.jsx b/app/javascript/mastodon/features/picture_in_picture/components/header.jsx
index b7fd9d1276f24e..80a13bd2e35b81 100644
--- a/app/javascript/mastodon/features/picture_in_picture/components/header.jsx
+++ b/app/javascript/mastodon/features/picture_in_picture/components/header.jsx
@@ -27,7 +27,7 @@ class Header extends ImmutablePureComponent {
static propTypes = {
accountId: PropTypes.string.isRequired,
statusId: PropTypes.string.isRequired,
- account: ImmutablePropTypes.map.isRequired,
+ account: ImmutablePropTypes.record.isRequired,
onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
diff --git a/app/javascript/mastodon/features/report/thanks.jsx b/app/javascript/mastodon/features/report/thanks.jsx
index 146d4b3897673e..904c4477013154 100644
--- a/app/javascript/mastodon/features/report/thanks.jsx
+++ b/app/javascript/mastodon/features/report/thanks.jsx
@@ -20,7 +20,7 @@ class Thanks extends PureComponent {
static propTypes = {
submitted: PropTypes.bool,
onClose: PropTypes.func.isRequired,
- account: ImmutablePropTypes.map.isRequired,
+ account: ImmutablePropTypes.record.isRequired,
dispatch: PropTypes.func.isRequired,
};
diff --git a/app/javascript/mastodon/features/ui/components/focal_point_modal.jsx b/app/javascript/mastodon/features/ui/components/focal_point_modal.jsx
index 40481e2c8767dd..5353ebdb8aa404 100644
--- a/app/javascript/mastodon/features/ui/components/focal_point_modal.jsx
+++ b/app/javascript/mastodon/features/ui/components/focal_point_modal.jsx
@@ -110,7 +110,7 @@ class FocalPointModal extends ImmutablePureComponent {
static propTypes = {
media: ImmutablePropTypes.map.isRequired,
- account: ImmutablePropTypes.map.isRequired,
+ account: ImmutablePropTypes.record.isRequired,
isUploadingThumbnail: PropTypes.bool,
onSave: PropTypes.func.isRequired,
onChangeDescription: PropTypes.func.isRequired,
diff --git a/app/javascript/mastodon/features/ui/components/report_modal.jsx b/app/javascript/mastodon/features/ui/components/report_modal.jsx
index 2b6f04207ebdd1..3fd8ff127d8789 100644
--- a/app/javascript/mastodon/features/ui/components/report_modal.jsx
+++ b/app/javascript/mastodon/features/ui/components/report_modal.jsx
@@ -41,7 +41,7 @@ class ReportModal extends ImmutablePureComponent {
statusId: PropTypes.string,
dispatch: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
- account: ImmutablePropTypes.map.isRequired,
+ account: ImmutablePropTypes.record.isRequired,
};
state = {
diff --git a/app/javascript/mastodon/initial_state.js b/app/javascript/mastodon/initial_state.js
index 598e7337480d65..b02469f993348b 100644
--- a/app/javascript/mastodon/initial_state.js
+++ b/app/javascript/mastodon/initial_state.js
@@ -1,43 +1,5 @@
// @ts-check
-/**
- * @typedef Emoji
- * @property {string} shortcode
- * @property {string} static_url
- * @property {string} url
- */
-
-/**
- * @typedef AccountField
- * @property {string} name
- * @property {string} value
- * @property {string} verified_at
- */
-
-/**
- * @typedef Account
- * @property {string} acct
- * @property {string} avatar
- * @property {string} avatar_static
- * @property {boolean} bot
- * @property {string} created_at
- * @property {boolean=} discoverable
- * @property {string} display_name
- * @property {Emoji[]} emojis
- * @property {AccountField[]} fields
- * @property {number} followers_count
- * @property {number} following_count
- * @property {boolean} group
- * @property {string} header
- * @property {string} header_static
- * @property {string} id
- * @property {string=} last_status_at
- * @property {boolean} locked
- * @property {string} note
- * @property {number} statuses_count
- * @property {string} url
- * @property {string} username
- */
/**
* @typedef {[code: string, name: string, localName: string]} InitialStateLanguage
@@ -85,7 +47,7 @@
/**
* @typedef InitialState
- * @property {Record} accounts
+ * @property {Record} accounts
* @property {InitialStateLanguage[]} languages
* @property {boolean=} critical_updates_pending
* @property {InitialStateMeta} meta
diff --git a/app/javascript/mastodon/locales/de.json b/app/javascript/mastodon/locales/de.json
index 76b37ef44b06df..3fcab71d708115 100644
--- a/app/javascript/mastodon/locales/de.json
+++ b/app/javascript/mastodon/locales/de.json
@@ -425,7 +425,7 @@
"notification.admin.report": "{name} meldete {target}",
"notification.admin.sign_up": "{name} registrierte sich",
"notification.favourite": "{name} favorisierte deinen Beitrag",
- "notification.follow": "{name} folgt dir jetzt",
+ "notification.follow": "{name} folgt dir",
"notification.follow_request": "{name} möchte dir folgen",
"notification.mention": "{name} erwähnte dich",
"notification.own_poll": "Deine Umfrage ist beendet",
diff --git a/app/javascript/mastodon/locales/global_locale.ts b/app/javascript/mastodon/locales/global_locale.ts
index 2d4329c7645023..8d142b8b049224 100644
--- a/app/javascript/mastodon/locales/global_locale.ts
+++ b/app/javascript/mastodon/locales/global_locale.ts
@@ -1,3 +1,5 @@
+import { isDevelopment } from 'mastodon/utils/environment';
+
export interface LocaleData {
locale: string;
messages: Record;
@@ -11,7 +13,7 @@ export function setLocale(locale: LocaleData) {
export function getLocale(): LocaleData {
if (!loadedLocale) {
- if (process.env.NODE_ENV === 'development') {
+ if (isDevelopment()) {
throw new Error('getLocale() called before any locale has been set');
} else {
return { locale: 'unknown', messages: {} };
diff --git a/app/javascript/mastodon/locales/he.json b/app/javascript/mastodon/locales/he.json
index 01618118a316c5..a8cb2ec27663b3 100644
--- a/app/javascript/mastodon/locales/he.json
+++ b/app/javascript/mastodon/locales/he.json
@@ -202,7 +202,7 @@
"dismissable_banner.community_timeline": "אלו הם החצרוצים הציבוריים האחרונים מהמשתמשים על שרת {domain}.",
"dismissable_banner.dismiss": "בטל",
"dismissable_banner.explore_links": "אלו הקישורים האחרונים ששותפו על ידי משתמשים ששרת זה רואה ברשת המבוזרת כרגע.",
- "dismissable_banner.explore_statuses": "ההודעות האלו, משרת זה ואחרים ברשת המבוזרת, צוברים חשיפה היום. הודעות חדשות יותר עם יותר הדהודים וחיבובים מדורגות גבוה יותר.",
+ "dismissable_banner.explore_statuses": "אלו הודעות משרת זה ואחרים ברשת המבוזרת שצוברות חשיפה היום. הודעות חדשות יותר עם יותר הדהודים וחיבובים מדורגות גבוה יותר.",
"dismissable_banner.explore_tags": "התגיות האלו, משרת זה ואחרים ברשת המבוזרת, צוברות חשיפה כעת.",
"dismissable_banner.public_timeline": "אלו ההודעות האחרונות שהתקבלו מהמשתמשים שנעקבים על ידי משתמשים מ־{domain}.",
"embed.instructions": "ניתן להטמיע את ההודעה הזו באתרך ע\"י העתקת הקוד שלהלן.",
@@ -630,7 +630,7 @@
"status.edited": "נערך ב{date}",
"status.edited_x_times": "נערך {count, plural, one {פעם {count}} other {{count} פעמים}}",
"status.embed": "הטמעה",
- "status.favourite": "מחובבת",
+ "status.favourite": "חיבוב",
"status.filter": "סנן הודעה זו",
"status.filtered": "סונן",
"status.hide": "הסתרת חיצרוץ",
diff --git a/app/javascript/mastodon/locales/hr.json b/app/javascript/mastodon/locales/hr.json
index 4d751cdda7d8e9..cb1b201a123f61 100644
--- a/app/javascript/mastodon/locales/hr.json
+++ b/app/javascript/mastodon/locales/hr.json
@@ -1,5 +1,6 @@
{
"about.contact": "Kontakt:",
+ "about.domain_blocks.no_reason_available": "Razlog nije dostupan",
"account.account_note_header": "Bilješka",
"account.add_or_remove_from_list": "Dodaj ili ukloni s liste",
"account.badges.bot": "Bot",
@@ -14,6 +15,7 @@
"account.edit_profile": "Uredi profil",
"account.enable_notifications": "Obavjesti me kada @{name} napravi objavu",
"account.endorse": "Istakni na profilu",
+ "account.featured_tags.last_status_never": "Nema postova",
"account.follow": "Prati",
"account.followers": "Pratitelji",
"account.followers.empty": "Nitko još ne prati korisnika/cu.",
@@ -21,13 +23,18 @@
"account.following_counter": "{count, plural, one {{counter} praćeni} few{{counter} praćena} other {{counter} praćenih}}",
"account.follows.empty": "Korisnik/ca još ne prati nikoga.",
"account.follows_you": "Prati te",
+ "account.go_to_profile": "Idi na profil",
"account.hide_reblogs": "Sakrij boostove od @{name}",
+ "account.in_memoriam": "U sjećanje.",
"account.link_verified_on": "Vlasništvo ove poveznice provjereno je {date}",
"account.locked_info": "Status privatnosti ovog računa postavljen je na zaključano. Vlasnik ručno pregledava tko ih može pratiti.",
"account.media": "Medijski sadržaj",
"account.mention": "Spomeni @{name}",
"account.mute": "Utišaj @{name}",
+ "account.mute_notifications_short": "Utišaj obavijesti",
+ "account.mute_short": "Utišaj",
"account.muted": "Utišano",
+ "account.open_original_page": "Otvori originalnu stranicu",
"account.posts": "Objave",
"account.posts_with_replies": "Objave i odgovori",
"account.report": "Prijavi @{name}",
@@ -52,6 +59,7 @@
"alert.unexpected.title": "Ups!",
"announcement.announcement": "Najava",
"attachments_list.unprocessed": "(neobrađeno)",
+ "audio.hide": "Sakrij audio",
"autosuggest_hashtag.per_week": "{count} tjedno",
"boost_modal.combo": "Možete pritisnuti {combo} kako biste preskočili ovo sljedeći put",
"bundle_column_error.error.title": "Oh, ne!",
@@ -66,6 +74,7 @@
"column.community": "Lokalna vremenska crta",
"column.directory": "Pregledavanje profila",
"column.domain_blocks": "Blokirane domene",
+ "column.favourites": "Favoriti",
"column.follow_requests": "Zahtjevi za praćenje",
"column.home": "Početna",
"column.lists": "Liste",
@@ -86,6 +95,8 @@
"community.column_settings.remote_only": "Samo udaljeno",
"compose.language.change": "Promijeni jezik",
"compose.language.search": "Pretraži jezike...",
+ "compose.published.open": "Otvori",
+ "compose.saved.body": "Post spremljen.",
"compose_form.direct_message_warning_learn_more": "Saznajte više",
"compose_form.encryption_warning": "Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.",
"compose_form.hashtag_warning": "This post won't be listed under any hashtag as it is unlisted. Only public posts can be searched by hashtag.",
@@ -179,6 +190,8 @@
"errors.unexpected_crash.copy_stacktrace": "Kopiraj stacktrace u međuspremnik",
"errors.unexpected_crash.report_issue": "Prijavi problem",
"explore.search_results": "Rezultati pretrage",
+ "explore.suggested_follows": "Ljudi",
+ "explore.title": "Pretraži",
"explore.trending_links": "Novosti",
"explore.trending_statuses": "Objave",
"explore.trending_tags": "Hashtagovi",
@@ -189,12 +202,17 @@
"filter_modal.select_filter.subtitle": "Odaberite postojeću kategoriju ili stvorite novu",
"filter_modal.select_filter.title": "Filtriraj ovu objavu",
"filter_modal.title.status": "Filtriraj objavu",
+ "firehose.all": "Sve",
+ "firehose.local": "Ovaj server",
"follow_request.authorize": "Autoriziraj",
"follow_request.reject": "Odbij",
+ "footer.about": "O aplikaciji",
"footer.get_app": "Preuzmi aplikaciju",
+ "footer.invite": "Pozovi ljude",
"footer.keyboard_shortcuts": "Tipkovni prečaci",
"footer.privacy_policy": "Pravila o zaštiti privatnosti",
"footer.source_code": "Prikaz izvornog koda",
+ "footer.status": "Stanje",
"generic.saved": "Spremljeno",
"getting_started.heading": "Počnimo",
"hashtag.column_header.tag_mode.all": "i {additional}",
@@ -212,7 +230,11 @@
"home.column_settings.show_reblogs": "Pokaži boostove",
"home.column_settings.show_replies": "Pokaži odgovore",
"home.hide_announcements": "Sakrij najave",
+ "home.pending_critical_update.title": "Dostupno je kritično sigurnosno ažuriranje!",
"home.show_announcements": "Prikaži najave",
+ "interaction_modal.login.action": "Odvedi me kući",
+ "interaction_modal.no_account_yet": "Nisi na Mastodonu?",
+ "interaction_modal.on_this_server": "Na ovom serveru",
"intervals.full.days": "{number, plural, one {# dan} other {# dana}}",
"intervals.full.hours": "{number, plural, one {# sat} few {# sata} other {# sati}}",
"intervals.full.minutes": "{number, plural, one {# minuta} few {# minute} other {# minuta}}",
diff --git a/app/javascript/mastodon/locales/intl_provider.tsx b/app/javascript/mastodon/locales/intl_provider.tsx
index 4fa8b2247c7cb2..68d4fcbd965191 100644
--- a/app/javascript/mastodon/locales/intl_provider.tsx
+++ b/app/javascript/mastodon/locales/intl_provider.tsx
@@ -2,12 +2,14 @@ import { useEffect, useState } from 'react';
import { IntlProvider as BaseIntlProvider } from 'react-intl';
+import { isProduction } from 'mastodon/utils/environment';
+
import { getLocale, isLocaleLoaded } from './global_locale';
import { loadLocale } from './load_locale';
function onProviderError(error: unknown) {
// Silent the error, like upstream does
- if (process.env.NODE_ENV === 'production') return;
+ if (isProduction()) return;
// This browser does not advertise Intl support for this locale, we only print a warning
// As-per the spec, the browser should select the best matching locale
diff --git a/app/javascript/mastodon/locales/sl.json b/app/javascript/mastodon/locales/sl.json
index 3841237f47cc9a..63507be791c919 100644
--- a/app/javascript/mastodon/locales/sl.json
+++ b/app/javascript/mastodon/locales/sl.json
@@ -61,12 +61,12 @@
"account.requested_follow": "{name} vam želi slediti",
"account.share": "Deli profil osebe @{name}",
"account.show_reblogs": "Pokaži izpostavitve osebe @{name}",
- "account.statuses_counter": "{count, plural, one {{count} tut} two {{count} tuta} few {{count} tuti} other {{count} tutov}}",
+ "account.statuses_counter": "{count, plural, one {{count} objava} two {{count} objavi} few {{count} objave} other {{count} objav}}",
"account.unblock": "Odblokiraj @{name}",
"account.unblock_domain": "Odblokiraj domeno {domain}",
"account.unblock_short": "Odblokiraj",
"account.unendorse": "Ne vključi v profil",
- "account.unfollow": "Prenehaj slediti",
+ "account.unfollow": "Ne sledi več",
"account.unmute": "Odtišaj @{name}",
"account.unmute_notifications_short": "Izklopi utišanje obvestil",
"account.unmute_short": "Odtišaj",
@@ -185,7 +185,7 @@
"confirmations.redraft.message": "Ali ste prepričani, da želite izbrisati ta status in ga preoblikovati? Vzljubi in izpostavitve bodo izgubljeni, odgovori na izvirno objavo pa bodo osiroteli.",
"confirmations.reply.confirm": "Odgovori",
"confirmations.reply.message": "Odgovarjanje bo prepisalo sporočilo, ki ga trenutno sestavljate. Ali ste prepričani, da želite nadaljevati?",
- "confirmations.unfollow.confirm": "Prenehaj slediti",
+ "confirmations.unfollow.confirm": "Ne sledi več",
"confirmations.unfollow.message": "Ali ste prepričani, da ne želite več slediti {name}?",
"conversation.delete": "Izbriši pogovor",
"conversation.mark_as_read": "Označi kot prebrano",
@@ -301,7 +301,7 @@
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} objava} two {{counter} objavi} few {{counter} objav} other {{counter} objav}}",
"hashtag.follow": "Sledi ključniku",
"hashtag.unfollow": "Nehaj slediti ključniku",
- "hashtags.and_other": "…and {count, plural, one {} two {# več} few {# več}other {# več}}",
+ "hashtags.and_other": "…in še {count, plural, other {#}}",
"home.actions.go_to_explore": "Poglejte, kaj je v trendu",
"home.actions.go_to_suggestions": "Poiščite osebe, ki jim želite slediti",
"home.column_settings.basic": "Osnovno",
diff --git a/app/javascript/mastodon/main.jsx b/app/javascript/mastodon/main.jsx
index cd73cb572e1d12..e7979d56a1c8c8 100644
--- a/app/javascript/mastodon/main.jsx
+++ b/app/javascript/mastodon/main.jsx
@@ -7,6 +7,8 @@ import * as perf from 'mastodon/performance';
import ready from 'mastodon/ready';
import { store } from 'mastodon/store';
+import { isProduction } from './utils/environment';
+
/**
* @returns {Promise}
*/
@@ -21,7 +23,7 @@ function main() {
root.render();
store.dispatch(setupBrowserNotifications());
- if (process.env.NODE_ENV === 'production' && me && 'serviceWorker' in navigator) {
+ if (isProduction() && me && 'serviceWorker' in navigator) {
const { Workbox } = await import('workbox-window');
const wb = new Workbox('/sw.js');
/** @type {ServiceWorkerRegistration} */
diff --git a/app/javascript/mastodon/models/account.ts b/app/javascript/mastodon/models/account.ts
new file mode 100644
index 00000000000000..f20d2a2d3e1561
--- /dev/null
+++ b/app/javascript/mastodon/models/account.ts
@@ -0,0 +1,149 @@
+import type { RecordOf } from 'immutable';
+import { List, Record as ImmutableRecord } from 'immutable';
+
+import escapeTextContentForBrowser from 'escape-html';
+
+import type {
+ ApiAccountFieldJSON,
+ ApiAccountRoleJSON,
+ ApiAccountJSON,
+} from 'mastodon/api_types/accounts';
+import type { ApiCustomEmojiJSON } from 'mastodon/api_types/custom_emoji';
+import emojify from 'mastodon/features/emoji/emoji';
+import { unescapeHTML } from 'mastodon/utils/html';
+
+import { CustomEmojiFactory } from './custom_emoji';
+import type { CustomEmoji } from './custom_emoji';
+
+// AccountField
+interface AccountFieldShape extends Required {
+ name_emojified: string;
+ value_emojified: string;
+ value_plain: string | null;
+}
+
+type AccountField = RecordOf;
+
+const AccountFieldFactory = ImmutableRecord({
+ name: '',
+ value: '',
+ verified_at: null,
+ name_emojified: '',
+ value_emojified: '',
+ value_plain: null,
+});
+
+// AccountRole
+export type AccountRoleShape = ApiAccountRoleJSON;
+export type AccountRole = RecordOf;
+
+const AccountRoleFactory = ImmutableRecord({
+ color: '',
+ id: '',
+ name: '',
+});
+
+// Account
+export interface AccountShape
+ extends Required<
+ Omit
+ > {
+ emojis: List;
+ fields: List;
+ roles: List;
+ display_name_html: string;
+ note_emojified: string;
+ note_plain: string | null;
+ hidden: boolean;
+ moved: string | null;
+}
+
+export type Account = RecordOf;
+
+export const accountDefaultValues: AccountShape = {
+ acct: '',
+ avatar: '',
+ avatar_static: '',
+ bot: false,
+ created_at: '',
+ discoverable: false,
+ display_name: '',
+ display_name_html: '',
+ emojis: List(),
+ fields: List(),
+ group: false,
+ header: '',
+ header_static: '',
+ id: '',
+ last_status_at: '',
+ locked: false,
+ noindex: false,
+ note: '',
+ note_emojified: '',
+ note_plain: 'string',
+ roles: List(),
+ uri: '',
+ url: '',
+ username: '',
+ followers_count: 0,
+ following_count: 0,
+ statuses_count: 0,
+ hidden: false,
+ suspended: false,
+ memorial: false,
+ limited: false,
+ moved: null,
+};
+
+const AccountFactory = ImmutableRecord(accountDefaultValues);
+
+type EmojiMap = Record;
+
+function makeEmojiMap(emojis: ApiCustomEmojiJSON[]) {
+ return emojis.reduce((obj, emoji) => {
+ obj[`:${emoji.shortcode}:`] = emoji;
+ return obj;
+ }, {});
+}
+
+function createAccountField(
+ jsonField: ApiAccountFieldJSON,
+ emojiMap: EmojiMap,
+) {
+ return AccountFieldFactory({
+ ...jsonField,
+ name_emojified: emojify(
+ escapeTextContentForBrowser(jsonField.name),
+ emojiMap,
+ ),
+ value_emojified: emojify(jsonField.value, emojiMap),
+ value_plain: unescapeHTML(jsonField.value),
+ });
+}
+
+export function createAccountFromServerJSON(serverJSON: ApiAccountJSON) {
+ const { moved, ...accountJSON } = serverJSON;
+
+ const emojiMap = makeEmojiMap(accountJSON.emojis);
+
+ const displayName =
+ accountJSON.display_name.trim().length === 0
+ ? accountJSON.username
+ : accountJSON.display_name;
+
+ return AccountFactory({
+ ...accountJSON,
+ moved: moved?.id,
+ fields: List(
+ serverJSON.fields.map((field) => createAccountField(field, emojiMap)),
+ ),
+ emojis: List(serverJSON.emojis.map((emoji) => CustomEmojiFactory(emoji))),
+ roles: List(serverJSON.roles?.map((role) => AccountRoleFactory(role))),
+ display_name_html: emojify(
+ escapeTextContentForBrowser(displayName),
+ emojiMap,
+ ),
+ note_emojified: emojify(accountJSON.note, emojiMap),
+ note_plain: unescapeHTML(accountJSON.note),
+ });
+}
diff --git a/app/javascript/mastodon/models/custom_emoji.ts b/app/javascript/mastodon/models/custom_emoji.ts
new file mode 100644
index 00000000000000..76479f3aebf54e
--- /dev/null
+++ b/app/javascript/mastodon/models/custom_emoji.ts
@@ -0,0 +1,15 @@
+import type { RecordOf } from 'immutable';
+import { Record } from 'immutable';
+
+import type { ApiCustomEmojiJSON } from 'mastodon/api_types/custom_emoji';
+
+type CustomEmojiShape = Required; // no changes from server shape
+export type CustomEmoji = RecordOf;
+
+export const CustomEmojiFactory = Record({
+ shortcode: '',
+ static_url: '',
+ url: '',
+ category: '',
+ visible_in_picker: false,
+});
diff --git a/app/javascript/mastodon/models/relationship.ts b/app/javascript/mastodon/models/relationship.ts
new file mode 100644
index 00000000000000..115b2787382a88
--- /dev/null
+++ b/app/javascript/mastodon/models/relationship.ts
@@ -0,0 +1,29 @@
+import type { RecordOf } from 'immutable';
+import { Record } from 'immutable';
+
+import type { ApiRelationshipJSON } from 'mastodon/api_types/relationships';
+
+type RelationshipShape = Required; // no changes from server shape
+export type Relationship = RecordOf;
+
+const RelationshipFactory = Record({
+ blocked_by: false,
+ blocking: false,
+ domain_blocking: false,
+ endorsed: false,
+ followed_by: false,
+ following: false,
+ id: '',
+ languages: null,
+ muting_notifications: false,
+ muting: false,
+ note: '',
+ notifying: false,
+ requested_by: false,
+ requested: false,
+ showing_reblogs: false,
+});
+
+export function createRelationship(attributes: Partial) {
+ return RelationshipFactory(attributes);
+}
diff --git a/app/javascript/mastodon/performance.js b/app/javascript/mastodon/performance.js
index 42849c82b10378..3bca95e85e63d6 100644
--- a/app/javascript/mastodon/performance.js
+++ b/app/javascript/mastodon/performance.js
@@ -5,7 +5,9 @@
import * as marky from 'marky';
-if (process.env.NODE_ENV === 'development') {
+import { isDevelopment } from './utils/environment';
+
+if (isDevelopment()) {
if (typeof performance !== 'undefined' && performance.setResourceTimingBufferSize) {
// Increase Firefox's performance entry limit; otherwise it's capped to 150.
// See: https://bugzilla.mozilla.org/show_bug.cgi?id=1331135
@@ -18,13 +20,13 @@ if (process.env.NODE_ENV === 'development') {
}
export function start(name) {
- if (process.env.NODE_ENV === 'development') {
+ if (isDevelopment()) {
marky.mark(name);
}
}
export function stop(name) {
- if (process.env.NODE_ENV === 'development') {
+ if (isDevelopment()) {
marky.stop(name);
}
}
diff --git a/app/javascript/mastodon/polyfills/base_polyfills.ts b/app/javascript/mastodon/polyfills/base_polyfills.ts
deleted file mode 100644
index 71565236cd00c3..00000000000000
--- a/app/javascript/mastodon/polyfills/base_polyfills.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import 'core-js/features/object/assign';
-import 'core-js/features/object/values';
-import 'core-js/features/symbol';
-import 'core-js/features/promise/finally';
-import { decode as decodeBase64 } from '../utils/base64';
-
-if (!Object.hasOwn(HTMLCanvasElement.prototype, 'toBlob')) {
- const BASE64_MARKER = ';base64,';
-
- Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', {
- value: function (
- this: HTMLCanvasElement,
- callback: BlobCallback,
- type = 'image/png',
- quality: unknown,
- ) {
- const dataURL: string = this.toDataURL(type, quality);
- let data;
-
- if (dataURL.includes(BASE64_MARKER)) {
- const [, base64] = dataURL.split(BASE64_MARKER);
- data = decodeBase64(base64);
- } else {
- [, data] = dataURL.split(',');
- }
-
- callback(new Blob([data], { type }));
- },
- });
-}
diff --git a/app/javascript/mastodon/polyfills/extra_polyfills.ts b/app/javascript/mastodon/polyfills/extra_polyfills.ts
index e6c69de8b557b7..a8d5530c5fcb6a 100644
--- a/app/javascript/mastodon/polyfills/extra_polyfills.ts
+++ b/app/javascript/mastodon/polyfills/extra_polyfills.ts
@@ -1,2 +1 @@
-import 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only';
import 'requestidlecallback';
diff --git a/app/javascript/mastodon/polyfills/index.ts b/app/javascript/mastodon/polyfills/index.ts
index e166c09d0e8b23..431c5b0f30f350 100644
--- a/app/javascript/mastodon/polyfills/index.ts
+++ b/app/javascript/mastodon/polyfills/index.ts
@@ -4,39 +4,18 @@
import { loadIntlPolyfills } from './intl';
-function importBasePolyfills() {
- return import(/* webpackChunkName: "base_polyfills" */ './base_polyfills');
-}
-
function importExtraPolyfills() {
return import(/* webpackChunkName: "extra_polyfills" */ './extra_polyfills');
}
export function loadPolyfills() {
- const needsBasePolyfills = !(
- 'toBlob' in HTMLCanvasElement.prototype &&
- 'assign' in Object &&
- 'values' in Object &&
- 'Symbol' in window &&
- 'finally' in Promise.prototype
- );
-
- // Latest version of Firefox and Safari do not have IntersectionObserver.
- // Edge does not have requestIdleCallback.
+ // Safari does not have requestIdleCallback.
// This avoids shipping them all the polyfills.
- /* eslint-disable @typescript-eslint/no-unnecessary-condition -- those properties might not exist in old browsers, even if they are always here in types */
- const needsExtraPolyfills = !(
- window.AbortController &&
- window.IntersectionObserver &&
- window.IntersectionObserverEntry &&
- 'isIntersecting' in IntersectionObserverEntry.prototype &&
- window.requestIdleCallback
- );
- /* eslint-enable @typescript-eslint/no-unnecessary-condition */
+ const needsExtraPolyfills = !window.requestIdleCallback;
return Promise.all([
loadIntlPolyfills(),
- needsBasePolyfills && importBasePolyfills(),
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- those properties might not exist in old browsers, even if they are always here in types
needsExtraPolyfills && importExtraPolyfills(),
]);
}
diff --git a/app/javascript/mastodon/reducers/accounts.js b/app/javascript/mastodon/reducers/accounts.js
deleted file mode 100644
index 76122cc63baa9f..00000000000000
--- a/app/javascript/mastodon/reducers/accounts.js
+++ /dev/null
@@ -1,39 +0,0 @@
-import { Map as ImmutableMap, fromJS } from 'immutable';
-
-import { ACCOUNT_REVEAL } from 'mastodon/actions/accounts';
-import { ACCOUNT_IMPORT, ACCOUNTS_IMPORT } from 'mastodon/actions/importer';
-
-const initialState = ImmutableMap();
-
-const normalizeAccount = (state, account) => {
- account = { ...account };
-
- delete account.followers_count;
- delete account.following_count;
- delete account.statuses_count;
-
- account.hidden = state.getIn([account.id, 'hidden']) === false ? false : account.limited;
-
- return state.set(account.id, fromJS(account));
-};
-
-const normalizeAccounts = (state, accounts) => {
- accounts.forEach(account => {
- state = normalizeAccount(state, account);
- });
-
- return state;
-};
-
-export default function accounts(state = initialState, action) {
- switch(action.type) {
- case ACCOUNT_IMPORT:
- return normalizeAccount(state, action.account);
- case ACCOUNTS_IMPORT:
- return normalizeAccounts(state, action.accounts);
- case ACCOUNT_REVEAL:
- return state.setIn([action.id, 'hidden'], false);
- default:
- return state;
- }
-}
diff --git a/app/javascript/mastodon/reducers/accounts.ts b/app/javascript/mastodon/reducers/accounts.ts
new file mode 100644
index 00000000000000..f7270eb60a574a
--- /dev/null
+++ b/app/javascript/mastodon/reducers/accounts.ts
@@ -0,0 +1,84 @@
+import { Map as ImmutableMap } from 'immutable';
+
+import type { Reducer } from 'redux';
+
+import {
+ followAccountSuccess,
+ unfollowAccountSuccess,
+ importAccounts,
+ revealAccount,
+} from 'mastodon/actions/accounts_typed';
+import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
+import { me } from 'mastodon/initial_state';
+import type { Account } from 'mastodon/models/account';
+import { createAccountFromServerJSON } from 'mastodon/models/account';
+
+const initialState = ImmutableMap();
+
+const normalizeAccount = (
+ state: typeof initialState,
+ account: ApiAccountJSON,
+) => {
+ return state.set(
+ account.id,
+ createAccountFromServerJSON(account).set(
+ 'hidden',
+ state.get(account.id)?.hidden === false
+ ? false
+ : account.limited || false,
+ ),
+ );
+};
+
+const normalizeAccounts = (
+ state: typeof initialState,
+ accounts: ApiAccountJSON[],
+) => {
+ accounts.forEach((account) => {
+ state = normalizeAccount(state, account);
+ });
+
+ return state;
+};
+
+function getCurrentUser() {
+ if (!me)
+ throw new Error(
+ 'No current user (me) defined when calling `accountsReducer`',
+ );
+
+ return me;
+}
+
+export const accountsReducer: Reducer = (
+ state = initialState,
+ action,
+) => {
+ if (revealAccount.match(action))
+ return state.setIn([action.payload.id, 'hidden'], false);
+ else if (importAccounts.match(action))
+ return normalizeAccounts(state, action.payload.accounts);
+ else if (followAccountSuccess.match(action)) {
+ return state
+ .update(
+ action.payload.relationship.id,
+ (account) => account?.update('followers_count', (n) => n + 1),
+ )
+ .update(
+ getCurrentUser(),
+ (account) => account?.update('following_count', (n) => n + 1),
+ );
+ } else if (unfollowAccountSuccess.match(action))
+ return state
+ .update(
+ action.payload.relationship.id,
+ (account) =>
+ account?.update('followers_count', (n) => Math.max(0, n - 1)),
+ )
+ .update(
+ getCurrentUser(),
+ (account) =>
+ account?.update('following_count', (n) => Math.max(0, n - 1)),
+ );
+ else return state;
+};
diff --git a/app/javascript/mastodon/reducers/accounts_counters.js b/app/javascript/mastodon/reducers/accounts_counters.js
deleted file mode 100644
index eb7878deb9a632..00000000000000
--- a/app/javascript/mastodon/reducers/accounts_counters.js
+++ /dev/null
@@ -1,49 +0,0 @@
-import { Map as ImmutableMap, fromJS } from 'immutable';
-
-import { me } from 'mastodon/initial_state';
-
-import {
- ACCOUNT_FOLLOW_SUCCESS,
- ACCOUNT_UNFOLLOW_SUCCESS,
-} from '../actions/accounts';
-import { ACCOUNT_IMPORT, ACCOUNTS_IMPORT } from '../actions/importer';
-
-const normalizeAccount = (state, account) => state.set(account.id, fromJS({
- followers_count: account.followers_count,
- following_count: account.following_count,
- statuses_count: account.statuses_count,
-}));
-
-const normalizeAccounts = (state, accounts) => {
- accounts.forEach(account => {
- state = normalizeAccount(state, account);
- });
-
- return state;
-};
-
-const incrementFollowers = (state, accountId) =>
- state.updateIn([accountId, 'followers_count'], num => num + 1)
- .updateIn([me, 'following_count'], num => num + 1);
-
-const decrementFollowers = (state, accountId) =>
- state.updateIn([accountId, 'followers_count'], num => Math.max(0, num - 1))
- .updateIn([me, 'following_count'], num => Math.max(0, num - 1));
-
-const initialState = ImmutableMap();
-
-export default function accountsCounters(state = initialState, action) {
- switch(action.type) {
- case ACCOUNT_IMPORT:
- return normalizeAccount(state, action.account);
- case ACCOUNTS_IMPORT:
- return normalizeAccounts(state, action.accounts);
- case ACCOUNT_FOLLOW_SUCCESS:
- return action.alreadyFollowing ? state :
- incrementFollowers(state, action.relationship.id);
- case ACCOUNT_UNFOLLOW_SUCCESS:
- return decrementFollowers(state, action.relationship.id);
- default:
- return state;
- }
-}
diff --git a/app/javascript/mastodon/reducers/accounts_map.js b/app/javascript/mastodon/reducers/accounts_map.js
index fca0e3ce1e3bc8..d5ecad7dbfaddf 100644
--- a/app/javascript/mastodon/reducers/accounts_map.js
+++ b/app/javascript/mastodon/reducers/accounts_map.js
@@ -1,7 +1,7 @@
import { Map as ImmutableMap } from 'immutable';
import { ACCOUNT_LOOKUP_FAIL } from '../actions/accounts';
-import { ACCOUNT_IMPORT, ACCOUNTS_IMPORT } from '../actions/importer';
+import { importAccounts } from '../actions/accounts_typed';
export const normalizeForLookup = str => str.toLowerCase();
@@ -11,10 +11,8 @@ export default function accountsMap(state = initialState, action) {
switch(action.type) {
case ACCOUNT_LOOKUP_FAIL:
return action.error?.response?.status === 404 ? state.set(normalizeForLookup(action.acct), null) : state;
- case ACCOUNT_IMPORT:
- return state.set(normalizeForLookup(action.account.acct), action.account.id);
- case ACCOUNTS_IMPORT:
- return state.withMutations(map => action.accounts.forEach(account => map.set(normalizeForLookup(account.acct), account.id)));
+ case importAccounts.type:
+ return state.withMutations(map => action.payload.accounts.forEach(account => map.set(normalizeForLookup(account.acct), account.id)));
default:
return state;
}
diff --git a/app/javascript/mastodon/reducers/contexts.js b/app/javascript/mastodon/reducers/contexts.js
index 32e194dd42568c..f7d7419a4e3ab9 100644
--- a/app/javascript/mastodon/reducers/contexts.js
+++ b/app/javascript/mastodon/reducers/contexts.js
@@ -1,8 +1,8 @@
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import {
- ACCOUNT_BLOCK_SUCCESS,
- ACCOUNT_MUTE_SUCCESS,
+ blockAccountSuccess,
+ muteAccountSuccess,
} from '../actions/accounts';
import { CONTEXT_FETCH_SUCCESS } from '../actions/statuses';
import { TIMELINE_DELETE, TIMELINE_UPDATE } from '../actions/timelines';
@@ -92,9 +92,9 @@ const updateContext = (state, status) => {
export default function replies(state = initialState, action) {
switch(action.type) {
- case ACCOUNT_BLOCK_SUCCESS:
- case ACCOUNT_MUTE_SUCCESS:
- return filterContexts(state, action.relationship, action.statuses);
+ case blockAccountSuccess.type:
+ case muteAccountSuccess.type:
+ return filterContexts(state, action.payload.relationship, action.payload.statuses);
case CONTEXT_FETCH_SUCCESS:
return normalizeContext(state, action.id, action.ancestors, action.descendants);
case TIMELINE_DELETE:
diff --git a/app/javascript/mastodon/reducers/conversations.js b/app/javascript/mastodon/reducers/conversations.js
index 247e8a5977cd5c..3e99e680e3e7a2 100644
--- a/app/javascript/mastodon/reducers/conversations.js
+++ b/app/javascript/mastodon/reducers/conversations.js
@@ -1,7 +1,7 @@
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
-import { ACCOUNT_BLOCK_SUCCESS, ACCOUNT_MUTE_SUCCESS } from 'mastodon/actions/accounts';
-import { DOMAIN_BLOCK_SUCCESS } from 'mastodon/actions/domain_blocks';
+import { blockAccountSuccess, muteAccountSuccess } from 'mastodon/actions/accounts';
+import { blockDomainSuccess } from 'mastodon/actions/domain_blocks';
import {
CONVERSATIONS_MOUNT,
@@ -105,11 +105,11 @@ export default function conversations(state = initialState, action) {
return item;
}));
- case ACCOUNT_BLOCK_SUCCESS:
- case ACCOUNT_MUTE_SUCCESS:
- return filterConversations(state, [action.relationship.id]);
- case DOMAIN_BLOCK_SUCCESS:
- return filterConversations(state, action.accounts);
+ case blockAccountSuccess.type:
+ case muteAccountSuccess.type:
+ return filterConversations(state, [action.payload.relationship.id]);
+ case blockDomainSuccess.type:
+ return filterConversations(state, action.payload.accounts);
case CONVERSATIONS_DELETE_SUCCESS:
return state.update('items', list => list.filterNot(item => item.get('id') === action.id));
default:
diff --git a/app/javascript/mastodon/reducers/domain_lists.js b/app/javascript/mastodon/reducers/domain_lists.js
index 8cdd3ba3764e55..5f63c77f5d4200 100644
--- a/app/javascript/mastodon/reducers/domain_lists.js
+++ b/app/javascript/mastodon/reducers/domain_lists.js
@@ -3,7 +3,7 @@ import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutabl
import {
DOMAIN_BLOCKS_FETCH_SUCCESS,
DOMAIN_BLOCKS_EXPAND_SUCCESS,
- DOMAIN_UNBLOCK_SUCCESS,
+ unblockDomainSuccess
} from '../actions/domain_blocks';
const initialState = ImmutableMap({
@@ -18,8 +18,8 @@ export default function domainLists(state = initialState, action) {
return state.setIn(['blocks', 'items'], ImmutableOrderedSet(action.domains)).setIn(['blocks', 'next'], action.next);
case DOMAIN_BLOCKS_EXPAND_SUCCESS:
return state.updateIn(['blocks', 'items'], set => set.union(action.domains)).setIn(['blocks', 'next'], action.next);
- case DOMAIN_UNBLOCK_SUCCESS:
- return state.updateIn(['blocks', 'items'], set => set.delete(action.domain));
+ case unblockDomainSuccess.type:
+ return state.updateIn(['blocks', 'items'], set => set.delete(action.payload.domain));
default:
return state;
}
diff --git a/app/javascript/mastodon/reducers/index.ts b/app/javascript/mastodon/reducers/index.ts
index 722f04f3700d92..ecef63387377b5 100644
--- a/app/javascript/mastodon/reducers/index.ts
+++ b/app/javascript/mastodon/reducers/index.ts
@@ -3,8 +3,7 @@ import { Record as ImmutableRecord } from 'immutable';
import { loadingBarReducer } from 'react-redux-loading-bar';
import { combineReducers } from 'redux-immutable';
-import accounts from './accounts';
-import accounts_counters from './accounts_counters';
+import { accountsReducer } from './accounts';
import accounts_map from './accounts_map';
import alerts from './alerts';
import announcements from './announcements';
@@ -32,7 +31,7 @@ import notifications from './notifications';
import picture_in_picture from './picture_in_picture';
import polls from './polls';
import push_notifications from './push_notifications';
-import relationships from './relationships';
+import { relationshipsReducer } from './relationships';
import search from './search';
import server from './server';
import settings from './settings';
@@ -55,11 +54,10 @@ const reducers = {
user_lists,
domain_lists,
status_lists,
- accounts,
- accounts_counters,
+ accounts: accountsReducer,
accounts_map,
statuses,
- relationships,
+ relationships: relationshipsReducer,
settings,
push_notifications,
mutes,
diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js
index 66870bec551a6b..4a7a822a35e951 100644
--- a/app/javascript/mastodon/reducers/notifications.js
+++ b/app/javascript/mastodon/reducers/notifications.js
@@ -1,12 +1,12 @@
import { fromJS, Map as ImmutableMap, List as ImmutableList } from 'immutable';
-import { DOMAIN_BLOCK_SUCCESS } from 'mastodon/actions/domain_blocks';
+import { blockDomainSuccess } from 'mastodon/actions/domain_blocks';
import {
- ACCOUNT_BLOCK_SUCCESS,
- ACCOUNT_MUTE_SUCCESS,
- FOLLOW_REQUEST_AUTHORIZE_SUCCESS,
- FOLLOW_REQUEST_REJECT_SUCCESS,
+ authorizeFollowRequestSuccess,
+ blockAccountSuccess,
+ muteAccountSuccess,
+ rejectFollowRequestSuccess,
} from '../actions/accounts';
import {
focusApp,
@@ -16,7 +16,7 @@ import {
MARKERS_FETCH_SUCCESS,
} from '../actions/markers';
import {
- NOTIFICATIONS_UPDATE,
+ notificationsUpdate,
NOTIFICATIONS_EXPAND_SUCCESS,
NOTIFICATIONS_EXPAND_REQUEST,
NOTIFICATIONS_EXPAND_FAIL,
@@ -274,19 +274,19 @@ export default function notifications(state = initialState, action) {
return state.set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('hasMore', true);
case NOTIFICATIONS_SCROLL_TOP:
return updateTop(state, action.top);
- case NOTIFICATIONS_UPDATE:
- return normalizeNotification(state, action.notification, action.usePendingItems);
+ case notificationsUpdate.type:
+ return normalizeNotification(state, action.payload.notification, action.payload.usePendingItems);
case NOTIFICATIONS_EXPAND_SUCCESS:
return expandNormalizedNotifications(state, action.notifications, action.next, action.isLoadingMore, action.isLoadingRecent, action.usePendingItems);
- case ACCOUNT_BLOCK_SUCCESS:
- return filterNotifications(state, [action.relationship.id]);
- case ACCOUNT_MUTE_SUCCESS:
- return action.relationship.muting_notifications ? filterNotifications(state, [action.relationship.id]) : state;
- case DOMAIN_BLOCK_SUCCESS:
- return filterNotifications(state, action.accounts);
- case FOLLOW_REQUEST_AUTHORIZE_SUCCESS:
- case FOLLOW_REQUEST_REJECT_SUCCESS:
- return filterNotifications(state, [action.id], 'follow_request');
+ case blockAccountSuccess.type:
+ return filterNotifications(state, [action.payload.relationship.id]);
+ case muteAccountSuccess.type:
+ return action.relationship.muting_notifications ? filterNotifications(state, [action.payload.relationship.id]) : state;
+ case blockDomainSuccess.type:
+ return filterNotifications(state, action.payload.accounts);
+ case authorizeFollowRequestSuccess.type:
+ case rejectFollowRequestSuccess.type:
+ return filterNotifications(state, [action.payload.id], 'follow_request');
case NOTIFICATIONS_CLEAR:
return state.set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('hasMore', false);
case TIMELINE_DELETE:
diff --git a/app/javascript/mastodon/reducers/relationships.js b/app/javascript/mastodon/reducers/relationships.js
deleted file mode 100644
index 32b4b4f371b209..00000000000000
--- a/app/javascript/mastodon/reducers/relationships.js
+++ /dev/null
@@ -1,88 +0,0 @@
-import { Map as ImmutableMap, fromJS } from 'immutable';
-
-import {
- submitAccountNote,
-} from '../actions/account_notes';
-import {
- ACCOUNT_FOLLOW_SUCCESS,
- ACCOUNT_FOLLOW_REQUEST,
- ACCOUNT_FOLLOW_FAIL,
- ACCOUNT_UNFOLLOW_SUCCESS,
- ACCOUNT_UNFOLLOW_REQUEST,
- ACCOUNT_UNFOLLOW_FAIL,
- ACCOUNT_BLOCK_SUCCESS,
- ACCOUNT_UNBLOCK_SUCCESS,
- ACCOUNT_MUTE_SUCCESS,
- ACCOUNT_UNMUTE_SUCCESS,
- ACCOUNT_PIN_SUCCESS,
- ACCOUNT_UNPIN_SUCCESS,
- RELATIONSHIPS_FETCH_SUCCESS,
- FOLLOW_REQUEST_AUTHORIZE_SUCCESS,
- FOLLOW_REQUEST_REJECT_SUCCESS,
-} from '../actions/accounts';
-import {
- DOMAIN_BLOCK_SUCCESS,
- DOMAIN_UNBLOCK_SUCCESS,
-} from '../actions/domain_blocks';
-import {
- NOTIFICATIONS_UPDATE,
-} from '../actions/notifications';
-
-
-const normalizeRelationship = (state, relationship) => state.set(relationship.id, fromJS(relationship));
-
-const normalizeRelationships = (state, relationships) => {
- relationships.forEach(relationship => {
- state = normalizeRelationship(state, relationship);
- });
-
- return state;
-};
-
-const setDomainBlocking = (state, accounts, blocking) => {
- return state.withMutations(map => {
- accounts.forEach(id => {
- map.setIn([id, 'domain_blocking'], blocking);
- });
- });
-};
-
-const initialState = ImmutableMap();
-
-export default function relationships(state = initialState, action) {
- switch(action.type) {
- case FOLLOW_REQUEST_AUTHORIZE_SUCCESS:
- return state.setIn([action.id, 'followed_by'], true).setIn([action.id, 'requested_by'], false);
- case FOLLOW_REQUEST_REJECT_SUCCESS:
- return state.setIn([action.id, 'followed_by'], false).setIn([action.id, 'requested_by'], false);
- case NOTIFICATIONS_UPDATE:
- return action.notification.type === 'follow_request' ? state.setIn([action.notification.account.id, 'requested_by'], true) : state;
- case ACCOUNT_FOLLOW_REQUEST:
- return state.getIn([action.id, 'following']) ? state : state.setIn([action.id, action.locked ? 'requested' : 'following'], true);
- case ACCOUNT_FOLLOW_FAIL:
- return state.setIn([action.id, action.locked ? 'requested' : 'following'], false);
- case ACCOUNT_UNFOLLOW_REQUEST:
- return state.setIn([action.id, 'following'], false);
- case ACCOUNT_UNFOLLOW_FAIL:
- return state.setIn([action.id, 'following'], true);
- case ACCOUNT_FOLLOW_SUCCESS:
- case ACCOUNT_UNFOLLOW_SUCCESS:
- case ACCOUNT_BLOCK_SUCCESS:
- case ACCOUNT_UNBLOCK_SUCCESS:
- case ACCOUNT_MUTE_SUCCESS:
- case ACCOUNT_UNMUTE_SUCCESS:
- case ACCOUNT_PIN_SUCCESS:
- case ACCOUNT_UNPIN_SUCCESS:
- return normalizeRelationship(state, action.relationship);
- case RELATIONSHIPS_FETCH_SUCCESS:
- return normalizeRelationships(state, action.relationships);
- case submitAccountNote.fulfilled:
- return normalizeRelationship(state, action.payload.relationship);
- case DOMAIN_BLOCK_SUCCESS:
- return setDomainBlocking(state, action.accounts, true);
- case DOMAIN_UNBLOCK_SUCCESS:
- return setDomainBlocking(state, action.accounts, false);
- default:
- return state;
- }
-}
diff --git a/app/javascript/mastodon/reducers/relationships.ts b/app/javascript/mastodon/reducers/relationships.ts
new file mode 100644
index 00000000000000..2ba61839c7bffa
--- /dev/null
+++ b/app/javascript/mastodon/reducers/relationships.ts
@@ -0,0 +1,123 @@
+import { Map as ImmutableMap } from 'immutable';
+
+import { isFulfilled } from '@reduxjs/toolkit';
+import type { Reducer } from 'redux';
+
+import type { ApiRelationshipJSON } from 'mastodon/api_types/relationships';
+import type { Account } from 'mastodon/models/account';
+import { createRelationship } from 'mastodon/models/relationship';
+import type { Relationship } from 'mastodon/models/relationship';
+
+import { submitAccountNote } from '../actions/account_notes';
+import {
+ followAccountSuccess,
+ unfollowAccountSuccess,
+ authorizeFollowRequestSuccess,
+ rejectFollowRequestSuccess,
+ followAccountRequest,
+ followAccountFail,
+ unfollowAccountRequest,
+ unfollowAccountFail,
+ blockAccountSuccess,
+ unblockAccountSuccess,
+ muteAccountSuccess,
+ unmuteAccountSuccess,
+ pinAccountSuccess,
+ unpinAccountSuccess,
+ fetchRelationshipsSuccess,
+} from '../actions/accounts_typed';
+import {
+ blockDomainSuccess,
+ unblockDomainSuccess,
+} from '../actions/domain_blocks_typed';
+import { notificationsUpdate } from '../actions/notifications_typed';
+
+const initialState = ImmutableMap();
+type State = typeof initialState;
+
+const normalizeRelationship = (
+ state: State,
+ relationship: ApiRelationshipJSON,
+) => state.set(relationship.id, createRelationship(relationship));
+
+const normalizeRelationships = (
+ state: State,
+ relationships: ApiRelationshipJSON[],
+) => {
+ relationships.forEach((relationship) => {
+ state = normalizeRelationship(state, relationship);
+ });
+
+ return state;
+};
+
+const setDomainBlocking = (
+ state: State,
+ accounts: Account[],
+ blocking: boolean,
+) => {
+ return state.withMutations((map) => {
+ accounts.forEach((id) => {
+ map.setIn([id, 'domain_blocking'], blocking);
+ });
+ });
+};
+
+export const relationshipsReducer: Reducer = (
+ state = initialState,
+ action,
+) => {
+ if (authorizeFollowRequestSuccess.match(action))
+ return state
+ .setIn([action.payload.id, 'followed_by'], true)
+ .setIn([action.payload.id, 'requested_by'], false);
+ else if (rejectFollowRequestSuccess.match(action))
+ return state
+ .setIn([action.payload.id, 'followed_by'], false)
+ .setIn([action.payload.id, 'requested_by'], false);
+ else if (notificationsUpdate.match(action))
+ return action.payload.notification.type === 'follow_request'
+ ? state.setIn(
+ [action.payload.notification.account.id, 'requested_by'],
+ true,
+ )
+ : state;
+ else if (followAccountRequest.match(action))
+ return state.getIn([action.payload.id, 'following'])
+ ? state
+ : state.setIn(
+ [
+ action.payload.id,
+ action.payload.locked ? 'requested' : 'following',
+ ],
+ true,
+ );
+ else if (followAccountFail.match(action))
+ return state.setIn(
+ [action.payload.id, action.payload.locked ? 'requested' : 'following'],
+ false,
+ );
+ else if (unfollowAccountRequest.match(action))
+ return state.setIn([action.payload.id, 'following'], false);
+ else if (unfollowAccountFail.match(action))
+ return state.setIn([action.payload.id, 'following'], true);
+ else if (
+ followAccountSuccess.match(action) ||
+ unfollowAccountSuccess.match(action) ||
+ blockAccountSuccess.match(action) ||
+ unblockAccountSuccess.match(action) ||
+ muteAccountSuccess.match(action) ||
+ unmuteAccountSuccess.match(action) ||
+ pinAccountSuccess.match(action) ||
+ unpinAccountSuccess.match(action) ||
+ isFulfilled(submitAccountNote)(action)
+ )
+ return normalizeRelationship(state, action.payload.relationship);
+ else if (fetchRelationshipsSuccess.match(action))
+ return normalizeRelationships(state, action.payload.relationships);
+ else if (blockDomainSuccess.match(action))
+ return setDomainBlocking(state, action.payload.accounts, true);
+ else if (unblockDomainSuccess.match(action))
+ return setDomainBlocking(state, action.payload.accounts, false);
+ else return state;
+};
diff --git a/app/javascript/mastodon/reducers/status_lists.js b/app/javascript/mastodon/reducers/status_lists.js
index 41cc07341c4969..6cb6a937bb915e 100644
--- a/app/javascript/mastodon/reducers/status_lists.js
+++ b/app/javascript/mastodon/reducers/status_lists.js
@@ -1,8 +1,8 @@
import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable';
import {
- ACCOUNT_BLOCK_SUCCESS,
- ACCOUNT_MUTE_SUCCESS,
+ blockAccountSuccess,
+ muteAccountSuccess,
} from '../actions/accounts';
import {
BOOKMARKED_STATUSES_FETCH_REQUEST,
@@ -142,9 +142,9 @@ export default function statusLists(state = initialState, action) {
return prependOneToList(state, 'pins', action.status);
case UNPIN_SUCCESS:
return removeOneFromList(state, 'pins', action.status);
- case ACCOUNT_BLOCK_SUCCESS:
- case ACCOUNT_MUTE_SUCCESS:
- return state.updateIn(['trending', 'items'], ImmutableOrderedSet(), list => list.filterNot(statusId => action.statuses.getIn([statusId, 'account']) === action.relationship.id));
+ case blockAccountSuccess.type:
+ case muteAccountSuccess.type:
+ return state.updateIn(['trending', 'items'], ImmutableOrderedSet(), list => list.filterNot(statusId => action.payload.statuses.getIn([statusId, 'account']) === action.payload.relationship.id));
default:
return state;
}
diff --git a/app/javascript/mastodon/reducers/suggestions.js b/app/javascript/mastodon/reducers/suggestions.js
index ce1bcc774064dc..0f224ff4b94f34 100644
--- a/app/javascript/mastodon/reducers/suggestions.js
+++ b/app/javascript/mastodon/reducers/suggestions.js
@@ -1,7 +1,7 @@
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
-import { ACCOUNT_BLOCK_SUCCESS, ACCOUNT_MUTE_SUCCESS } from 'mastodon/actions/accounts';
-import { DOMAIN_BLOCK_SUCCESS } from 'mastodon/actions/domain_blocks';
+import { blockAccountSuccess, muteAccountSuccess } from 'mastodon/actions/accounts';
+import { blockDomainSuccess } from 'mastodon/actions/domain_blocks';
import {
SUGGESTIONS_FETCH_REQUEST,
@@ -29,11 +29,11 @@ export default function suggestionsReducer(state = initialState, action) {
return state.set('isLoading', false);
case SUGGESTIONS_DISMISS:
return state.update('items', list => list.filterNot(x => x.account === action.id));
- case ACCOUNT_BLOCK_SUCCESS:
- case ACCOUNT_MUTE_SUCCESS:
- return state.update('items', list => list.filterNot(x => x.account === action.relationship.id));
- case DOMAIN_BLOCK_SUCCESS:
- return state.update('items', list => list.filterNot(x => action.accounts.includes(x.account)));
+ case blockAccountSuccess.type:
+ case muteAccountSuccess.type:
+ return state.update('items', list => list.filterNot(x => x.account === action.payload.relationship.id));
+ case blockDomainSuccess.type:
+ return state.update('items', list => list.filterNot(x => action.payload.accounts.includes(x.account)));
default:
return state;
}
diff --git a/app/javascript/mastodon/reducers/timelines.js b/app/javascript/mastodon/reducers/timelines.js
index cb3da50727a6b4..43dedd6e6d8cc8 100644
--- a/app/javascript/mastodon/reducers/timelines.js
+++ b/app/javascript/mastodon/reducers/timelines.js
@@ -1,9 +1,9 @@
import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable';
import {
- ACCOUNT_BLOCK_SUCCESS,
- ACCOUNT_MUTE_SUCCESS,
- ACCOUNT_UNFOLLOW_SUCCESS,
+ blockAccountSuccess,
+ muteAccountSuccess,
+ unfollowAccountSuccess
} from '../actions/accounts';
import {
TIMELINE_UPDATE,
@@ -200,11 +200,11 @@ export default function timelines(state = initialState, action) {
return deleteStatus(state, action.id, action.references, action.reblogOf);
case TIMELINE_CLEAR:
return clearTimeline(state, action.timeline);
- case ACCOUNT_BLOCK_SUCCESS:
- case ACCOUNT_MUTE_SUCCESS:
- return filterTimelines(state, action.relationship, action.statuses);
- case ACCOUNT_UNFOLLOW_SUCCESS:
- return filterTimeline('home', state, action.relationship, action.statuses);
+ case blockAccountSuccess.type:
+ case muteAccountSuccess.type:
+ return filterTimelines(state, action.payload.relationship, action.payload.statuses);
+ case unfollowAccountSuccess.type:
+ return filterTimeline('home', state, action.payload.relationship, action.payload.statuses);
case TIMELINE_SCROLL_TOP:
return updateTop(state, action.timeline, action.top);
case TIMELINE_CONNECT:
diff --git a/app/javascript/mastodon/reducers/user_lists.js b/app/javascript/mastodon/reducers/user_lists.js
index 089899398ed872..2f17fed5fdb64a 100644
--- a/app/javascript/mastodon/reducers/user_lists.js
+++ b/app/javascript/mastodon/reducers/user_lists.js
@@ -33,8 +33,8 @@ import {
FOLLOW_REQUESTS_EXPAND_REQUEST,
FOLLOW_REQUESTS_EXPAND_SUCCESS,
FOLLOW_REQUESTS_EXPAND_FAIL,
- FOLLOW_REQUEST_AUTHORIZE_SUCCESS,
- FOLLOW_REQUEST_REJECT_SUCCESS,
+ authorizeFollowRequestSuccess,
+ rejectFollowRequestSuccess,
} from '../actions/accounts';
import {
BLOCKS_FETCH_REQUEST,
@@ -66,11 +66,7 @@ import {
MUTES_EXPAND_SUCCESS,
MUTES_EXPAND_FAIL,
} from '../actions/mutes';
-import {
- NOTIFICATIONS_UPDATE,
-} from '../actions/notifications';
-
-
+import { notificationsUpdate } from '../actions/notifications';
const initialListState = ImmutableMap({
next: null,
@@ -163,8 +159,8 @@ export default function userLists(state = initialState, action) {
case FAVOURITES_FETCH_FAIL:
case FAVOURITES_EXPAND_FAIL:
return state.setIn(['favourited_by', action.id, 'isLoading'], false);
- case NOTIFICATIONS_UPDATE:
- return action.notification.type === 'follow_request' ? normalizeFollowRequest(state, action.notification) : state;
+ case notificationsUpdate.type:
+ return action.payload.notification.type === 'follow_request' ? normalizeFollowRequest(state, action.payload.notification) : state;
case FOLLOW_REQUESTS_FETCH_SUCCESS:
return normalizeList(state, ['follow_requests'], action.accounts, action.next);
case FOLLOW_REQUESTS_EXPAND_SUCCESS:
@@ -175,9 +171,9 @@ export default function userLists(state = initialState, action) {
case FOLLOW_REQUESTS_FETCH_FAIL:
case FOLLOW_REQUESTS_EXPAND_FAIL:
return state.setIn(['follow_requests', 'isLoading'], false);
- case FOLLOW_REQUEST_AUTHORIZE_SUCCESS:
- case FOLLOW_REQUEST_REJECT_SUCCESS:
- return state.updateIn(['follow_requests', 'items'], list => list.filterNot(item => item === action.id));
+ case authorizeFollowRequestSuccess.type:
+ case rejectFollowRequestSuccess.type:
+ return state.updateIn(['follow_requests', 'items'], list => list.filterNot(item => item === action.payload.id));
case BLOCKS_FETCH_SUCCESS:
return normalizeList(state, ['blocks'], action.accounts, action.next);
case BLOCKS_EXPAND_SUCCESS:
diff --git a/app/javascript/mastodon/selectors/accounts.ts b/app/javascript/mastodon/selectors/accounts.ts
new file mode 100644
index 00000000000000..66193136c45012
--- /dev/null
+++ b/app/javascript/mastodon/selectors/accounts.ts
@@ -0,0 +1,47 @@
+import { Record as ImmutableRecord } from 'immutable';
+import { createSelector } from 'reselect';
+
+import { accountDefaultValues } from 'mastodon/models/account';
+import type { Account, AccountShape } from 'mastodon/models/account';
+import type { Relationship } from 'mastodon/models/relationship';
+import type { RootState } from 'mastodon/store';
+
+const getAccountBase = (state: RootState, id: string) =>
+ state.accounts.get(id, null);
+
+const getAccountRelationship = (state: RootState, id: string) =>
+ state.relationships.get(id, null);
+
+const getAccountMoved = (state: RootState, id: string) => {
+ const movedToId = state.accounts.get(id)?.moved;
+
+ if (!movedToId) return undefined;
+
+ return state.accounts.get(movedToId);
+};
+
+interface FullAccountShape extends Omit {
+ relationship: Relationship | null;
+ moved: Account | null;
+}
+
+const FullAccountFactory = ImmutableRecord({
+ ...accountDefaultValues,
+ moved: null,
+ relationship: null,
+});
+
+export function makeGetAccount() {
+ return createSelector(
+ [getAccountBase, getAccountRelationship, getAccountMoved],
+ (base, relationship, moved) => {
+ if (base === null) {
+ return null;
+ }
+
+ return FullAccountFactory(base)
+ .set('relationship', relationship)
+ .set('moved', moved ?? null);
+ },
+ );
+}
diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js
index 0968fb090b6cfd..8a07ba774d8e3c 100644
--- a/app/javascript/mastodon/selectors/index.js
+++ b/app/javascript/mastodon/selectors/index.js
@@ -5,23 +5,7 @@ import { toServerSideType } from 'mastodon/utils/filters';
import { me } from '../initial_state';
-const getAccountBase = (state, id) => state.getIn(['accounts', id], null);
-const getAccountCounters = (state, id) => state.getIn(['accounts_counters', id], null);
-const getAccountRelationship = (state, id) => state.getIn(['relationships', id], null);
-const getAccountMoved = (state, id) => state.getIn(['accounts', state.getIn(['accounts', id, 'moved'])]);
-
-export const makeGetAccount = () => {
- return createSelector([getAccountBase, getAccountCounters, getAccountRelationship, getAccountMoved], (base, counters, relationship, moved) => {
- if (base === null) {
- return null;
- }
-
- return base.merge(counters).withMutations(map => {
- map.set('relationship', relationship);
- map.set('moved', moved);
- });
- });
-};
+export { makeGetAccount } from "./accounts";
const getFilters = (state, { contextType }) => {
if (!contextType) return null;
diff --git a/app/javascript/mastodon/store/store.ts b/app/javascript/mastodon/store/store.ts
index 63508856803810..9f43f58a43dfa4 100644
--- a/app/javascript/mastodon/store/store.ts
+++ b/app/javascript/mastodon/store/store.ts
@@ -35,6 +35,5 @@ export const store = configureStore({
// Infer the `RootState` and `AppDispatch` types from the store itself
export type RootState = ReturnType;
-// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
export type AppDispatch = typeof store.dispatch;
export type GetState = typeof store.getState;
diff --git a/app/javascript/mastodon/utils/environment.ts b/app/javascript/mastodon/utils/environment.ts
new file mode 100644
index 00000000000000..b6371499f63694
--- /dev/null
+++ b/app/javascript/mastodon/utils/environment.ts
@@ -0,0 +1,7 @@
+export function isDevelopment() {
+ return process.env.NODE_ENV === 'development';
+}
+
+export function isProduction() {
+ return process.env.NODE_ENV === 'production';
+}
diff --git a/app/javascript/types/resources.ts b/app/javascript/types/resources.ts
deleted file mode 100644
index f3901ad150c9b9..00000000000000
--- a/app/javascript/types/resources.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import type { Record } from 'immutable';
-
-type CustomEmoji = Record<{
- shortcode: string;
- static_url: string;
- url: string;
-}>;
-
-type AccountField = Record<{
- name: string;
- value: string;
- verified_at: string | null;
-}>;
-
-interface AccountApiResponseValues {
- acct: string;
- avatar: string;
- avatar_static: string;
- bot: boolean;
- created_at: string;
- discoverable: boolean;
- display_name: string;
- emojis: CustomEmoji[];
- fields: AccountField[];
- followers_count: number;
- following_count: number;
- group: boolean;
- header: string;
- header_static: string;
- id: string;
- last_status_at: string;
- locked: boolean;
- note: string;
- statuses_count: number;
- url: string;
- uri: string;
- username: string;
-}
-
-type NormalizedAccountField = Record<{
- name_emojified: string;
- value_emojified: string;
- value_plain: string;
-}>;
-
-interface NormalizedAccountValues {
- display_name_html: string;
- fields: NormalizedAccountField[];
- note_emojified: string;
- note_plain: string;
-}
-
-export type Account = Record<
- AccountApiResponseValues & NormalizedAccountValues
->;
diff --git a/app/lib/activitypub/parser/status_parser.rb b/app/lib/activitypub/parser/status_parser.rb
index 75b8f3d5c3f870..510f00f075d6de 100644
--- a/app/lib/activitypub/parser/status_parser.rb
+++ b/app/lib/activitypub/parser/status_parser.rb
@@ -53,7 +53,8 @@ def title
end
def created_at
- @object['published']&.to_datetime
+ datetime = @object['published']&.to_datetime
+ datetime if datetime.present? && (0..9999).cover?(datetime.year)
rescue ArgumentError
nil
end
diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb
index 3b14a6748a7bf8..48167873e1058c 100644
--- a/app/services/fan_out_on_write_service.rb
+++ b/app/services/fan_out_on_write_service.rb
@@ -8,6 +8,7 @@ class FanOutOnWriteService < BaseService
# @param [Hash] options
# @option options [Boolean] update
# @option options [Array] silenced_account_ids
+ # @option options [Boolean] skip_notifications
def call(status, options = {})
@status = status
@account = status.account
@@ -37,8 +38,11 @@ def check_race_condition!
def fan_out_to_local_recipients!
deliver_to_self!
- notify_mentioned_accounts!
- notify_about_update! if update?
+
+ unless @options[:skip_notifications]
+ notify_mentioned_accounts!
+ notify_about_update! if update?
+ end
case @status.visibility.to_sym
when :public, :unlisted, :private
diff --git a/app/workers/thread_resolve_worker.rb b/app/workers/thread_resolve_worker.rb
index 3206c45f6399e5..d4cefb3fdc0627 100644
--- a/app/workers/thread_resolve_worker.rb
+++ b/app/workers/thread_resolve_worker.rb
@@ -7,13 +7,18 @@ class ThreadResolveWorker
sidekiq_options queue: 'pull', retry: 3
def perform(child_status_id, parent_url, options = {})
- child_status = Status.find(child_status_id)
- parent_status = FetchRemoteStatusService.new.call(parent_url, **options.deep_symbolize_keys)
+ child_status = Status.find(child_status_id)
+ return if child_status.in_reply_to_id.present?
+
+ parent_status = ActivityPub::TagManager.instance.uri_to_resource(parent_url, Status)
+ parent_status ||= FetchRemoteStatusService.new.call(parent_url, **options.deep_symbolize_keys)
return if parent_status.nil?
child_status.thread = parent_status
child_status.save!
+
+ DistributionWorker.perform_async(child_status_id, { 'skip_notifications' => true }) if child_status.within_realtime_window?
rescue ActiveRecord::RecordNotFound
true
end
diff --git a/babel.config.js b/babel.config.js
index f53e5918cbe089..9ced748a9615f7 100644
--- a/babel.config.js
+++ b/babel.config.js
@@ -7,8 +7,8 @@ module.exports = (api) => {
};
const envOptions = {
- loose: true,
- modules: false,
+ useBuiltIns: "usage",
+ corejs: { version: "3.30" },
debug: false,
include: [
'transform-numeric-separator',
@@ -18,29 +18,14 @@ module.exports = (api) => {
],
};
- const config = {
- presets: [
- '@babel/preset-typescript',
- ['@babel/react', reactOptions],
- ['@babel/env', envOptions],
- ],
- plugins: [
- ['formatjs'],
- 'preval',
- ],
- overrides: [
- {
- test: /tesseract\.js/,
- presets: [
- ['@babel/env', { ...envOptions, modules: 'commonjs' }],
- ],
- },
- ],
- };
+ const plugins = [
+ ['formatjs'],
+ 'preval',
+ ];
switch (env) {
case 'production':
- config.plugins.push(...[
+ plugins.push(...[
'lodash',
[
'transform-react-remove-prop-types',
@@ -63,14 +48,33 @@ module.exports = (api) => {
],
]);
break;
+
case 'development':
reactOptions.development = true;
envOptions.debug = true;
- break;
- case 'test':
- envOptions.modules = 'commonjs';
+
+ // We need Babel to not inject polyfills in dev, as this breaks `preval` files
+ envOptions.useBuiltIns = false;
+ envOptions.corejs = undefined;
break;
}
+ const config = {
+ presets: [
+ '@babel/preset-typescript',
+ ['@babel/react', reactOptions],
+ ['@babel/env', envOptions],
+ ],
+ plugins,
+ overrides: [
+ {
+ test: /tesseract\.js/,
+ presets: [
+ ['@babel/env', { ...envOptions, modules: 'commonjs' }],
+ ],
+ },
+ ],
+ };
+
return config;
};
diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb
index 41d0ee25b78642..2c6099623db501 100644
--- a/config/initializers/devise.rb
+++ b/config/initializers/devise.rb
@@ -2,6 +2,15 @@
require 'devise/strategies/authenticatable'
+# TODO: Remove this patch when this PR or similar is merged into Devise:
+# https://github.com/heartcombo/devise/pull/5645
+# We rely on ENV vars and not secrets/credentials, so the deprecation is just noise.
+class Devise::SecretKeyFinder
+ def find
+ @application.secret_key_base
+ end
+end
+
Warden::Manager.after_set_user except: :fetch do |user, warden|
session_id = warden.cookies.signed['_session_id'] || warden.raw_session['auth_id']
session_id = user.activate_session(warden.request) unless user.session_activations.active?(session_id)
diff --git a/config/initializers/json_ld.rb b/config/initializers/json_ld.rb
deleted file mode 100644
index 3ed3c4b31a31bb..00000000000000
--- a/config/initializers/json_ld.rb
+++ /dev/null
@@ -1,4 +0,0 @@
-# frozen_string_literal: true
-
-require_relative '../../lib/json_ld/security'
-require_relative '../../lib/json_ld/identity'
diff --git a/lib/json_ld/identity.rb b/config/initializers/json_ld_identity.rb
similarity index 100%
rename from lib/json_ld/identity.rb
rename to config/initializers/json_ld_identity.rb
diff --git a/lib/json_ld/security.rb b/config/initializers/json_ld_security.rb
similarity index 100%
rename from lib/json_ld/security.rb
rename to config/initializers/json_ld_security.rb
diff --git a/config/locales/ar.yml b/config/locales/ar.yml
index f8c74207de7434..f43d1ad2a67bad 100644
--- a/config/locales/ar.yml
+++ b/config/locales/ar.yml
@@ -578,6 +578,7 @@ ar:
total_reported: تقارير عنهم
total_storage: الوسائط المُرفَقة
totals_time_period_hint_html: المجموع المعروض في الأسفل يشمل بيانات منذ البداية.
+ unknown_instance: لا يوجد حاليا أي تسجيل لهذا النطاق على هذا الخادم.
invites:
deactivate_all: تعطيلها كافة
filter:
@@ -1112,6 +1113,11 @@ ar:
hint_html: شيء واحد آخر! نحن بحاجة إلى التأكّد من أنك إنسان (حتى نتمكن من تتفادي البريد المزعج!). حل رمز CAPTCHA أدناه وانقر فوق "متابعة".
title: التحقق من الأمان
confirmations:
+ awaiting_review: تمّ تأكيد عنوان بريدك الإلكتروني! موظفو %{domain} يعاينونَ تسجيلكَ حاليًا. ستتلقى بريدًا إلكترونيًا إن تَمّ قُبولك!
+ awaiting_review_title: التسجيل الخاص بك قيد المُعاينة
+ clicking_this_link: اضغط على هذا الرابط
+ login_link: تسجيل الدخول
+ proceed_to_login_html: يمكنكَ الآن الاستمرار إلى %{login_link}.
wrong_email_hint: إذا كان عنوان البريد الإلكتروني هذا غير صحيح، يمكنك تغييره في إعدادات الحساب.
delete_account: احذف الحساب
delete_account_html: إن كنت ترغب في حذف حسابك يُمكنك المواصلة هنا. سوف يُطلَبُ منك التأكيد قبل الحذف.
diff --git a/config/locales/cy.yml b/config/locales/cy.yml
index baa6d31e3580ba..846ce41def5bd9 100644
--- a/config/locales/cy.yml
+++ b/config/locales/cy.yml
@@ -1,7 +1,7 @@
---
cy:
about:
- about_mastodon_html: 'Rhwydwaith cymdeithasol y dyfodol: Dim hysbysebion, dim gwyliadwriaeth gorfforaethol, dylunio moesegol, a datganoli! Chi sy berchen eich data gyda Mastodon!'
+ about_mastodon_html: 'Rhwydwaith cymdeithasol y dyfodol: Dim hysbysebion, dim gwyliadwriaeth gorfforaethol, dylunio moesegol a datganoli! Chi sy berchen eich data gyda Mastodon!'
contact_missing: Heb ei osodF
contact_unavailable: Ddim yn berthnasol
hosted_on: Mastodon wedi ei weinyddu ar %{domain}
@@ -1113,6 +1113,14 @@ cy:
hint_html: Un peth arall! Mae angen i ni gadarnhau eich bod yn ddynol (mae hyn er mwyn i ni allu cadw'r sbam allan!). Datryswch y CAPTCHA isod a chliciwch "Parhau".
title: Gwiriad diogelwch
confirmations:
+ awaiting_review: Mae eich cyfeiriad e-bost wedi'i gadarnhau! Mae aelod o staff %{domain} wrthi'n adolygu'ch cofrestriad. Byddwch yn derbyn e-bost os ydyn nhw'n cymeradwyo eich cyfrif!
+ awaiting_review_title: Mae eich cofrestriad yn cael ei adolygu
+ clicking_this_link: clicio ar y ddolen hon
+ login_link: mewngofnodi
+ proceed_to_login_html: Gallwch nawr symud ymlaen i %{login_link}.
+ redirect_to_app_html: Dylech fod wedi cael eich ailgyfeirio i ap %{app_name}. Os na ddigwyddodd hynny, rhowch gynnig ar %{clicking_this_link} neu ewch eich hun i'r ap.
+ registration_complete: Mae eich cofrestriad ar %{domain} bellach wedi'i gwblhau!
+ welcome_title: Croeso, %{name}!
wrong_email_hint: Os nad yw'r cyfeiriad e-bost hwnnw'n gywir, gallwch ei newid yng ngosodiadau'r cyfrif.
delete_account: Dileu cyfrif
delete_account_html: Os hoffech chi ddileu eich cyfrif, mae modd parhau yma. Bydd gofyn i chi gadarnhau.
diff --git a/config/locales/doorkeeper.hr.yml b/config/locales/doorkeeper.hr.yml
index cb48de313549e1..abf07ab723628a 100644
--- a/config/locales/doorkeeper.hr.yml
+++ b/config/locales/doorkeeper.hr.yml
@@ -35,8 +35,11 @@ hr:
scopes: Odvojite opsege razmacima. Za korištenje zadanih opsega, ostavite prazno.
index:
application: Aplikacija
+ delete: Obriši
+ empty: Nemaš aplikacija.
name: Ime
new: Nova aplikacija
+ show: Prikaži
title: Vaše aplikacije
new:
title: Nova aplikacija
@@ -60,6 +63,7 @@ hr:
confirmations:
revoke: Jeste li sigurni?
index:
+ authorized_at: Autorizirano %{date}
title: Vaše autorizirane aplikacije
errors:
messages:
@@ -88,6 +92,14 @@ hr:
authorized_applications:
destroy:
notice: Aplikacija je opozvana.
+ grouped_scopes:
+ title:
+ favourites: Favoriti
+ filters: Filteri
+ follows: Praćeni
+ lists: Liste
+ search: Traži
+ statuses: Objave
layouts:
admin:
nav:
@@ -95,6 +107,7 @@ hr:
application:
title: Traži se OAuth autorizacija
scopes:
+ admin:read: pročitaj sve podatke na serveru
follow: mijenjati odnose između računa
read: čitati sve podatke Vašeg računa
write: mijenjati sve podatke Vašeg računa
diff --git a/config/locales/doorkeeper.sl.yml b/config/locales/doorkeeper.sl.yml
index 8fe39bbf61f903..a613308b28afbf 100644
--- a/config/locales/doorkeeper.sl.yml
+++ b/config/locales/doorkeeper.sl.yml
@@ -4,7 +4,7 @@ sl:
attributes:
doorkeeper/application:
name: Ime programa
- redirect_uri: Preusmeritev URI
+ redirect_uri: URI za preusmeritev
scopes: Obsegi
website: Spletišče programa
errors:
@@ -19,7 +19,7 @@ sl:
doorkeeper:
applications:
buttons:
- authorize: Overi
+ authorize: Odobri
cancel: Prekliči
destroy: Uniči
edit: Uredi
@@ -55,23 +55,23 @@ sl:
title: 'Program: %{name}'
authorizations:
buttons:
- authorize: Overi
+ authorize: Odobri
deny: Zavrni
error:
title: Prišlo je do napake
new:
prompt_html: "%{client_name} želi dovoljenje za dostop do vašega računa. Gre za zunanji program. Če mu ne zaupate, mu ne dodelite teh pravic."
- review_permissions: Preglej pravice
- title: Potrebna je pooblastitev
+ review_permissions: Preglej dovoljenja
+ title: Potrebna je odobritev
show:
- title: Kopirajte to pooblastilno kodo in jo prilepite v program.
+ title: Kopirajte to odobritveno kodo in jo prilepite v program.
authorized_applications:
buttons:
revoke: Prekliči
confirmations:
revoke: Ali ste prepričani?
index:
- authorized_at: Overjen(a) %{date}
+ authorized_at: Odobreno %{date}
description_html: To so programi, ki lahko dostopajo do vašega računa prek vmesnika API. Če so na seznamu programi, ki jih ne prepoznate ali pa se čudno vedejo, lahko prekličete njihovo pravico do dostopa.
last_used_at: Zadnjič uporabljeno %{date}
never_used: Nikoli uporabljeno
@@ -80,14 +80,14 @@ sl:
title: Vaši odobreni programi
errors:
messages:
- access_denied: Lastnik virov ali strežnik pooblastil je zavrnil zahtevo.
+ access_denied: Lastnik virov ali odobritveni strežnik je zavrnil zahtevo.
credential_flow_not_configured: Pretok geselskih pooblastil lastnika virov ni uspel, ker Doorkeeper.configure.resource_owner_from_credentials ni nastavljen.
- invalid_client: Overitev odjemalca ni uspela zaradi neznanega odjemalca, zaradi nevključitve overitve odjemalca ali zaradi nepodprte metode overitve.
- invalid_grant: Predložena odobritev za pooblastilo je neveljavna, potekla, preklicana, se ne ujema z URI preusmeritvijo, ki je uporabljena v zahtevi za pooblastilo ali je bila izdana drugemu odjemalcu.
+ invalid_client: Odobritev odjemalca ni uspela zaradi neznanega odjemalca, zaradi nevključitve odobritve odjemalca ali zaradi nepodprte metode odobritve.
+ invalid_grant: Predložena odobritev je neveljavna, je potekla, je preklicana, se ne ujema z URI-jem za preusmeritev uporabljenim v zahtevi za odobritev, ali pa je bila izdana drugemu odjemalcu.
invalid_redirect_uri: URI za preusmeritev ni veljaven.
invalid_request:
missing_param: 'Zahtevani parameter manjka: %{value}.'
- request_not_authorized: Zahtevo je potrebno overiti. Zahtevani parameter za overjanje zahteve manjka ali ni veljaven.
+ request_not_authorized: Zahtevo je potrebno odobriti. Zahtevani parameter za odobritev zahteve manjka ali ni veljaven.
unknown: Zahtevku manjka zahtevani parameter, vključuje nepodprto vrednost parametra ali je nepravilno oblikovan.
invalid_resource_owner: Predložene poverilnice lastnika virov niso veljavne ali pa lastnika virov ni mogoče najti
invalid_scope: Zahtevani obseg je neveljaven, neznan ali nepravilen.
@@ -96,11 +96,11 @@ sl:
revoked: Žeton za dostop je bil preklican
unknown: Žeton za dostop je neveljaven
resource_owner_authenticator_not_configured: Iskanje lastnika virov ni uspelo, ker Doorkeeper.configure.resource_owner_authenticator ni nastavljen.
- server_error: Strežnik pooblastil je naletel na nepričakovano stanje, ki je preprečilo, da bi izpolnil zahtevo.
- temporarily_unavailable: Strežnik pooblastil, zaradi začasne preobremenitve ali vzdrževanja, trenutno ne more obdelati zahteve.
- unauthorized_client: Odjemalec nima pooblastila za izvajanje te zahteve po tej metodi.
- unsupported_grant_type: Strežnik pooblastil ne podpira vrste odobritve pooblastila.
- unsupported_response_type: Strežnik pooblastil ne podpira te vrste odziva.
+ server_error: Odobritveni strežnik je naletel na nepričakovano stanje, ki je preprečilo, da bi izpolnil zahtevo.
+ temporarily_unavailable: Odobritveni strežnik zaradi začasne preobremenitve ali vzdrževanja trenutno ne more obdelati zahteve.
+ unauthorized_client: Odjemalec nima odobritve za izvajanje te zahteve po tej metodi.
+ unsupported_grant_type: Odobritveni strežnik ne podpira zahtevane vrste odobritve.
+ unsupported_response_type: Odobritveni strežnik pooblastil ne podpira te vrste odziva.
flash:
applications:
create:
@@ -145,7 +145,7 @@ sl:
applications: Programi
oauth2_provider: Ponudnik OAuth2
application:
- title: Potrebna je pooblastitev OAuth
+ title: Potrebna je odobritev OAuth
scopes:
admin:read: preberi vse podatke na strežniku
admin:read:accounts: preberi občutljive informacije vseh računov
diff --git a/config/locales/my.yml b/config/locales/my.yml
index 8e01e82afe60e7..d8e83543c691b7 100644
--- a/config/locales/my.yml
+++ b/config/locales/my.yml
@@ -523,6 +523,7 @@ my:
total_reported: "၎င်းတို့နှင့်ဆိုင်သော အစီရင်ခံစာများ"
total_storage: မီဒီယာ ပူးတွဲချက်များ
totals_time_period_hint_html: အောက်တွင်ဖော်ပြထားသော စုစုပေါင်းမှာ အချိန်တိုင်းအတွက် အချက်အလက်များဖြစ်သည်။
+ unknown_instance: လောလောဆယ် ဤဆာဗာတွင် ဤဒိုမိန်း၏ မှတ်တမ်းမရှိပါ။
invites:
deactivate_all: အားလုံးပယ်ဖျက်ရန်
filter:
@@ -1006,6 +1007,13 @@ my:
hint_html: နောက်ထပ်တစ်ခုသာ။ သင်သည် လူသားဖြစ်ကြောင်း ကျွန်ုပ်တို့ အတည်ပြုရန် လိုအပ်ပါသည် (စပမ်းများကို ရှောင်ရှားနိုင်စေရန် အတွက်ဖြစ်ပါသည်။) အောက်ပါ CAPTCHA ကိုဖြေရှင်းပြီး "Continue" ကို နှိပ်ပါ။
title: လုံခြုံရေးစစ်ဆေးမှု
confirmations:
+ awaiting_review: သင့်အီးမေးလ်လိပ်စာကို အတည်ပြုပြီးပါပြီ။ %{domain} စီမံသူများမှ ယခု သင်စာရင်းသွင်းခြင်းကို ပြန်လည်သုံးသပ်နေပါသည်။ သင့်အကောင့်ကို အတည်ပြုပါက အီးမေးလ်တစ်စောင် လက်ခံရရှိမည်ဖြစ်သည်။
+ awaiting_review_title: သင် စာရင်သွင်းထားခြင်းကို စစ်ဆေးနေပါသည်
+ clicking_this_link: ဤလင့်ခ်ကို နှိပ်ပါ
+ login_link: အကောင့်ဝင်ရန်
+ proceed_to_login_html: သင် ယခု %{login_link} သို့ ဆက်သွားနိုင်ပါပြီ။
+ registration_complete: "%{domain} တွင် သင် စာရင်းသွင်းခြင်းမှာ မှန်ကန်ပါပြီ။"
+ welcome_title: ကြိုဆိုပါတယ် %{name}။
wrong_email_hint: ထိုအီးမေးလ်လိပ်စာ မမှန်ပါက အကောင့်သတ်မှတ်ချက်များတွင် ပြောင်းလဲနိုင်သည်။
delete_account: အကောင့်ဖျက်ပါ
delete_account_html: သင့်အကောင့်ဖျက်လိုပါကဤနေရာတွင် ဆက်လက်လုပ်ဆောင်နိုင်သည်။ အတည်ပြုချက်တောင်းပါမည်။
@@ -1067,6 +1075,7 @@ my:
functional: သင့်အကောင့်မှာ အပြည့်အဝလုပ်ဆောင်နေပါပြီ။
pending: သင့်အက်ပလီကေးရှင်းကို ကျွန်ုပ်တို့၏ဝန်ထမ်းများမှ ပြန်လည်သုံးသပ်နေပါသည်။ အချိန်အနည်းငယ်ကြာနိုင်ပါသည်။ သင့်အက်ပလီကေးရှင်းကို အတည်ပြုပြီးပါက အီးမေးလ်တစ်စောင် သင် လက်ခံရရှိမည်ဖြစ်သည်။
redirecting_to: သင့်အကောင့်မှာ လက်ရှိတွင် %{acct} သို့ ပြန်ညွှန်းနေသောကြောင့် သုံးစွဲ၍မရပါ။
+ self_destruct: "%{domain} ပိတ်သွားသောကြောင့် သင့်အကောင့်သို့ အကန့်အသတ်ဖြင့်သာ ဝင်ရောက်နိုင်မည်ဖြစ်သည်။"
view_strikes: သင့်အကောင့်ကို ဆန့်ကျင်သည့် ယခင်ကလုပ်ဆောင်ချက်များကို ကြည့်ပါ
too_fast: ဖောင်တင်သည်မှာ မြန်နေပါသည်။ ထပ်စမ်းကြည့်ပါ။
use_security_key: လုံခြုံရေးကီးကို သုံးပါ
@@ -1525,6 +1534,8 @@ my:
over_daily_limit: ယနေ့အတွက် စီစဉ်ထားသည့် ပို့စ်များ၏ ကန့်သတ်ချက် %{limit} ကို ကျော်လွန်သွားပါပြီ
over_total_limit: စီစဉ်ထားသည့် ပို့စ်များ၏ ကန့်သတ်ချက် %{limit} ကို ကျော်လွန်သွားပါပြီ
too_soon: စီစဉ်ထားသောရက်စွဲမှာ အနာဂတ်အတွက်ဖြစ်သည်
+ self_destruct:
+ title: ဤဆာဗာ ပိတ်ထားပါသည်
sessions:
activity: နောက်ဆုံးလုပ်ဆောင်ချက်
browser: ဘရောက်ဇာ
diff --git a/config/locales/simple_form.sq.yml b/config/locales/simple_form.sq.yml
index 3256e7378dd915..dc07478070cc2a 100644
--- a/config/locales/simple_form.sq.yml
+++ b/config/locales/simple_form.sq.yml
@@ -256,8 +256,8 @@ sq:
site_contact_username: Emër përdoruesi kontakti
site_extended_description: Përshkrim i zgjeruar
site_short_description: Përshkrim shërbyesi
- site_terms: Rregulla Privatësie
- site_title: Emër shërbyesi
+ site_terms: Politika e privatësisë
+ site_title: Emri i serverit
status_page_url: URL faqeje gjendjesh
theme: Temë parazgjedhje
thumbnail: Miniaturë shërbyesi
diff --git a/config/locales/sk.yml b/config/locales/sk.yml
index f16803fb29aa64..b3bc8c2dcd0003 100644
--- a/config/locales/sk.yml
+++ b/config/locales/sk.yml
@@ -585,6 +585,7 @@ sk:
title: Serverové pravidlá
settings:
about:
+ manage_rules: Spravuj serverové pravidlá
title: Ohľadom
appearance:
title: Vzhľad
@@ -607,9 +608,16 @@ sk:
approved: Pre registráciu je nutné povolenie
none: Nikto sa nemôže registrovať
open: Ktokoľvek sa môže zaregistrovať
+ title: Nastavenia servera
site_uploads:
delete: Vymaž nahratý súbor
destroyed_msg: Nahratie bolo zo stránky úspešne vymazané!
+ software_updates:
+ documentation_link: Zisti viac
+ title: Dostupné aktualizácie
+ types:
+ major: Hlavné vydanie
+ version: Verzia
statuses:
account: Autor
application: Aplikácia
diff --git a/config/locales/uk.yml b/config/locales/uk.yml
index 8069c76c6d95c4..85db04715b5727 100644
--- a/config/locales/uk.yml
+++ b/config/locales/uk.yml
@@ -1077,12 +1077,12 @@ uk:
hint_html: Ще одне! Ми повинні пересвідчитись, що ви людина (щоб ми могли уникнути спаму!). Розв'яжіть CAPTCHA внизу і натисніть кнопку "Продовжити".
title: Перевірка безпеки
confirmations:
- awaiting_review: Ваша електронна адреса підтверджена! Співробітники %{domain} тепер переглядають вашу реєстрацію. Ви отримаєте електронного листа, якщо вони затвердять ваш обліковий запис!
+ awaiting_review: Ваша електронна адреса підтверджена! Наразі співробітники %{domain} розглядають вашу реєстрацію. Ви отримаєте електронний лист, якщо вони затвердять ваш обліковий запис!
awaiting_review_title: Ваша реєстрація розглядається
clicking_this_link: натисніть це посилання
login_link: увійти
proceed_to_login_html: Тепер ви можете перейти до %{login_link}.
- redirect_to_app_html: Ви мали бути перенаправлені до програми %{app_name}. Якщо цього не сталося, спробуйте %{clicking_this_link} або вручну поверніться до програми.
+ redirect_to_app_html: Вас мало переспрямувати до програми %{app_name}. Якщо цього не сталося, спробуйте %{clicking_this_link} або вручну поверніться до програми.
registration_complete: Ваша реєстрація на %{domain} завершена!
welcome_title: Ласкаво просимо, %{name}!
wrong_email_hint: Якщо ця адреса електронної пошти неправильна, можна змінити її в налаштуваннях облікового запису.
diff --git a/config/webpack/rules/index.js b/config/webpack/rules/index.js
index b026857887efbd..4be59f1b6423d5 100644
--- a/config/webpack/rules/index.js
+++ b/config/webpack/rules/index.js
@@ -2,7 +2,6 @@ const babel = require('./babel');
const css = require('./css');
const file = require('./file');
const materialIcons = require('./material_icons');
-const nodeModules = require('./node_modules');
const tesseract = require('./tesseract');
// Webpack loaders are processed in reverse order
@@ -13,6 +12,5 @@ module.exports = {
file,
tesseract,
css,
- nodeModules,
babel,
};
diff --git a/config/webpack/rules/node_modules.js b/config/webpack/rules/node_modules.js
deleted file mode 100644
index 89c9d422d3ee2a..00000000000000
--- a/config/webpack/rules/node_modules.js
+++ /dev/null
@@ -1,27 +0,0 @@
-const { join } = require('path');
-
-const { settings, env } = require('../configuration');
-
-module.exports = {
- test: /\.(js|mjs)$/,
- include: /node_modules/,
- exclude: [
- /@babel(?:\/|\\{1,2})runtime/,
- /tesseract.js/,
- ],
- use: [
- {
- loader: 'babel-loader',
- options: {
- babelrc: false,
- plugins: [
- 'transform-react-remove-prop-types',
- ],
- cacheDirectory: join(settings.cache_path, 'babel-loader-node-modules'),
- cacheCompression: env.NODE_ENV === 'production',
- compact: false,
- sourceMaps: false,
- },
- },
- ],
-};
diff --git a/config/webpack/tests.js b/config/webpack/tests.js
deleted file mode 100644
index e6a8f1c2a95f12..00000000000000
--- a/config/webpack/tests.js
+++ /dev/null
@@ -1,9 +0,0 @@
-// Note: You must restart bin/webpack-dev-server for changes to take effect
-
-const { merge } = require('webpack-merge');
-
-const sharedConfig = require('./shared');
-
-module.exports = merge(sharedConfig, {
- mode: 'production',
-});
diff --git a/package.json b/package.json
index 40578504e822bb..c5ad74c61cb10a 100644
--- a/package.json
+++ b/package.json
@@ -49,7 +49,6 @@
"@reduxjs/toolkit": "^1.9.5",
"@renchap/compression-webpack-plugin": "^6.1.4",
"@svgr/webpack": "^5.5.0",
- "abortcontroller-polyfill": "^1.7.5",
"atrament": "0.2.4",
"arrow-key-navigation": "^1.2.0",
"async-mutex": "^0.4.0",
diff --git a/spec/lib/activitypub/activity/create_spec.rb b/spec/lib/activitypub/activity/create_spec.rb
index d7c4c131a20500..5e92cb1d397e23 100644
--- a/spec/lib/activitypub/activity/create_spec.rb
+++ b/spec/lib/activitypub/activity/create_spec.rb
@@ -23,6 +23,109 @@
stub_request(:get, 'http://example.com/emojib.png').to_return(body: attachment_fixture('emojo.png'), headers: { 'Content-Type' => 'application/octet-stream' })
end
+ describe 'processing posts received out of order' do
+ let(:follower) { Fabricate(:account, username: 'bob') }
+
+ let(:object_json) do
+ {
+ id: [ActivityPub::TagManager.instance.uri_for(sender), 'post1'].join('/'),
+ type: 'Note',
+ to: [
+ 'https://www.w3.org/ns/activitystreams#Public',
+ ActivityPub::TagManager.instance.uri_for(follower),
+ ],
+ content: '@bob lorem ipsum',
+ published: 1.hour.ago.utc.iso8601,
+ updated: 1.hour.ago.utc.iso8601,
+ tag: {
+ type: 'Mention',
+ href: ActivityPub::TagManager.instance.uri_for(follower),
+ },
+ }
+ end
+
+ let(:reply_json) do
+ {
+ id: [ActivityPub::TagManager.instance.uri_for(sender), 'reply'].join('/'),
+ type: 'Note',
+ inReplyTo: object_json[:id],
+ to: [
+ 'https://www.w3.org/ns/activitystreams#Public',
+ ActivityPub::TagManager.instance.uri_for(follower),
+ ],
+ content: '@bob lorem ipsum',
+ published: Time.now.utc.iso8601,
+ updated: Time.now.utc.iso8601,
+ tag: {
+ type: 'Mention',
+ href: ActivityPub::TagManager.instance.uri_for(follower),
+ },
+ }
+ end
+
+ def activity_for_object(json)
+ {
+ '@context': 'https://www.w3.org/ns/activitystreams',
+ id: [json[:id], 'activity'].join('/'),
+ type: 'Create',
+ actor: ActivityPub::TagManager.instance.uri_for(sender),
+ object: json,
+ }.with_indifferent_access
+ end
+
+ before do
+ follower.follow!(sender)
+ end
+
+ around do |example|
+ Sidekiq::Testing.fake! do
+ example.run
+ Sidekiq::Worker.clear_all
+ end
+ end
+
+ it 'correctly processes posts and inserts them in timelines', :aggregate_failures do
+ # Simulate a temporary failure preventing from fetching the parent post
+ stub_request(:get, object_json[:id]).to_return(status: 500)
+
+ # When receiving the reply…
+ described_class.new(activity_for_object(reply_json), sender, delivery: true).perform
+
+ # NOTE: Refering explicitly to the workers is a bit awkward
+ DistributionWorker.drain
+ FeedInsertWorker.drain
+
+ # …it creates a status with an unknown parent
+ reply = Status.find_by(uri: reply_json[:id])
+ expect(reply.reply?).to be true
+ expect(reply.in_reply_to_id).to be_nil
+
+ # …and creates a notification
+ expect(LocalNotificationWorker.jobs.size).to eq 1
+
+ # …but does not insert it into timelines
+ expect(redis.zscore(FeedManager.instance.key(:home, follower.id), reply.id)).to be_nil
+
+ # When receiving the parent…
+ described_class.new(activity_for_object(object_json), sender, delivery: true).perform
+
+ Sidekiq::Worker.drain_all
+
+ # …it creates a status and insert it into timelines
+ parent = Status.find_by(uri: object_json[:id])
+ expect(parent.reply?).to be false
+ expect(parent.in_reply_to_id).to be_nil
+ expect(reply.reload.in_reply_to_id).to eq parent.id
+
+ # Check that the both statuses have been inserted into the home feed
+ expect(redis.zscore(FeedManager.instance.key(:home, follower.id), parent.id)).to be_within(0.1).of(parent.id.to_f)
+ expect(redis.zscore(FeedManager.instance.key(:home, follower.id), reply.id)).to be_within(0.1).of(reply.id.to_f)
+
+ # Creates two notifications
+ expect(Notification.count).to eq 2
+ end
+ end
+
describe '#perform' do
context 'when fetching' do
subject { described_class.new(json, sender) }
@@ -31,29 +134,67 @@
subject.perform
end
- context 'when object has been edited' do
+ context 'when object publication date is below ISO8601 range' do
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
content: 'Lorem ipsum',
- published: '2022-01-22T15:00:00Z',
- updated: '2022-01-22T16:00:00Z',
+ published: '-0977-11-03T08:31:22Z',
}
end
- it 'creates status' do
+ it 'creates status with a valid creation date', :aggregate_failures do
+ status = sender.statuses.first
+
+ expect(status).to_not be_nil
+ expect(status.text).to eq 'Lorem ipsum'
+
+ expect(status.created_at).to be_within(30).of(Time.now.utc)
+ end
+ end
+
+ context 'when object publication date is above ISO8601 range' do
+ let(:object_json) do
+ {
+ id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
+ type: 'Note',
+ content: 'Lorem ipsum',
+ published: '10000-11-03T08:31:22Z',
+ }
+ end
+
+ it 'creates status with a valid creation date', :aggregate_failures do
status = sender.statuses.first
expect(status).to_not be_nil
expect(status.text).to eq 'Lorem ipsum'
+
+ expect(status.created_at).to be_within(30).of(Time.now.utc)
end
+ end
- it 'marks status as edited' do
+ context 'when object has been edited' do
+ let(:object_json) do
+ {
+ id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
+ type: 'Note',
+ content: 'Lorem ipsum',
+ published: '2022-01-22T15:00:00Z',
+ updated: '2022-01-22T16:00:00Z',
+ }
+ end
+
+ it 'creates status with appropriate creation and edition dates', :aggregate_failures do
status = sender.statuses.first
expect(status).to_not be_nil
+ expect(status.text).to eq 'Lorem ipsum'
+
+ expect(status.created_at).to eq '2022-01-22T15:00:00Z'.to_datetime
+
expect(status.edited?).to be true
+ expect(status.edited_at).to eq '2022-01-22T16:00:00Z'.to_datetime
end
end
diff --git a/spec/system/unlogged_spec.rb b/spec/system/unlogged_spec.rb
new file mode 100644
index 00000000000000..c3ebf51d7fb098
--- /dev/null
+++ b/spec/system/unlogged_spec.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe 'UnloggedBrowsing' do
+ subject { page }
+
+ before do
+ visit root_path
+ end
+
+ it 'loads the home page' do
+ expect(subject).to have_css('div.app-holder')
+
+ expect(subject).to have_css('div.columns-area__panels__main')
+ end
+end
diff --git a/yarn.lock b/yarn.lock
index 9d1b969fc1a7db..dd31a1cc388b7d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2904,11 +2904,6 @@ abort-controller@^3.0.0:
dependencies:
event-target-shim "^5.0.0"
-abortcontroller-polyfill@^1.7.5:
- version "1.7.5"
- resolved "https://registry.yarnpkg.com/abortcontroller-polyfill/-/abortcontroller-polyfill-1.7.5.tgz#6738495f4e901fbb57b6c0611d0c75f76c485bed"
- integrity sha512-JMJ5soJWP18htbbxJjG7bG6yuI6pRhgJ0scHHTfkUjf6wjP912xZWvM+A4sJK3gqd9E8fcPbDnOefbA9Th/FIQ==
-
accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.8:
version "1.3.8"
resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e"