diff --git a/app/javascript/mastodon/actions/account_notes.js b/app/javascript/mastodon/actions/account_notes.js deleted file mode 100644 index 72b943300d8..00000000000 --- a/app/javascript/mastodon/actions/account_notes.js +++ /dev/null @@ -1,37 +0,0 @@ -import api from '../api'; - -export const ACCOUNT_NOTE_SUBMIT_REQUEST = 'ACCOUNT_NOTE_SUBMIT_REQUEST'; -export const ACCOUNT_NOTE_SUBMIT_SUCCESS = 'ACCOUNT_NOTE_SUBMIT_SUCCESS'; -export const ACCOUNT_NOTE_SUBMIT_FAIL = 'ACCOUNT_NOTE_SUBMIT_FAIL'; - -export function submitAccountNote(id, value) { - return (dispatch, getState) => { - dispatch(submitAccountNoteRequest()); - - api(getState).post(`/api/v1/accounts/${id}/note`, { - comment: value, - }).then(response => { - dispatch(submitAccountNoteSuccess(response.data)); - }).catch(error => dispatch(submitAccountNoteFail(error))); - }; -} - -export function submitAccountNoteRequest() { - return { - type: ACCOUNT_NOTE_SUBMIT_REQUEST, - }; -} - -export function submitAccountNoteSuccess(relationship) { - return { - type: ACCOUNT_NOTE_SUBMIT_SUCCESS, - relationship, - }; -} - -export function submitAccountNoteFail(error) { - return { - type: ACCOUNT_NOTE_SUBMIT_FAIL, - error, - }; -} diff --git a/app/javascript/mastodon/actions/account_notes.ts b/app/javascript/mastodon/actions/account_notes.ts new file mode 100644 index 00000000000..eeef23e3666 --- /dev/null +++ b/app/javascript/mastodon/actions/account_notes.ts @@ -0,0 +1,18 @@ +import { createAppAsyncThunk } from 'mastodon/store/typed_functions'; + +import api from '../api'; + +export const submitAccountNote = createAppAsyncThunk( + 'account_note/submit', + async (args: { id: string; value: string }, { getState }) => { + // TODO: replace `unknown` with `ApiRelationshipJSON` when it is merged + const response = await api(getState).post( + `/api/v1/accounts/${args.id}/note`, + { + comment: args.value, + }, + ); + + return { relationship: response.data }; + }, +); diff --git a/app/javascript/mastodon/api.js b/app/javascript/mastodon/api.js deleted file mode 100644 index 1c171a1c4aa..00000000000 --- a/app/javascript/mastodon/api.js +++ /dev/null @@ -1,76 +0,0 @@ -// @ts-check - -import axios from 'axios'; -import LinkHeader from 'http-link-header'; - -import ready from './ready'; - -/** - * @param {import('axios').AxiosResponse} response - * @returns {LinkHeader} - */ -export const getLinks = response => { - const value = response.headers.link; - - if (!value) { - return new LinkHeader(); - } - - return LinkHeader.parse(value); -}; - -/** @type {import('axios').RawAxiosRequestHeaders} */ -const csrfHeader = {}; - -/** - * @returns {void} - */ -const setCSRFHeader = () => { - /** @type {HTMLMetaElement | null} */ - const csrfToken = document.querySelector('meta[name=csrf-token]'); - - if (csrfToken) { - csrfHeader['X-CSRF-Token'] = csrfToken.content; - } -}; - -ready(setCSRFHeader); - -/** - * @param {() => import('immutable').Map} getState - * @returns {import('axios').RawAxiosRequestHeaders} - */ -const authorizationHeaderFromState = getState => { - const accessToken = getState && getState().getIn(['meta', 'access_token'], ''); - - if (!accessToken) { - return {}; - } - - return { - 'Authorization': `Bearer ${accessToken}`, - }; -}; - -/** - * @param {() => import('immutable').Map} getState - * @returns {import('axios').AxiosInstance} - */ -export default function api(getState) { - return axios.create({ - headers: { - ...csrfHeader, - ...authorizationHeaderFromState(getState), - }, - - transformResponse: [ - function (data) { - try { - return JSON.parse(data); - } catch { - return data; - } - }, - ], - }); -} diff --git a/app/javascript/mastodon/api.ts b/app/javascript/mastodon/api.ts new file mode 100644 index 00000000000..f262fd85707 --- /dev/null +++ b/app/javascript/mastodon/api.ts @@ -0,0 +1,63 @@ +import type { AxiosResponse, RawAxiosRequestHeaders } from 'axios'; +import axios from 'axios'; +import LinkHeader from 'http-link-header'; + +import ready from './ready'; +import type { GetState } from './store'; + +export const getLinks = (response: AxiosResponse) => { + const value = response.headers.link as string | undefined; + + if (!value) { + return new LinkHeader(); + } + + return LinkHeader.parse(value); +}; + +const csrfHeader: RawAxiosRequestHeaders = {}; + +const setCSRFHeader = () => { + const csrfToken = document.querySelector( + 'meta[name=csrf-token]', + ); + + if (csrfToken) { + csrfHeader['X-CSRF-Token'] = csrfToken.content; + } +}; + +void ready(setCSRFHeader); + +const authorizationHeaderFromState = (getState?: GetState) => { + const accessToken = + getState && (getState().meta.get('access_token', '') as string); + + if (!accessToken) { + return {}; + } + + return { + Authorization: `Bearer ${accessToken}`, + } as RawAxiosRequestHeaders; +}; + +// eslint-disable-next-line import/no-default-export +export default function api(getState: GetState) { + return axios.create({ + headers: { + ...csrfHeader, + ...authorizationHeaderFromState(getState), + }, + + transformResponse: [ + function (data: unknown) { + try { + return JSON.parse(data as string) as unknown; + } catch { + return data; + } + }, + ], + }); +} diff --git a/app/javascript/mastodon/features/account/containers/account_note_container.js b/app/javascript/mastodon/features/account/containers/account_note_container.js index 9fbe0671c06..20304a45243 100644 --- a/app/javascript/mastodon/features/account/containers/account_note_container.js +++ b/app/javascript/mastodon/features/account/containers/account_note_container.js @@ -11,7 +11,7 @@ const mapStateToProps = (state, { account }) => ({ const mapDispatchToProps = (dispatch, { account }) => ({ onSave (value) { - dispatch(submitAccountNote(account.get('id'), value)); + dispatch(submitAccountNote({ id: account.get('id'), value})); }, }); diff --git a/app/javascript/mastodon/reducers/relationships.js b/app/javascript/mastodon/reducers/relationships.js index d1ccf9ac953..191448c0e8f 100644 --- a/app/javascript/mastodon/reducers/relationships.js +++ b/app/javascript/mastodon/reducers/relationships.js @@ -1,7 +1,7 @@ import { Map as ImmutableMap, fromJS } from 'immutable'; import { - ACCOUNT_NOTE_SUBMIT_SUCCESS, + submitAccountNote, } from '../actions/account_notes'; import { ACCOUNT_FOLLOW_SUCCESS, @@ -73,10 +73,10 @@ export default function relationships(state = initialState, action) { case ACCOUNT_UNMUTE_SUCCESS: case ACCOUNT_PIN_SUCCESS: case ACCOUNT_UNPIN_SUCCESS: - case ACCOUNT_NOTE_SUBMIT_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: diff --git a/app/javascript/mastodon/store/index.ts b/app/javascript/mastodon/store/index.ts index f7486627940..c2629b0ed74 100644 --- a/app/javascript/mastodon/store/index.ts +++ b/app/javascript/mastodon/store/index.ts @@ -1,45 +1,8 @@ -import type { TypedUseSelectorHook } from 'react-redux'; -import { useDispatch, useSelector } from 'react-redux'; +export { store } from './store'; +export type { GetState, AppDispatch, RootState } from './store'; -import { configureStore } from '@reduxjs/toolkit'; - -import { rootReducer } from '../reducers'; - -import { errorsMiddleware } from './middlewares/errors'; -import { loadingBarMiddleware } from './middlewares/loading_bar'; -import { soundsMiddleware } from './middlewares/sounds'; - -export const store = configureStore({ - reducer: rootReducer, - middleware: (getDefaultMiddleware) => - getDefaultMiddleware({ - // In development, Redux Toolkit enables 2 default middlewares to detect - // common issues with states. Unfortunately, our use of ImmutableJS for state - // triggers both, so lets disable them until our state is fully refactored - - // https://redux-toolkit.js.org/api/serializabilityMiddleware - // This checks recursively that every values in the state are serializable in JSON - // Which is not the case, as we use ImmutableJS structures, but also File objects - serializableCheck: false, - - // https://redux-toolkit.js.org/api/immutabilityMiddleware - // This checks recursively if every value in the state is immutable (ie, a JS primitive type) - // But this is not the case, as our Root State is an ImmutableJS map, which is an object - immutableCheck: false, - }) - .concat( - loadingBarMiddleware({ - promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'], - }), - ) - .concat(errorsMiddleware) - .concat(soundsMiddleware()), -}); - -// 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 const useAppDispatch: () => AppDispatch = useDispatch; -export const useAppSelector: TypedUseSelectorHook = useSelector; +export { + createAppAsyncThunk, + useAppDispatch, + useAppSelector, +} from './typed_functions'; diff --git a/app/javascript/mastodon/store/store.ts b/app/javascript/mastodon/store/store.ts new file mode 100644 index 00000000000..63508856803 --- /dev/null +++ b/app/javascript/mastodon/store/store.ts @@ -0,0 +1,40 @@ +import { configureStore } from '@reduxjs/toolkit'; + +import { rootReducer } from '../reducers'; + +import { errorsMiddleware } from './middlewares/errors'; +import { loadingBarMiddleware } from './middlewares/loading_bar'; +import { soundsMiddleware } from './middlewares/sounds'; + +export const store = configureStore({ + reducer: rootReducer, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ + // In development, Redux Toolkit enables 2 default middlewares to detect + // common issues with states. Unfortunately, our use of ImmutableJS for state + // triggers both, so lets disable them until our state is fully refactored + + // https://redux-toolkit.js.org/api/serializabilityMiddleware + // This checks recursively that every values in the state are serializable in JSON + // Which is not the case, as we use ImmutableJS structures, but also File objects + serializableCheck: false, + + // https://redux-toolkit.js.org/api/immutabilityMiddleware + // This checks recursively if every value in the state is immutable (ie, a JS primitive type) + // But this is not the case, as our Root State is an ImmutableJS map, which is an object + immutableCheck: false, + }) + .concat( + loadingBarMiddleware({ + promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'], + }), + ) + .concat(errorsMiddleware) + .concat(soundsMiddleware()), +}); + +// 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/store/typed_functions.ts b/app/javascript/mastodon/store/typed_functions.ts new file mode 100644 index 00000000000..d05a256baba --- /dev/null +++ b/app/javascript/mastodon/store/typed_functions.ts @@ -0,0 +1,16 @@ +import type { TypedUseSelectorHook } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; + +import { createAsyncThunk } from '@reduxjs/toolkit'; + +import type { AppDispatch, RootState } from './store'; + +export const useAppDispatch: () => AppDispatch = useDispatch; +export const useAppSelector: TypedUseSelectorHook = useSelector; + +export const createAppAsyncThunk = createAsyncThunk.withTypes<{ + state: RootState; + dispatch: AppDispatch; + rejectValue: string; + extra: { s: string; n: number }; +}>();