Merge branch 'axios' into 'main'

Remove axios

See merge request soapbox-pub/soapbox!3298
This commit is contained in:
Alex Gleason 2024-12-14 01:57:14 +00:00
commit dcdd14b2b8
114 changed files with 975 additions and 4838 deletions

View File

@ -90,8 +90,6 @@
"@types/semver": "^7.3.9", "@types/semver": "^7.3.9",
"@webbtc/webln-types": "^3.0.0", "@webbtc/webln-types": "^3.0.0",
"autoprefixer": "^10.4.15", "autoprefixer": "^10.4.15",
"axios": "^1.2.2",
"axios-mock-adapter": "^1.22.0",
"blurhash": "^2.0.0", "blurhash": "^2.0.0",
"bowser": "^2.11.0", "bowser": "^2.11.0",
"browserslist": "^4.16.6", "browserslist": "^4.16.6",

View File

@ -12,7 +12,8 @@ const fetchAboutPage = (slug = 'index', locale?: string) => (dispatch: React.Dis
const filename = `${slug}${locale ? `.${locale}` : ''}.html`; const filename = `${slug}${locale ? `.${locale}` : ''}.html`;
return api(getState).get(`/instance/about/${filename}`) return api(getState).get(`/instance/about/${filename}`)
.then(({ data: html }) => { .then((response) => response.text())
.then((html) => {
dispatch({ type: FETCH_ABOUT_PAGE_SUCCESS, slug, locale, html }); dispatch({ type: FETCH_ABOUT_PAGE_SUCCESS, slug, locale, html });
return html; return html;
}) })

View File

@ -1,55 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { __stub } from 'soapbox/api/index.ts';
import { mockStore, rootState } from 'soapbox/jest/test-helpers.tsx';
import { submitAccountNote } from './account-notes.ts';
describe('submitAccountNote()', () => {
let store: ReturnType<typeof mockStore>;
beforeEach(() => {
store = mockStore(rootState);
});
describe('with a successful API request', () => {
beforeEach(() => {
__stub((mock) => {
mock.onPost('/api/v1/accounts/1/note').reply(200, {});
});
});
it('post the note to the API', async() => {
const expectedActions = [
{ type: 'ACCOUNT_NOTE_SUBMIT_REQUEST' },
{ type: 'ACCOUNT_NOTE_SUBMIT_SUCCESS', relationship: {} },
];
await store.dispatch(submitAccountNote('1', 'hello'));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
describe('with an unsuccessful API request', () => {
beforeEach(() => {
__stub((mock) => {
mock.onPost('/api/v1/accounts/1/note').networkError();
});
});
it('should dispatch failed action', async() => {
const expectedActions = [
{ type: 'ACCOUNT_NOTE_SUBMIT_REQUEST' },
{
type: 'ACCOUNT_NOTE_SUBMIT_FAIL',
error: new Error('Network Error'),
},
];
await store.dispatch(submitAccountNote('1', 'hello'));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
});

View File

@ -15,8 +15,9 @@ const submitAccountNote = (id: string, value: string) =>
.post(`/api/v1/accounts/${id}/note`, { .post(`/api/v1/accounts/${id}/note`, {
comment: value, comment: value,
}) })
.then(response => { .then((response) => response.json())
dispatch(submitAccountNoteSuccess(response.data)); .then((data) => {
dispatch(submitAccountNoteSuccess(data));
}) })
.catch(error => dispatch(submitAccountNoteFail(error))); .catch(error => dispatch(submitAccountNoteFail(error)));
}; };

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,11 @@
import { HTTPError } from 'soapbox/api/HTTPError.ts';
import { importEntities } from 'soapbox/entity-store/actions.ts'; import { importEntities } from 'soapbox/entity-store/actions.ts';
import { Entities } from 'soapbox/entity-store/entities.ts'; import { Entities } from 'soapbox/entity-store/entities.ts';
import { selectAccount } from 'soapbox/selectors/index.ts'; import { selectAccount } from 'soapbox/selectors/index.ts';
import { isLoggedIn } from 'soapbox/utils/auth.ts'; import { isLoggedIn } from 'soapbox/utils/auth.ts';
import { getFeatures, parseVersion, PLEROMA } from 'soapbox/utils/features.ts'; import { getFeatures, parseVersion, PLEROMA } from 'soapbox/utils/features.ts';
import api, { getLinks } from '../api/index.ts'; import api from '../api/index.ts';
import { import {
importFetchedAccount, importFetchedAccount,
@ -12,7 +13,6 @@ import {
importErrorWhileFetchingAccountByUsername, importErrorWhileFetchingAccountByUsername,
} from './importer/index.ts'; } from './importer/index.ts';
import type { AxiosError, CancelToken } from 'axios';
import type { Map as ImmutableMap } from 'immutable'; import type { Map as ImmutableMap } from 'immutable';
import type { AppDispatch, RootState } from 'soapbox/store.ts'; import type { AppDispatch, RootState } from 'soapbox/store.ts';
import type { APIEntity, Status } from 'soapbox/types/entities.ts'; import type { APIEntity, Status } from 'soapbox/types/entities.ts';
@ -118,7 +118,7 @@ const BIRTHDAY_REMINDERS_FETCH_REQUEST = 'BIRTHDAY_REMINDERS_FETCH_REQUEST';
const BIRTHDAY_REMINDERS_FETCH_SUCCESS = 'BIRTHDAY_REMINDERS_FETCH_SUCCESS'; const BIRTHDAY_REMINDERS_FETCH_SUCCESS = 'BIRTHDAY_REMINDERS_FETCH_SUCCESS';
const BIRTHDAY_REMINDERS_FETCH_FAIL = 'BIRTHDAY_REMINDERS_FETCH_FAIL'; const BIRTHDAY_REMINDERS_FETCH_FAIL = 'BIRTHDAY_REMINDERS_FETCH_FAIL';
const maybeRedirectLogin = (error: AxiosError, history?: History) => { const maybeRedirectLogin = (error: HTTPError, history?: History) => {
// The client is unauthorized - redirect to login. // The client is unauthorized - redirect to login.
if (history && error?.response?.status === 401) { if (history && error?.response?.status === 401) {
history.push('/login'); history.push('/login');
@ -130,7 +130,7 @@ const noOp = () => new Promise(f => f(undefined));
const createAccount = (params: Record<string, any>) => const createAccount = (params: Record<string, any>) =>
async (dispatch: AppDispatch, getState: () => RootState) => { async (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: ACCOUNT_CREATE_REQUEST, params }); dispatch({ type: ACCOUNT_CREATE_REQUEST, params });
return api(getState, 'app').post('/api/v1/accounts', params).then(({ data: token }) => { return api(getState, 'app').post('/api/v1/accounts', params).then((response) => response.json()).then((token) => {
return dispatch({ type: ACCOUNT_CREATE_SUCCESS, params, token }); return dispatch({ type: ACCOUNT_CREATE_SUCCESS, params, token });
}).catch(error => { }).catch(error => {
dispatch({ type: ACCOUNT_CREATE_FAIL, error, params }); dispatch({ type: ACCOUNT_CREATE_FAIL, error, params });
@ -152,9 +152,10 @@ const fetchAccount = (id: string) =>
return api(getState) return api(getState)
.get(`/api/v1/accounts/${id}`) .get(`/api/v1/accounts/${id}`)
.then(response => { .then((response) => response.json())
dispatch(importFetchedAccount(response.data)); .then((data) => {
dispatch(fetchAccountSuccess(response.data)); dispatch(importFetchedAccount(data));
dispatch(fetchAccountSuccess(data));
}) })
.catch(error => { .catch(error => {
dispatch(fetchAccountFail(id, error)); dispatch(fetchAccountFail(id, error));
@ -167,10 +168,10 @@ const fetchAccountByUsername = (username: string, history?: History) =>
const features = getFeatures(instance); const features = getFeatures(instance);
if (features.accountByUsername && (me || !features.accountLookup)) { if (features.accountByUsername && (me || !features.accountLookup)) {
return api(getState).get(`/api/v1/accounts/${username}`).then(response => { return api(getState).get(`/api/v1/accounts/${username}`).then((response) => response.json()).then((data) => {
dispatch(fetchRelationships([response.data.id])); dispatch(fetchRelationships([data.id]));
dispatch(importFetchedAccount(response.data)); dispatch(importFetchedAccount(data));
dispatch(fetchAccountSuccess(response.data)); dispatch(fetchAccountSuccess(data));
}).catch(error => { }).catch(error => {
dispatch(fetchAccountFail(null, error)); dispatch(fetchAccountFail(null, error));
dispatch(importErrorWhileFetchingAccountByUsername(username)); dispatch(importErrorWhileFetchingAccountByUsername(username));
@ -230,10 +231,10 @@ const blockAccount = (id: string) =>
return api(getState) return api(getState)
.post(`/api/v1/accounts/${id}/block`) .post(`/api/v1/accounts/${id}/block`)
.then(response => { .then((response) => response.json()).then((data) => {
dispatch(importEntities([response.data], Entities.RELATIONSHIPS)); dispatch(importEntities([data], Entities.RELATIONSHIPS));
// Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers
return dispatch(blockAccountSuccess(response.data, getState().statuses)); return dispatch(blockAccountSuccess(data, getState().statuses));
}).catch(error => dispatch(blockAccountFail(error))); }).catch(error => dispatch(blockAccountFail(error)));
}; };
@ -245,9 +246,9 @@ const unblockAccount = (id: string) =>
return api(getState) return api(getState)
.post(`/api/v1/accounts/${id}/unblock`) .post(`/api/v1/accounts/${id}/unblock`)
.then(response => { .then((response) => response.json()).then((data) => {
dispatch(importEntities([response.data], Entities.RELATIONSHIPS)); dispatch(importEntities([data], Entities.RELATIONSHIPS));
return dispatch(unblockAccountSuccess(response.data)); return dispatch(unblockAccountSuccess(data));
}) })
.catch(error => dispatch(unblockAccountFail(error))); .catch(error => dispatch(unblockAccountFail(error)));
}; };
@ -307,10 +308,10 @@ const muteAccount = (id: string, notifications?: boolean, duration = 0) =>
return api(getState) return api(getState)
.post(`/api/v1/accounts/${id}/mute`, params) .post(`/api/v1/accounts/${id}/mute`, params)
.then(response => { .then((response) => response.json()).then((data) => {
dispatch(importEntities([response.data], Entities.RELATIONSHIPS)); dispatch(importEntities([data], Entities.RELATIONSHIPS));
// Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers
return dispatch(muteAccountSuccess(response.data, getState().statuses)); return dispatch(muteAccountSuccess(data, getState().statuses));
}) })
.catch(error => dispatch(muteAccountFail(error))); .catch(error => dispatch(muteAccountFail(error)));
}; };
@ -323,9 +324,9 @@ const unmuteAccount = (id: string) =>
return api(getState) return api(getState)
.post(`/api/v1/accounts/${id}/unmute`) .post(`/api/v1/accounts/${id}/unmute`)
.then(response => { .then((response) => response.json()).then((data) => {
dispatch(importEntities([response.data], Entities.RELATIONSHIPS)); dispatch(importEntities([data], Entities.RELATIONSHIPS));
return dispatch(unmuteAccountSuccess(response.data)); return dispatch(unmuteAccountSuccess(data));
}) })
.catch(error => dispatch(unmuteAccountFail(error))); .catch(error => dispatch(unmuteAccountFail(error)));
}; };
@ -369,7 +370,7 @@ const subscribeAccount = (id: string, notifications?: boolean) =>
return api(getState) return api(getState)
.post(`/api/v1/pleroma/accounts/${id}/subscribe`, { notifications }) .post(`/api/v1/pleroma/accounts/${id}/subscribe`, { notifications })
.then(response => dispatch(subscribeAccountSuccess(response.data))) .then((response) => response.json()).then((data) => dispatch(subscribeAccountSuccess(data)))
.catch(error => dispatch(subscribeAccountFail(error))); .catch(error => dispatch(subscribeAccountFail(error)));
}; };
@ -381,7 +382,7 @@ const unsubscribeAccount = (id: string) =>
return api(getState) return api(getState)
.post(`/api/v1/pleroma/accounts/${id}/unsubscribe`) .post(`/api/v1/pleroma/accounts/${id}/unsubscribe`)
.then(response => dispatch(unsubscribeAccountSuccess(response.data))) .then((response) => response.json()).then((data) => dispatch(unsubscribeAccountSuccess(data)))
.catch(error => dispatch(unsubscribeAccountFail(error))); .catch(error => dispatch(unsubscribeAccountFail(error)));
}; };
@ -423,7 +424,7 @@ const removeFromFollowers = (id: string) =>
return api(getState) return api(getState)
.post(`/api/v1/accounts/${id}/remove_from_followers`) .post(`/api/v1/accounts/${id}/remove_from_followers`)
.then(response => dispatch(removeFromFollowersSuccess(response.data))) .then((response) => response.json()).then((data) => dispatch(removeFromFollowersSuccess(data)))
.catch(error => dispatch(removeFromFollowersFail(id, error))); .catch(error => dispatch(removeFromFollowersFail(id, error)));
}; };
@ -449,12 +450,13 @@ const fetchFollowers = (id: string) =>
return api(getState) return api(getState)
.get(`/api/v1/accounts/${id}/followers`) .get(`/api/v1/accounts/${id}/followers`)
.then(response => { .then(async (response) => {
const next = getLinks(response).refs.find(link => link.rel === 'next'); const next = response.next();
const data = await response.json();
dispatch(importFetchedAccounts(response.data)); dispatch(importFetchedAccounts(data));
dispatch(fetchFollowersSuccess(id, response.data, next ? next.uri : null)); dispatch(fetchFollowersSuccess(id, data, next));
dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id))); dispatch(fetchRelationships(data.map((item: APIEntity) => item.id)));
}) })
.catch(error => { .catch(error => {
dispatch(fetchFollowersFail(id, error)); dispatch(fetchFollowersFail(id, error));
@ -493,12 +495,13 @@ const expandFollowers = (id: string) =>
return api(getState) return api(getState)
.get(url) .get(url)
.then(response => { .then(async (response) => {
const next = getLinks(response).refs.find(link => link.rel === 'next'); const next = response.next();
const data = await response.json();
dispatch(importFetchedAccounts(response.data)); dispatch(importFetchedAccounts(data));
dispatch(expandFollowersSuccess(id, response.data, next ? next.uri : null)); dispatch(expandFollowersSuccess(id, data, next));
dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id))); dispatch(fetchRelationships(data.map((item: APIEntity) => item.id)));
}) })
.catch(error => { .catch(error => {
dispatch(expandFollowersFail(id, error)); dispatch(expandFollowersFail(id, error));
@ -529,12 +532,13 @@ const fetchFollowing = (id: string) =>
return api(getState) return api(getState)
.get(`/api/v1/accounts/${id}/following`) .get(`/api/v1/accounts/${id}/following`)
.then(response => { .then(async (response) => {
const next = getLinks(response).refs.find(link => link.rel === 'next'); const next = response.next();
const data = await response.json();
dispatch(importFetchedAccounts(response.data)); dispatch(importFetchedAccounts(data));
dispatch(fetchFollowingSuccess(id, response.data, next ? next.uri : null)); dispatch(fetchFollowingSuccess(id, data, next));
dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id))); dispatch(fetchRelationships(data.map((item: APIEntity) => item.id)));
}) })
.catch(error => { .catch(error => {
dispatch(fetchFollowingFail(id, error)); dispatch(fetchFollowingFail(id, error));
@ -573,12 +577,13 @@ const expandFollowing = (id: string) =>
return api(getState) return api(getState)
.get(url) .get(url)
.then(response => { .then(async (response) => {
const next = getLinks(response).refs.find(link => link.rel === 'next'); const next = response.next();
const data = await response.json();
dispatch(importFetchedAccounts(response.data)); dispatch(importFetchedAccounts(data));
dispatch(expandFollowingSuccess(id, response.data, next ? next.uri : null)); dispatch(expandFollowingSuccess(id, data, next));
dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id))); dispatch(fetchRelationships(data.map((item: APIEntity) => item.id)));
}) })
.catch(error => { .catch(error => {
dispatch(expandFollowingFail(id, error)); dispatch(expandFollowingFail(id, error));
@ -618,9 +623,9 @@ const fetchRelationships = (accountIds: string[]) =>
return api(getState) return api(getState)
.get(`/api/v1/accounts/relationships?${newAccountIds.map(id => `id[]=${id}`).join('&')}`) .get(`/api/v1/accounts/relationships?${newAccountIds.map(id => `id[]=${id}`).join('&')}`)
.then(response => { .then((response) => response.json()).then((data) => {
dispatch(importEntities(response.data, Entities.RELATIONSHIPS)); dispatch(importEntities(data, Entities.RELATIONSHIPS));
dispatch(fetchRelationshipsSuccess(response.data)); dispatch(fetchRelationshipsSuccess(data));
}) })
.catch(error => dispatch(fetchRelationshipsFail(error))); .catch(error => dispatch(fetchRelationshipsFail(error)));
}; };
@ -651,10 +656,11 @@ const fetchFollowRequests = () =>
return api(getState) return api(getState)
.get('/api/v1/follow_requests') .get('/api/v1/follow_requests')
.then(response => { .then(async (response) => {
const next = getLinks(response).refs.find(link => link.rel === 'next'); const next = response.next();
dispatch(importFetchedAccounts(response.data)); const data = await response.json();
dispatch(fetchFollowRequestsSuccess(response.data, next ? next.uri : null)); dispatch(importFetchedAccounts(data));
dispatch(fetchFollowRequestsSuccess(data, next));
}) })
.catch(error => dispatch(fetchFollowRequestsFail(error))); .catch(error => dispatch(fetchFollowRequestsFail(error)));
}; };
@ -688,10 +694,11 @@ const expandFollowRequests = () =>
return api(getState) return api(getState)
.get(url) .get(url)
.then(response => { .then(async (response) => {
const next = getLinks(response).refs.find(link => link.rel === 'next'); const next = response.next();
dispatch(importFetchedAccounts(response.data)); const data = await response.json();
dispatch(expandFollowRequestsSuccess(response.data, next ? next.uri : null)); dispatch(importFetchedAccounts(data));
dispatch(expandFollowRequestsSuccess(data, next));
}) })
.catch(error => dispatch(expandFollowRequestsFail(error))); .catch(error => dispatch(expandFollowRequestsFail(error)));
}; };
@ -773,8 +780,8 @@ const pinAccount = (id: string) =>
dispatch(pinAccountRequest(id)); dispatch(pinAccountRequest(id));
return api(getState).post(`/api/v1/accounts/${id}/pin`).then(response => { return api(getState).post(`/api/v1/accounts/${id}/pin`).then((response) => response.json()).then((data) => {
dispatch(pinAccountSuccess(response.data)); dispatch(pinAccountSuccess(data));
}).catch(error => { }).catch(error => {
dispatch(pinAccountFail(error)); dispatch(pinAccountFail(error));
}); });
@ -786,8 +793,8 @@ const unpinAccount = (id: string) =>
dispatch(unpinAccountRequest(id)); dispatch(unpinAccountRequest(id));
return api(getState).post(`/api/v1/accounts/${id}/unpin`).then(response => { return api(getState).post(`/api/v1/accounts/${id}/unpin`).then((response) => response.json()).then((data) => {
dispatch(unpinAccountSuccess(response.data)); dispatch(unpinAccountSuccess(data));
}).catch(error => { }).catch(error => {
dispatch(unpinAccountFail(error)); dispatch(unpinAccountFail(error));
}); });
@ -796,7 +803,7 @@ const unpinAccount = (id: string) =>
const updateNotificationSettings = (params: Record<string, any>) => const updateNotificationSettings = (params: Record<string, any>) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: NOTIFICATION_SETTINGS_REQUEST, params }); dispatch({ type: NOTIFICATION_SETTINGS_REQUEST, params });
return api(getState).put('/api/pleroma/notification_settings', params).then(({ data }) => { return api(getState).put('/api/pleroma/notification_settings', params).then((response) => response.json()).then((data) => {
dispatch({ type: NOTIFICATION_SETTINGS_SUCCESS, params, data }); dispatch({ type: NOTIFICATION_SETTINGS_SUCCESS, params, data });
}).catch(error => { }).catch(error => {
dispatch({ type: NOTIFICATION_SETTINGS_FAIL, params, error }); dispatch({ type: NOTIFICATION_SETTINGS_FAIL, params, error });
@ -838,9 +845,9 @@ const fetchPinnedAccounts = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch(fetchPinnedAccountsRequest(id)); dispatch(fetchPinnedAccountsRequest(id));
api(getState).get(`/api/v1/pleroma/accounts/${id}/endorsements`).then(response => { api(getState).get(`/api/v1/pleroma/accounts/${id}/endorsements`).then((response) => response.json()).then((data) => {
dispatch(importFetchedAccounts(response.data)); dispatch(importFetchedAccounts(data));
dispatch(fetchPinnedAccountsSuccess(id, response.data, null)); dispatch(fetchPinnedAccountsSuccess(id, data, null));
}).catch(error => { }).catch(error => {
dispatch(fetchPinnedAccountsFail(id, error)); dispatch(fetchPinnedAccountsFail(id, error));
}); });
@ -867,7 +874,7 @@ const fetchPinnedAccountsFail = (id: string, error: unknown) => ({
const accountSearch = (params: Record<string, any>, signal?: AbortSignal) => const accountSearch = (params: Record<string, any>, signal?: AbortSignal) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: ACCOUNT_SEARCH_REQUEST, params }); dispatch({ type: ACCOUNT_SEARCH_REQUEST, params });
return api(getState).get('/api/v1/accounts/search', { params, signal }).then(({ data: accounts }) => { return api(getState).get('/api/v1/accounts/search', { searchParams: params, signal }).then((response) => response.json()).then((accounts) => {
dispatch(importFetchedAccounts(accounts)); dispatch(importFetchedAccounts(accounts));
dispatch({ type: ACCOUNT_SEARCH_SUCCESS, accounts }); dispatch({ type: ACCOUNT_SEARCH_SUCCESS, accounts });
return accounts; return accounts;
@ -877,10 +884,10 @@ const accountSearch = (params: Record<string, any>, signal?: AbortSignal) =>
}); });
}; };
const accountLookup = (acct: string, cancelToken?: CancelToken) => const accountLookup = (acct: string, signal?: AbortSignal) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: ACCOUNT_LOOKUP_REQUEST, acct }); dispatch({ type: ACCOUNT_LOOKUP_REQUEST, acct });
return api(getState).get('/api/v1/accounts/lookup', { params: { acct }, cancelToken }).then(({ data: account }) => { return api(getState).get('/api/v1/accounts/lookup', { searchParams: { acct }, signal }).then((response) => response.json()).then((account) => {
if (account && account.id) dispatch(importFetchedAccount(account)); if (account && account.id) dispatch(importFetchedAccount(account));
dispatch({ type: ACCOUNT_LOOKUP_SUCCESS, account }); dispatch({ type: ACCOUNT_LOOKUP_SUCCESS, account });
return account; return account;
@ -898,11 +905,11 @@ const fetchBirthdayReminders = (month: number, day: number) =>
dispatch({ type: BIRTHDAY_REMINDERS_FETCH_REQUEST, day, month, id: me }); dispatch({ type: BIRTHDAY_REMINDERS_FETCH_REQUEST, day, month, id: me });
return api(getState).get('/api/v1/pleroma/birthdays', { params: { day, month } }).then(response => { return api(getState).get('/api/v1/pleroma/birthdays', { searchParams: { day, month } }).then((response) => response.json()).then((data) => {
dispatch(importFetchedAccounts(response.data)); dispatch(importFetchedAccounts(data));
dispatch({ dispatch({
type: BIRTHDAY_REMINDERS_FETCH_SUCCESS, type: BIRTHDAY_REMINDERS_FETCH_SUCCESS,
accounts: response.data, accounts: data,
day, day,
month, month,
id: me, id: me,

View File

@ -3,9 +3,8 @@ import { importFetchedAccount, importFetchedAccounts, importFetchedStatuses } fr
import { accountIdsToAccts } from 'soapbox/selectors/index.ts'; import { accountIdsToAccts } from 'soapbox/selectors/index.ts';
import { filterBadges, getTagDiff } from 'soapbox/utils/badges.ts'; import { filterBadges, getTagDiff } from 'soapbox/utils/badges.ts';
import api, { getLinks } from '../api/index.ts'; import api from '../api/index.ts';
import type { AxiosResponse } from 'axios';
import type { AppDispatch, RootState } from 'soapbox/store.ts'; import type { AppDispatch, RootState } from 'soapbox/store.ts';
import type { APIEntity } from 'soapbox/types/entities.ts'; import type { APIEntity } from 'soapbox/types/entities.ts';
@ -74,7 +73,7 @@ const fetchConfig = () =>
dispatch({ type: ADMIN_CONFIG_FETCH_REQUEST }); dispatch({ type: ADMIN_CONFIG_FETCH_REQUEST });
return api(getState) return api(getState)
.get('/api/v1/pleroma/admin/config') .get('/api/v1/pleroma/admin/config')
.then(({ data }) => { .then((response) => response.json()).then((data) => {
dispatch({ type: ADMIN_CONFIG_FETCH_SUCCESS, configs: data.configs, needsReboot: data.need_reboot }); dispatch({ type: ADMIN_CONFIG_FETCH_SUCCESS, configs: data.configs, needsReboot: data.need_reboot });
}).catch(error => { }).catch(error => {
dispatch({ type: ADMIN_CONFIG_FETCH_FAIL, error }); dispatch({ type: ADMIN_CONFIG_FETCH_FAIL, error });
@ -86,7 +85,7 @@ const updateConfig = (configs: Record<string, any>[]) =>
dispatch({ type: ADMIN_CONFIG_UPDATE_REQUEST, configs }); dispatch({ type: ADMIN_CONFIG_UPDATE_REQUEST, configs });
return api(getState) return api(getState)
.post('/api/v1/pleroma/admin/config', { configs }) .post('/api/v1/pleroma/admin/config', { configs })
.then(({ data }) => { .then((response) => response.json()).then((data) => {
dispatch({ type: ADMIN_CONFIG_UPDATE_SUCCESS, configs: data.configs, needsReboot: data.need_reboot }); dispatch({ type: ADMIN_CONFIG_UPDATE_SUCCESS, configs: data.configs, needsReboot: data.need_reboot });
}).catch(error => { }).catch(error => {
dispatch({ type: ADMIN_CONFIG_UPDATE_FAIL, error, configs }); dispatch({ type: ADMIN_CONFIG_UPDATE_FAIL, error, configs });
@ -111,7 +110,8 @@ function fetchReports(params: Record<string, any> = {}) {
dispatch({ type: ADMIN_REPORTS_FETCH_REQUEST, params }); dispatch({ type: ADMIN_REPORTS_FETCH_REQUEST, params });
try { try {
const { data: reports } = await api(getState).get('/api/v1/admin/reports', { params }); const response = await api(getState).get('/api/v1/admin/reports', { searchParams: params });
const reports = await response.json();
reports.forEach((report: APIEntity) => { reports.forEach((report: APIEntity) => {
dispatch(importFetchedAccount(report.account?.account)); dispatch(importFetchedAccount(report.account?.account));
dispatch(importFetchedAccount(report.target_account?.account)); dispatch(importFetchedAccount(report.target_account?.account));
@ -158,8 +158,9 @@ function fetchUsers(filters: Record<string, boolean>, page = 1, query?: string |
}; };
try { try {
const { data: accounts, ...response } = await api(getState).get(url || '/api/v1/admin/accounts', { params }); const response = await api(getState).get(url || '/api/v1/admin/accounts', { searchParams: params });
const next = getLinks(response as AxiosResponse<any, any>).refs.find(link => link.rel === 'next')?.uri; const accounts = await response.json();
const next = response.next();
dispatch(importFetchedAccounts(accounts.map(({ account }: APIEntity) => account))); dispatch(importFetchedAccounts(accounts.map(({ account }: APIEntity) => account)));
dispatch(fetchRelationships(accounts.map((account_1: APIEntity) => account_1.id))); dispatch(fetchRelationships(accounts.map((account_1: APIEntity) => account_1.id)));
@ -206,8 +207,9 @@ const deleteUser = (accountId: string) =>
const nicknames = accountIdsToAccts(getState(), [accountId]); const nicknames = accountIdsToAccts(getState(), [accountId]);
dispatch({ type: ADMIN_USERS_DELETE_REQUEST, accountId }); dispatch({ type: ADMIN_USERS_DELETE_REQUEST, accountId });
return api(getState) return api(getState)
.delete('/api/v1/pleroma/admin/users', { data: { nicknames } }) .request('DELETE', '/api/v1/pleroma/admin/users', { nicknames })
.then(({ data: nicknames }) => { .then((response) => response.json())
.then(({ nicknames }) => {
dispatch({ type: ADMIN_USERS_DELETE_SUCCESS, nicknames, accountId }); dispatch({ type: ADMIN_USERS_DELETE_SUCCESS, nicknames, accountId });
}).catch(error => { }).catch(error => {
dispatch({ type: ADMIN_USERS_DELETE_FAIL, error, accountId }); dispatch({ type: ADMIN_USERS_DELETE_FAIL, error, accountId });
@ -218,8 +220,9 @@ function approveUser(accountId: string) {
return async (dispatch: AppDispatch, getState: () => RootState) => { return async (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: ADMIN_USERS_APPROVE_REQUEST, accountId }); dispatch({ type: ADMIN_USERS_APPROVE_REQUEST, accountId });
try { try {
const { data: user } = await api(getState) const { user } = await api(getState)
.post(`/api/v1/admin/accounts/${accountId}/approve`); .post(`/api/v1/admin/accounts/${accountId}/approve`)
.then((response) => response.json());
dispatch({ type: ADMIN_USERS_APPROVE_SUCCESS, user, accountId }); dispatch({ type: ADMIN_USERS_APPROVE_SUCCESS, user, accountId });
} catch (error) { } catch (error) {
dispatch({ type: ADMIN_USERS_APPROVE_FAIL, error, accountId }); dispatch({ type: ADMIN_USERS_APPROVE_FAIL, error, accountId });
@ -231,8 +234,9 @@ function rejectUser(accountId: string) {
return async (dispatch: AppDispatch, getState: () => RootState) => { return async (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: ADMIN_USERS_REJECT_REQUEST, accountId }); dispatch({ type: ADMIN_USERS_REJECT_REQUEST, accountId });
try { try {
const { data: user } = await api(getState) const { user } = await api(getState)
.post(`/api/v1/admin/accounts/${accountId}/reject`); .post(`/api/v1/admin/accounts/${accountId}/reject`)
.then((response) => response.json());
dispatch({ type: ADMIN_USERS_REJECT_SUCCESS, user, accountId }); dispatch({ type: ADMIN_USERS_REJECT_SUCCESS, user, accountId });
} catch (error) { } catch (error) {
dispatch({ type: ADMIN_USERS_REJECT_FAIL, error, accountId }); dispatch({ type: ADMIN_USERS_REJECT_FAIL, error, accountId });
@ -288,7 +292,7 @@ const untagUsers = (accountIds: string[], tags: string[]) =>
dispatch({ type: ADMIN_USERS_UNTAG_REQUEST, accountIds, tags }); dispatch({ type: ADMIN_USERS_UNTAG_REQUEST, accountIds, tags });
return api(getState) return api(getState)
.delete('/api/v1/pleroma/admin/users/tag', { data: { nicknames, tags } }) .request('DELETE', '/api/v1/pleroma/admin/users/tag', { nicknames, tags })
.then(() => { .then(() => {
dispatch({ type: ADMIN_USERS_UNTAG_SUCCESS, accountIds, tags }); dispatch({ type: ADMIN_USERS_UNTAG_SUCCESS, accountIds, tags });
}).catch(error => { }).catch(error => {
@ -320,7 +324,7 @@ const addPermission = (accountIds: string[], permissionGroup: string) =>
dispatch({ type: ADMIN_ADD_PERMISSION_GROUP_REQUEST, accountIds, permissionGroup }); dispatch({ type: ADMIN_ADD_PERMISSION_GROUP_REQUEST, accountIds, permissionGroup });
return api(getState) return api(getState)
.post(`/api/v1/pleroma/admin/users/permission_group/${permissionGroup}`, { nicknames }) .post(`/api/v1/pleroma/admin/users/permission_group/${permissionGroup}`, { nicknames })
.then(({ data }) => { .then((response) => response.json()).then((data) => {
dispatch({ type: ADMIN_ADD_PERMISSION_GROUP_SUCCESS, accountIds, permissionGroup, data }); dispatch({ type: ADMIN_ADD_PERMISSION_GROUP_SUCCESS, accountIds, permissionGroup, data });
}).catch(error => { }).catch(error => {
dispatch({ type: ADMIN_ADD_PERMISSION_GROUP_FAIL, error, accountIds, permissionGroup }); dispatch({ type: ADMIN_ADD_PERMISSION_GROUP_FAIL, error, accountIds, permissionGroup });
@ -332,8 +336,8 @@ const removePermission = (accountIds: string[], permissionGroup: string) =>
const nicknames = accountIdsToAccts(getState(), accountIds); const nicknames = accountIdsToAccts(getState(), accountIds);
dispatch({ type: ADMIN_REMOVE_PERMISSION_GROUP_REQUEST, accountIds, permissionGroup }); dispatch({ type: ADMIN_REMOVE_PERMISSION_GROUP_REQUEST, accountIds, permissionGroup });
return api(getState) return api(getState)
.delete(`/api/v1/pleroma/admin/users/permission_group/${permissionGroup}`, { data: { nicknames } }) .request('DELETE', `/api/v1/pleroma/admin/users/permission_group/${permissionGroup}`, { nicknames })
.then(({ data }) => { .then((response) => response.json()).then((data) => {
dispatch({ type: ADMIN_REMOVE_PERMISSION_GROUP_SUCCESS, accountIds, permissionGroup, data }); dispatch({ type: ADMIN_REMOVE_PERMISSION_GROUP_SUCCESS, accountIds, permissionGroup, data });
}).catch(error => { }).catch(error => {
dispatch({ type: ADMIN_REMOVE_PERMISSION_GROUP_FAIL, error, accountIds, permissionGroup }); dispatch({ type: ADMIN_REMOVE_PERMISSION_GROUP_FAIL, error, accountIds, permissionGroup });

View File

@ -45,8 +45,8 @@ const fetchAliases = (dispatch: AppDispatch, getState: () => RootState) => {
dispatch(fetchAliasesRequest()); dispatch(fetchAliasesRequest());
api(getState).get('/api/pleroma/aliases') api(getState).get('/api/pleroma/aliases')
.then(response => { .then((response) => response.json()).then((data) => {
dispatch(fetchAliasesSuccess(response.data.aliases)); dispatch(fetchAliasesSuccess(data.aliases));
}) })
.catch(err => dispatch(fetchAliasesFail(err))); .catch(err => dispatch(fetchAliasesFail(err)));
}; };
@ -75,7 +75,7 @@ const fetchAliasesSuggestions = (q: string) =>
limit: 4, limit: 4,
}; };
api(getState).get('/api/v1/accounts/search', { params }).then(({ data }) => { api(getState).get('/api/v1/accounts/search', { searchParams: params }).then((response) => response.json()).then((data) => {
dispatch(importFetchedAccounts(data)); dispatch(importFetchedAccounts(data));
dispatch(fetchAliasesSuggestionsReady(q, data)); dispatch(fetchAliasesSuggestionsReady(q, data));
}).catch(error => toast.showAlertForError(error)); }).catch(error => toast.showAlertForError(error));
@ -111,11 +111,12 @@ const addToAliases = (account: Account) =>
dispatch(addToAliasesRequest()); dispatch(addToAliasesRequest());
api(getState).patch('/api/v1/accounts/update_credentials', { also_known_as: [...alsoKnownAs, account.pleroma?.ap_id] }) api(getState).patch('/api/v1/accounts/update_credentials', { also_known_as: [...alsoKnownAs, account.pleroma?.ap_id] })
.then((response => { .then((response) => response.json())
.then((data) => {
toast.success(messages.createSuccess); toast.success(messages.createSuccess);
dispatch(addToAliasesSuccess); dispatch(addToAliasesSuccess);
dispatch(patchMeSuccess(response.data)); dispatch(patchMeSuccess(data));
})) })
.catch(err => dispatch(addToAliasesFail(err))); .catch(err => dispatch(addToAliasesFail(err)));
return; return;
@ -162,10 +163,10 @@ const removeFromAliases = (account: string) =>
dispatch(removeFromAliasesRequest()); dispatch(removeFromAliasesRequest());
api(getState).patch('/api/v1/accounts/update_credentials', { also_known_as: alsoKnownAs.filter((id: string) => id !== account) }) api(getState).patch('/api/v1/accounts/update_credentials', { also_known_as: alsoKnownAs.filter((id: string) => id !== account) })
.then(response => { .then((response) => response.json()).then((data) => {
toast.success(messages.removeSuccess); toast.success(messages.removeSuccess);
dispatch(removeFromAliasesSuccess); dispatch(removeFromAliasesSuccess);
dispatch(patchMeSuccess(response.data)); dispatch(patchMeSuccess(data));
}) })
.catch(err => dispatch(removeFromAliasesFail(err))); .catch(err => dispatch(removeFromAliasesFail(err)));
@ -174,12 +175,10 @@ const removeFromAliases = (account: string) =>
dispatch(addToAliasesRequest()); dispatch(addToAliasesRequest());
api(getState).delete('/api/pleroma/aliases', { api(getState).request('DELETE', '/api/pleroma/aliases', {
data: {
alias: account, alias: account,
},
}) })
.then(response => { .then(() => {
toast.success(messages.removeSuccess); toast.success(messages.removeSuccess);
dispatch(removeFromAliasesSuccess); dispatch(removeFromAliasesSuccess);
dispatch(fetchAliases); dispatch(fetchAliases);

View File

@ -21,7 +21,7 @@ export const APP_VERIFY_CREDENTIALS_FAIL = 'APP_VERIFY_CREDENTIALS_FAIL';
export function createApp(params?: Record<string, string>, baseURL?: string) { export function createApp(params?: Record<string, string>, baseURL?: string) {
return (dispatch: React.Dispatch<AnyAction>) => { return (dispatch: React.Dispatch<AnyAction>) => {
dispatch({ type: APP_CREATE_REQUEST, params }); dispatch({ type: APP_CREATE_REQUEST, params });
return baseClient(null, baseURL).post('/api/v1/apps', params).then(({ data: app }) => { return baseClient(null, baseURL).post('/api/v1/apps', params).then((response) => response.json()).then((app) => {
dispatch({ type: APP_CREATE_SUCCESS, params, app }); dispatch({ type: APP_CREATE_SUCCESS, params, app });
return app as Record<string, string>; return app as Record<string, string>;
}).catch(error => { }).catch(error => {
@ -34,7 +34,7 @@ export function createApp(params?: Record<string, string>, baseURL?: string) {
export function verifyAppCredentials(token: string) { export function verifyAppCredentials(token: string) {
return (dispatch: React.Dispatch<AnyAction>) => { return (dispatch: React.Dispatch<AnyAction>) => {
dispatch({ type: APP_VERIFY_CREDENTIALS_REQUEST, token }); dispatch({ type: APP_VERIFY_CREDENTIALS_REQUEST, token });
return baseClient(token).get('/api/v1/apps/verify_credentials').then(({ data: app }) => { return baseClient(token).get('/api/v1/apps/verify_credentials').then((response) => response.json()).then((app) => {
dispatch({ type: APP_VERIFY_CREDENTIALS_SUCCESS, token, app }); dispatch({ type: APP_VERIFY_CREDENTIALS_SUCCESS, token, app });
return app; return app;
}).catch(error => { }).catch(error => {

View File

@ -14,6 +14,7 @@ import { createApp } from 'soapbox/actions/apps.ts';
import { fetchMeSuccess, fetchMeFail } from 'soapbox/actions/me.ts'; import { fetchMeSuccess, fetchMeFail } from 'soapbox/actions/me.ts';
import { obtainOAuthToken, revokeOAuthToken } from 'soapbox/actions/oauth.ts'; import { obtainOAuthToken, revokeOAuthToken } from 'soapbox/actions/oauth.ts';
import { startOnboarding } from 'soapbox/actions/onboarding.ts'; import { startOnboarding } from 'soapbox/actions/onboarding.ts';
import { HTTPError } from 'soapbox/api/HTTPError.ts';
import { custom } from 'soapbox/custom.ts'; import { custom } from 'soapbox/custom.ts';
import { queryClient } from 'soapbox/queries/client.ts'; import { queryClient } from 'soapbox/queries/client.ts';
import { selectAccount } from 'soapbox/selectors/index.ts'; import { selectAccount } from 'soapbox/selectors/index.ts';
@ -28,7 +29,6 @@ import api, { baseClient } from '../api/index.ts';
import { importFetchedAccount } from './importer/index.ts'; import { importFetchedAccount } from './importer/index.ts';
import type { AxiosError } from 'axios';
import type { AppDispatch, RootState } from 'soapbox/store.ts'; import type { AppDispatch, RootState } from 'soapbox/store.ts';
export const SWITCH_ACCOUNT = 'SWITCH_ACCOUNT'; export const SWITCH_ACCOUNT = 'SWITCH_ACCOUNT';
@ -132,7 +132,7 @@ export const otpVerify = (code: string, mfa_token: string) =>
challenge_type: 'totp', challenge_type: 'totp',
redirect_uri: 'urn:ietf:wg:oauth:2.0:oob', redirect_uri: 'urn:ietf:wg:oauth:2.0:oob',
scope: getScopes(getState()), scope: getScopes(getState()),
}).then(({ data: token }) => dispatch(authLoggedIn(token))); }).then((response) => response.json()).then((token) => dispatch(authLoggedIn(token)));
}; };
export const verifyCredentials = (token: string, accountUrl?: string) => { export const verifyCredentials = (token: string, accountUrl?: string) => {
@ -141,7 +141,7 @@ export const verifyCredentials = (token: string, accountUrl?: string) => {
return (dispatch: AppDispatch, getState: () => RootState) => { return (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: VERIFY_CREDENTIALS_REQUEST, token }); dispatch({ type: VERIFY_CREDENTIALS_REQUEST, token });
return baseClient(token, baseURL).get('/api/v1/accounts/verify_credentials').then(({ data: account }) => { return baseClient(token, baseURL).get('/api/v1/accounts/verify_credentials').then((response) => response.json()).then((account) => {
dispatch(importFetchedAccount(account)); dispatch(importFetchedAccount(account));
dispatch({ type: VERIFY_CREDENTIALS_SUCCESS, token, account }); dispatch({ type: VERIFY_CREDENTIALS_SUCCESS, token, account });
if (account.id === getState().me) dispatch(fetchMeSuccess(account)); if (account.id === getState().me) dispatch(fetchMeSuccess(account));
@ -149,7 +149,7 @@ export const verifyCredentials = (token: string, accountUrl?: string) => {
}).catch(error => { }).catch(error => {
if (error?.response?.status === 403 && error?.response?.data?.id) { if (error?.response?.status === 403 && error?.response?.data?.id) {
// The user is waitlisted // The user is waitlisted
const account = error.response.data; const account = error.data;
dispatch(importFetchedAccount(account)); dispatch(importFetchedAccount(account));
dispatch({ type: VERIFY_CREDENTIALS_SUCCESS, token, account }); dispatch({ type: VERIFY_CREDENTIALS_SUCCESS, token, account });
if (account.id === getState().me) dispatch(fetchMeSuccess(account)); if (account.id === getState().me) dispatch(fetchMeSuccess(account));
@ -163,19 +163,32 @@ export const verifyCredentials = (token: string, accountUrl?: string) => {
}; };
}; };
export class MfaRequiredError extends Error {
constructor(public token: string) {
super('MFA is required');
}
}
export const logIn = (username: string, password: string) => export const logIn = (username: string, password: string) =>
(dispatch: AppDispatch) => dispatch(getAuthApp()).then(() => { (dispatch: AppDispatch) => dispatch(getAuthApp()).then(() => {
return dispatch(createUserToken(normalizeUsername(username), password)); return dispatch(createUserToken(normalizeUsername(username), password));
}).catch((error: AxiosError) => { }).catch(async (error) => {
if ((error.response?.data as any)?.error === 'mfa_required') { if (error instanceof HTTPError) {
const data = await error.response.error();
if (data) {
if (data.error === 'mfa_required' && 'mfa_token' in data && typeof data.mfa_token === 'string') {
// If MFA is required, throw the error and handle it in the component. // If MFA is required, throw the error and handle it in the component.
throw error; throw new MfaRequiredError(data.mfa_token);
} else if ((error.response?.data as any)?.identifier === 'awaiting_approval') { } else if (data.error === 'awaiting_approval') {
toast.error(messages.awaitingApproval); toast.error(messages.awaitingApproval);
} else { } else {
// Return "wrong password" message. // Return "wrong password" message.
toast.error(messages.invalidCredentials); toast.error(messages.invalidCredentials);
} }
}
}
throw error; throw error;
}); });

View File

@ -13,7 +13,7 @@ export const BACKUPS_CREATE_FAIL = 'BACKUPS_CREATE_FAIL';
export const fetchBackups = () => export const fetchBackups = () =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: BACKUPS_FETCH_REQUEST }); dispatch({ type: BACKUPS_FETCH_REQUEST });
return api(getState).get('/api/v1/pleroma/backups').then(({ data: backups }) => return api(getState).get('/api/v1/pleroma/backups').then((response) => response.json()).then((backups) =>
dispatch({ type: BACKUPS_FETCH_SUCCESS, backups }), dispatch({ type: BACKUPS_FETCH_SUCCESS, backups }),
).catch(error => { ).catch(error => {
dispatch({ type: BACKUPS_FETCH_FAIL, error }); dispatch({ type: BACKUPS_FETCH_FAIL, error });
@ -23,7 +23,7 @@ export const fetchBackups = () =>
export const createBackup = () => export const createBackup = () =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: BACKUPS_CREATE_REQUEST }); dispatch({ type: BACKUPS_CREATE_REQUEST });
return api(getState).post('/api/v1/pleroma/backups').then(({ data: backups }) => return api(getState).post('/api/v1/pleroma/backups').then((response) => response.json()).then((backups) =>
dispatch({ type: BACKUPS_CREATE_SUCCESS, backups }), dispatch({ type: BACKUPS_CREATE_SUCCESS, backups }),
).catch(error => { ).catch(error => {
dispatch({ type: BACKUPS_CREATE_FAIL, error }); dispatch({ type: BACKUPS_CREATE_FAIL, error });

View File

@ -1,190 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { __stub } from 'soapbox/api/index.ts';
import { mockStore, rootState } from 'soapbox/jest/test-helpers.tsx';
import { ListRecord, ReducerRecord as UserListsRecord } from 'soapbox/reducers/user-lists.ts';
import { expandBlocks, fetchBlocks } from './blocks.ts';
const account = {
acct: 'twoods',
display_name: 'Tiger Woods',
id: '22',
username: 'twoods',
};
describe('fetchBlocks()', () => {
let store: ReturnType<typeof mockStore>;
describe('if logged out', () => {
beforeEach(() => {
const state = { ...rootState, me: null };
store = mockStore(state);
});
it('should do nothing', async() => {
await store.dispatch(fetchBlocks());
const actions = store.getActions();
expect(actions).toEqual([]);
});
});
describe('if logged in', () => {
beforeEach(() => {
const state = { ...rootState, me: '1234' };
store = mockStore(state);
});
describe('with a successful API request', () => {
beforeEach(async () => {
const blocks = await import('soapbox/__fixtures__/blocks.json');
__stub((mock) => {
mock.onGet('/api/v1/blocks').reply(200, blocks, {
link: '<https://example.com/api/v1/blocks?since_id=1>; rel=\'prev\'',
});
});
});
it('should fetch blocks from the API', async() => {
const expectedActions = [
{ type: 'BLOCKS_FETCH_REQUEST' },
{ type: 'ACCOUNTS_IMPORT', accounts: [account] },
{ type: 'BLOCKS_FETCH_SUCCESS', accounts: [account], next: null },
{
type: 'RELATIONSHIPS_FETCH_REQUEST',
ids: ['22'],
skipLoading: true,
},
];
await store.dispatch(fetchBlocks());
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
describe('with an unsuccessful API request', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v1/blocks').networkError();
});
});
it('should dispatch failed action', async() => {
const expectedActions = [
{ type: 'BLOCKS_FETCH_REQUEST' },
{ type: 'BLOCKS_FETCH_FAIL', error: new Error('Network Error') },
];
await store.dispatch(fetchBlocks());
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
});
});
describe('expandBlocks()', () => {
let store: ReturnType<typeof mockStore>;
describe('if logged out', () => {
beforeEach(() => {
const state = { ...rootState, me: null };
store = mockStore(state);
});
it('should do nothing', async() => {
await store.dispatch(expandBlocks());
const actions = store.getActions();
expect(actions).toEqual([]);
});
});
describe('if logged in', () => {
beforeEach(() => {
const state = { ...rootState, me: '1234' };
store = mockStore(state);
});
describe('without a url', () => {
beforeEach(() => {
const state = {
...rootState,
me: '1234',
user_lists: UserListsRecord({ blocks: ListRecord({ next: null }) }),
};
store = mockStore(state);
});
it('should do nothing', async() => {
await store.dispatch(expandBlocks());
const actions = store.getActions();
expect(actions).toEqual([]);
});
});
describe('with a url', () => {
beforeEach(() => {
const state = {
...rootState,
me: '1234',
user_lists: UserListsRecord({ blocks: ListRecord({ next: 'example' }) }),
};
store = mockStore(state);
});
describe('with a successful API request', () => {
beforeEach(async () => {
const blocks = await import('soapbox/__fixtures__/blocks.json');
__stub((mock) => {
mock.onGet('example').reply(200, blocks, {
link: '<https://example.com/api/v1/blocks?since_id=1>; rel=\'prev\'',
});
});
});
it('should fetch blocks from the url', async() => {
const expectedActions = [
{ type: 'BLOCKS_EXPAND_REQUEST' },
{ type: 'ACCOUNTS_IMPORT', accounts: [account] },
{ type: 'BLOCKS_EXPAND_SUCCESS', accounts: [account], next: null },
{
type: 'RELATIONSHIPS_FETCH_REQUEST',
ids: ['22'],
skipLoading: true,
},
];
await store.dispatch(expandBlocks());
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
describe('with an unsuccessful API request', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('example').networkError();
});
});
it('should dispatch failed action', async() => {
const expectedActions = [
{ type: 'BLOCKS_EXPAND_REQUEST' },
{ type: 'BLOCKS_EXPAND_FAIL', error: new Error('Network Error') },
];
await store.dispatch(expandBlocks());
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
});
});
});

View File

@ -1,6 +1,6 @@
import { isLoggedIn } from 'soapbox/utils/auth.ts'; import { isLoggedIn } from 'soapbox/utils/auth.ts';
import api, { getLinks } from '../api/index.ts'; import api from '../api/index.ts';
import { fetchRelationships } from './accounts.ts'; import { fetchRelationships } from './accounts.ts';
import { importFetchedAccounts } from './importer/index.ts'; import { importFetchedAccounts } from './importer/index.ts';
@ -22,11 +22,12 @@ const fetchBlocks = () => (dispatch: AppDispatch, getState: () => RootState) =>
return api(getState) return api(getState)
.get('/api/v1/blocks') .get('/api/v1/blocks')
.then(response => { .then(async (response) => {
const next = getLinks(response).refs.find(link => link.rel === 'next'); const next = response.next();
dispatch(importFetchedAccounts(response.data)); const data = await response.json();
dispatch(fetchBlocksSuccess(response.data, next ? next.uri : null)); dispatch(importFetchedAccounts(data));
dispatch(fetchRelationships(response.data.map((item: any) => item.id)) as any); dispatch(fetchBlocksSuccess(data, next));
dispatch(fetchRelationships(data.map((item: any) => item.id)) as any);
}) })
.catch(error => dispatch(fetchBlocksFail(error))); .catch(error => dispatch(fetchBlocksFail(error)));
}; };
@ -63,11 +64,12 @@ const expandBlocks = () => (dispatch: AppDispatch, getState: () => RootState) =>
return api(getState) return api(getState)
.get(url) .get(url)
.then(response => { .then(async (response) => {
const next = getLinks(response).refs.find(link => link.rel === 'next'); const next = response.next();
dispatch(importFetchedAccounts(response.data)); const data = await response.json();
dispatch(expandBlocksSuccess(response.data, next ? next.uri : null)); dispatch(importFetchedAccounts(data));
dispatch(fetchRelationships(response.data.map((item: any) => item.id)) as any); dispatch(expandBlocksSuccess(data, next));
dispatch(fetchRelationships(data.map((item: any) => item.id)) as any);
}) })
.catch(error => dispatch(expandBlocksFail(error))); .catch(error => dispatch(expandBlocksFail(error)));
}; };

View File

@ -1,4 +1,4 @@
import api, { getLinks } from '../api/index.ts'; import api from '../api/index.ts';
import { importFetchedStatuses } from './importer/index.ts'; import { importFetchedStatuses } from './importer/index.ts';
@ -23,10 +23,11 @@ const fetchBookmarkedStatuses = () =>
dispatch(fetchBookmarkedStatusesRequest()); dispatch(fetchBookmarkedStatusesRequest());
return api(getState).get('/api/v1/bookmarks').then(response => { return api(getState).get('/api/v1/bookmarks').then(async (response) => {
const next = getLinks(response).refs.find(link => link.rel === 'next'); const next = response.next();
dispatch(importFetchedStatuses(response.data)); const data = await response.json();
return dispatch(fetchBookmarkedStatusesSuccess(response.data, next ? next.uri : null)); dispatch(importFetchedStatuses(data));
return dispatch(fetchBookmarkedStatusesSuccess(data, next));
}).catch(error => { }).catch(error => {
dispatch(fetchBookmarkedStatusesFail(error)); dispatch(fetchBookmarkedStatusesFail(error));
}); });
@ -58,10 +59,11 @@ const expandBookmarkedStatuses = () =>
dispatch(expandBookmarkedStatusesRequest()); dispatch(expandBookmarkedStatusesRequest());
return api(getState).get(url).then(response => { return api(getState).get(url).then(async (response) => {
const next = getLinks(response).refs.find(link => link.rel === 'next'); const next = response.next();
dispatch(importFetchedStatuses(response.data)); const data = await response.json();
return dispatch(expandBookmarkedStatusesSuccess(response.data, next ? next.uri : null)); dispatch(importFetchedStatuses(data));
return dispatch(expandBookmarkedStatusesSuccess(data, next));
}).catch(error => { }).catch(error => {
dispatch(expandBookmarkedStatusesFail(error)); dispatch(expandBookmarkedStatusesFail(error));
}); });

View File

@ -3,7 +3,7 @@ import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
import { getSettings, changeSetting } from 'soapbox/actions/settings.ts'; import { getSettings, changeSetting } from 'soapbox/actions/settings.ts';
import { getFeatures } from 'soapbox/utils/features.ts'; import { getFeatures } from 'soapbox/utils/features.ts';
import api, { getLinks } from '../api/index.ts'; import api from '../api/index.ts';
import type { AppDispatch, RootState } from 'soapbox/store.ts'; import type { AppDispatch, RootState } from 'soapbox/store.ts';
import type { History } from 'soapbox/types/history.ts'; import type { History } from 'soapbox/types/history.ts';
@ -38,22 +38,19 @@ const CHAT_MESSAGE_DELETE_FAIL = 'CHAT_MESSAGE_DELETE_FAIL';
const fetchChatsV1 = () => const fetchChatsV1 = () =>
(dispatch: AppDispatch, getState: () => RootState) => (dispatch: AppDispatch, getState: () => RootState) =>
api(getState).get('/api/v1/pleroma/chats').then((response) => { api(getState).get('/api/v1/pleroma/chats').then((response) => response.json()).then((data) => {
dispatch({ type: CHATS_FETCH_SUCCESS, chats: response.data }); dispatch({ type: CHATS_FETCH_SUCCESS, chats: data });
}).catch(error => { }).catch(error => {
dispatch({ type: CHATS_FETCH_FAIL, error }); dispatch({ type: CHATS_FETCH_FAIL, error });
}); });
const fetchChatsV2 = () => const fetchChatsV2 = () =>
(dispatch: AppDispatch, getState: () => RootState) => (dispatch: AppDispatch, getState: () => RootState) =>
api(getState).get('/api/v2/pleroma/chats').then((response) => { api(getState).get('/api/v2/pleroma/chats').then(async (response) => {
let next: { uri: string } | undefined = getLinks(response).refs.find(link => link.rel === 'next'); const next = response.next();
const data = await response.json();
if (!next && response.data.length) { dispatch({ type: CHATS_FETCH_SUCCESS, chats: data, next });
next = { uri: `/api/v2/pleroma/chats?max_id=${response.data[response.data.length - 1].id}&offset=0` };
}
dispatch({ type: CHATS_FETCH_SUCCESS, chats: response.data, next: next ? next.uri : null });
}).catch(error => { }).catch(error => {
dispatch({ type: CHATS_FETCH_FAIL, error }); dispatch({ type: CHATS_FETCH_FAIL, error });
}); });
@ -81,10 +78,11 @@ const expandChats = () =>
} }
dispatch({ type: CHATS_EXPAND_REQUEST }); dispatch({ type: CHATS_EXPAND_REQUEST });
api(getState).get(url).then(response => { api(getState).get(url).then(async (response) => {
const next = getLinks(response).refs.find(link => link.rel === 'next'); const next = response.next();
const data = await response.json();
dispatch({ type: CHATS_EXPAND_SUCCESS, chats: response.data, next: next ? next.uri : null }); dispatch({ type: CHATS_EXPAND_SUCCESS, chats: data, next });
}).catch(error => { }).catch(error => {
dispatch({ type: CHATS_EXPAND_FAIL, error }); dispatch({ type: CHATS_EXPAND_FAIL, error });
}); });
@ -93,7 +91,8 @@ const expandChats = () =>
const fetchChatMessages = (chatId: string, maxId: string | null = null) => const fetchChatMessages = (chatId: string, maxId: string | null = null) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: CHAT_MESSAGES_FETCH_REQUEST, chatId, maxId }); dispatch({ type: CHAT_MESSAGES_FETCH_REQUEST, chatId, maxId });
return api(getState).get(`/api/v1/pleroma/chats/${chatId}/messages`, { params: { max_id: maxId } }).then(({ data }) => { const searchParams = maxId ? { max_id: maxId } : undefined;
return api(getState).get(`/api/v1/pleroma/chats/${chatId}/messages`, { searchParams }).then((response) => response.json()).then((data) => {
dispatch({ type: CHAT_MESSAGES_FETCH_SUCCESS, chatId, maxId, chatMessages: data }); dispatch({ type: CHAT_MESSAGES_FETCH_SUCCESS, chatId, maxId, chatMessages: data });
}).catch(error => { }).catch(error => {
dispatch({ type: CHAT_MESSAGES_FETCH_FAIL, chatId, maxId, error }); dispatch({ type: CHAT_MESSAGES_FETCH_FAIL, chatId, maxId, error });
@ -105,7 +104,7 @@ const sendChatMessage = (chatId: string, params: Record<string, any>) =>
const uuid = `末_${Date.now()}_${crypto.randomUUID()}`; const uuid = `末_${Date.now()}_${crypto.randomUUID()}`;
const me = getState().me; const me = getState().me;
dispatch({ type: CHAT_MESSAGE_SEND_REQUEST, chatId, params, uuid, me }); dispatch({ type: CHAT_MESSAGE_SEND_REQUEST, chatId, params, uuid, me });
return api(getState).post(`/api/v1/pleroma/chats/${chatId}/messages`, params).then(({ data }) => { return api(getState).post(`/api/v1/pleroma/chats/${chatId}/messages`, params).then((response) => response.json()).then((data) => {
dispatch({ type: CHAT_MESSAGE_SEND_SUCCESS, chatId, chatMessage: data, uuid }); dispatch({ type: CHAT_MESSAGE_SEND_SUCCESS, chatId, chatMessage: data, uuid });
}).catch(error => { }).catch(error => {
dispatch({ type: CHAT_MESSAGE_SEND_FAIL, chatId, error, uuid }); dispatch({ type: CHAT_MESSAGE_SEND_FAIL, chatId, error, uuid });
@ -164,7 +163,7 @@ const toggleMainWindow = () =>
const fetchChat = (chatId: string) => const fetchChat = (chatId: string) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: CHAT_FETCH_REQUEST, chatId }); dispatch({ type: CHAT_FETCH_REQUEST, chatId });
return api(getState).get(`/api/v1/pleroma/chats/${chatId}`).then(({ data }) => { return api(getState).get(`/api/v1/pleroma/chats/${chatId}`).then((response) => response.json()).then((data) => {
dispatch({ type: CHAT_FETCH_SUCCESS, chat: data }); dispatch({ type: CHAT_FETCH_SUCCESS, chat: data });
}).catch(error => { }).catch(error => {
dispatch({ type: CHAT_FETCH_FAIL, chatId, error }); dispatch({ type: CHAT_FETCH_FAIL, chatId, error });
@ -174,7 +173,7 @@ const fetchChat = (chatId: string) =>
const startChat = (accountId: string) => const startChat = (accountId: string) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: CHAT_FETCH_REQUEST, accountId }); dispatch({ type: CHAT_FETCH_REQUEST, accountId });
return api(getState).post(`/api/v1/pleroma/chats/by-account-id/${accountId}`).then(({ data }) => { return api(getState).post(`/api/v1/pleroma/chats/by-account-id/${accountId}`).then((response) => response.json()).then((data) => {
dispatch({ type: CHAT_FETCH_SUCCESS, chat: data }); dispatch({ type: CHAT_FETCH_SUCCESS, chat: data });
return data; return data;
}).catch(error => { }).catch(error => {
@ -191,7 +190,7 @@ const markChatRead = (chatId: string, lastReadId?: string | null) =>
if (!lastReadId) return; if (!lastReadId) return;
dispatch({ type: CHAT_READ_REQUEST, chatId, lastReadId }); dispatch({ type: CHAT_READ_REQUEST, chatId, lastReadId });
api(getState).post(`/api/v1/pleroma/chats/${chatId}/read`, { last_read_id: lastReadId }).then(({ data }) => { api(getState).post(`/api/v1/pleroma/chats/${chatId}/read`, { last_read_id: lastReadId }).then((response) => response.json()).then((data) => {
dispatch({ type: CHAT_READ_SUCCESS, chat: data, lastReadId }); dispatch({ type: CHAT_READ_SUCCESS, chat: data, lastReadId });
}).catch(error => { }).catch(error => {
dispatch({ type: CHAT_READ_FAIL, chatId, error, lastReadId }); dispatch({ type: CHAT_READ_FAIL, chatId, error, lastReadId });
@ -201,7 +200,7 @@ const markChatRead = (chatId: string, lastReadId?: string | null) =>
const deleteChatMessage = (chatId: string, messageId: string) => const deleteChatMessage = (chatId: string, messageId: string) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: CHAT_MESSAGE_DELETE_REQUEST, chatId, messageId }); dispatch({ type: CHAT_MESSAGE_DELETE_REQUEST, chatId, messageId });
api(getState).delete(`/api/v1/pleroma/chats/${chatId}/messages/${messageId}`).then(({ data }) => { api(getState).delete(`/api/v1/pleroma/chats/${chatId}/messages/${messageId}`).then((response) => response.json()).then((data) => {
dispatch({ type: CHAT_MESSAGE_DELETE_SUCCESS, chatId, messageId, chatMessage: data }); dispatch({ type: CHAT_MESSAGE_DELETE_SUCCESS, chatId, messageId, chatMessage: data });
}).catch(error => { }).catch(error => {
dispatch({ type: CHAT_MESSAGE_DELETE_FAIL, chatId, messageId, error }); dispatch({ type: CHAT_MESSAGE_DELETE_FAIL, chatId, messageId, error });

View File

@ -1,8 +1,8 @@
import axios, { Canceler } from 'axios';
import { throttle } from 'es-toolkit'; import { throttle } from 'es-toolkit';
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList } from 'immutable';
import { defineMessages, IntlShape } from 'react-intl'; import { defineMessages, IntlShape } from 'react-intl';
import { HTTPError } from 'soapbox/api/HTTPError.ts';
import api from 'soapbox/api/index.ts'; import api from 'soapbox/api/index.ts';
import { isNativeEmoji } from 'soapbox/features/emoji/index.ts'; import { isNativeEmoji } from 'soapbox/features/emoji/index.ts';
import emojiSearch from 'soapbox/features/emoji/search.ts'; import emojiSearch from 'soapbox/features/emoji/search.ts';
@ -29,9 +29,7 @@ import type { AppDispatch, RootState } from 'soapbox/store.ts';
import type { APIEntity, Status, Tag } from 'soapbox/types/entities.ts'; import type { APIEntity, Status, Tag } from 'soapbox/types/entities.ts';
import type { History } from 'soapbox/types/history.ts'; import type { History } from 'soapbox/types/history.ts';
const { CancelToken, isCancel } = axios; let cancelFetchComposeSuggestions: AbortController | undefined;
let cancelFetchComposeSuggestions: Canceler;
const COMPOSE_CHANGE = 'COMPOSE_CHANGE' as const; const COMPOSE_CHANGE = 'COMPOSE_CHANGE' as const;
const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST' as const; const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST' as const;
@ -369,9 +367,7 @@ const uploadCompose = (composeId: string, files: FileList, intl: IntlShape) =>
const attachmentLimit = getState().instance.configuration.statuses.max_media_attachments; const attachmentLimit = getState().instance.configuration.statuses.max_media_attachments;
const media = getState().compose.get(composeId)?.media_attachments; const media = getState().compose.get(composeId)?.media_attachments;
const progress = new Array(files.length).fill(0); const progress: number[] = new Array(files.length).fill(0);
let total = Array.from(files).reduce((a, v) => a + v.size, 0);
const mediaCount = media ? media.size : 0; const mediaCount = media ? media.size : 0;
if (files.length + mediaCount > attachmentLimit) { if (files.length + mediaCount > attachmentLimit) {
@ -389,11 +385,10 @@ const uploadCompose = (composeId: string, files: FileList, intl: IntlShape) =>
intl, intl,
(data) => dispatch(uploadComposeSuccess(composeId, data, f)), (data) => dispatch(uploadComposeSuccess(composeId, data, f)),
(error) => dispatch(uploadComposeFail(composeId, error)), (error) => dispatch(uploadComposeFail(composeId, error)),
({ loaded }: any) => { (e: ProgressEvent) => {
progress[i] = loaded; progress[i] = e.loaded;
dispatch(uploadComposeProgress(composeId, progress.reduce((a, v) => a + v, 0), total)); dispatch(uploadComposeProgress(composeId, progress.reduce((a, v) => a + v, 0), e.total));
}, },
(value) => total += value,
)); ));
}); });
@ -433,8 +428,8 @@ const changeUploadCompose = (composeId: string, id: string, params: Record<strin
dispatch(changeUploadComposeRequest(composeId)); dispatch(changeUploadComposeRequest(composeId));
dispatch(updateMedia(id, params)).then(response => { dispatch(updateMedia(id, params)).then((response) => response.json()).then((data) => {
dispatch(changeUploadComposeSuccess(composeId, response.data)); dispatch(changeUploadComposeSuccess(composeId, data));
}).catch(error => { }).catch(error => {
dispatch(changeUploadComposeFail(composeId, id, error)); dispatch(changeUploadComposeFail(composeId, id, error));
}); });
@ -480,9 +475,8 @@ const setGroupTimelineVisible = (composeId: string, groupTimelineVisible: boolea
}); });
const clearComposeSuggestions = (composeId: string) => { const clearComposeSuggestions = (composeId: string) => {
if (cancelFetchComposeSuggestions) { cancelFetchComposeSuggestions?.abort();
cancelFetchComposeSuggestions();
}
return { return {
type: COMPOSE_SUGGESTIONS_CLEAR, type: COMPOSE_SUGGESTIONS_CLEAR,
id: composeId, id: composeId,
@ -490,23 +484,20 @@ const clearComposeSuggestions = (composeId: string) => {
}; };
const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, composeId, token) => { const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, composeId, token) => {
if (cancelFetchComposeSuggestions) { cancelFetchComposeSuggestions?.abort();
cancelFetchComposeSuggestions(composeId);
}
api(getState).get('/api/v1/accounts/search', { api(getState).get('/api/v1/accounts/search', {
cancelToken: new CancelToken(cancel => { signal: cancelFetchComposeSuggestions?.signal,
cancelFetchComposeSuggestions = cancel; searchParams: {
}),
params: {
q: token.slice(1), q: token.slice(1),
resolve: false, resolve: false,
limit: 10, limit: 10,
}, },
}).then(response => { }).then((response) => response.json()).then((data) => {
dispatch(importFetchedAccounts(response.data)); dispatch(importFetchedAccounts(data));
dispatch(readyComposeSuggestionsAccounts(composeId, token, response.data)); dispatch(readyComposeSuggestionsAccounts(composeId, token, data));
}).catch(error => { }).catch(error => {
if (!isCancel(error)) { if (error instanceof HTTPError) {
toast.showAlertForError(error); toast.showAlertForError(error);
} }
}); });
@ -519,9 +510,7 @@ const fetchComposeSuggestionsEmojis = (dispatch: AppDispatch, composeId: string,
}; };
const fetchComposeSuggestionsTags = (dispatch: AppDispatch, getState: () => RootState, composeId: string, token: string) => { const fetchComposeSuggestionsTags = (dispatch: AppDispatch, getState: () => RootState, composeId: string, token: string) => {
if (cancelFetchComposeSuggestions) { cancelFetchComposeSuggestions?.abort();
cancelFetchComposeSuggestions(composeId);
}
const state = getState(); const state = getState();
@ -535,18 +524,16 @@ const fetchComposeSuggestionsTags = (dispatch: AppDispatch, getState: () => Root
} }
api(getState).get('/api/v2/search', { api(getState).get('/api/v2/search', {
cancelToken: new CancelToken(cancel => { signal: cancelFetchComposeSuggestions?.signal,
cancelFetchComposeSuggestions = cancel; searchParams: {
}),
params: {
q: token.slice(1), q: token.slice(1),
limit: 10, limit: 10,
type: 'hashtags', type: 'hashtags',
}, },
}).then(response => { }).then((response) => response.json()).then((data) => {
dispatch(updateSuggestionTags(composeId, token, response.data?.hashtags.map(normalizeTag))); dispatch(updateSuggestionTags(composeId, token, data?.hashtags.map(normalizeTag)));
}).catch(error => { }).catch(error => {
if (!isCancel(error)) { if (error instanceof HTTPError) {
toast.showAlertForError(error); toast.showAlertForError(error);
} }
}); });

View File

@ -1,5 +1,3 @@
import axios from 'axios';
import * as BuildConfig from 'soapbox/build-config.ts'; import * as BuildConfig from 'soapbox/build-config.ts';
import { isURL } from 'soapbox/utils/auth.ts'; import { isURL } from 'soapbox/utils/auth.ts';
import sourceCode from 'soapbox/utils/code.ts'; import sourceCode from 'soapbox/utils/code.ts';
@ -36,17 +34,12 @@ export const prepareRequest = (provider: string) => {
localStorage.setItem('soapbox:external:baseurl', baseURL); localStorage.setItem('soapbox:external:baseurl', baseURL);
localStorage.setItem('soapbox:external:scopes', scopes); localStorage.setItem('soapbox:external:scopes', scopes);
const params = { const query = new URLSearchParams({ provider });
provider,
authorization: {
client_id,
redirect_uri,
scope: scopes,
},
};
const formdata = axios.toFormData(params); // FIXME: I don't know if this is the correct way to encode the query params.
const query = new URLSearchParams(formdata as any); query.append('authorization.client_id', client_id);
query.append('authorization.redirect_uri', redirect_uri);
query.append('authorization.scope', scopes);
location.href = `${baseURL}/oauth/prepare_request?${query.toString()}`; location.href = `${baseURL}/oauth/prepare_request?${query.toString()}`;
}; };

View File

@ -1,6 +1,6 @@
import { isLoggedIn } from 'soapbox/utils/auth.ts'; import { isLoggedIn } from 'soapbox/utils/auth.ts';
import api, { getLinks } from '../api/index.ts'; import api from '../api/index.ts';
import { import {
importFetchedAccounts, importFetchedAccounts,
@ -53,13 +53,14 @@ const expandConversations = ({ maxId }: Record<string, any> = {}) => (dispatch:
const isLoadingRecent = !!params.since_id; const isLoadingRecent = !!params.since_id;
api(getState).get('/api/v1/conversations', { params }) api(getState).get('/api/v1/conversations', { searchParams: params })
.then(response => { .then(async (response) => {
const next = getLinks(response).refs.find(link => link.rel === 'next'); const next = response.next();
const data = await response.json();
dispatch(importFetchedAccounts(response.data.reduce((aggr: Array<APIEntity>, item: APIEntity) => aggr.concat(item.accounts), []))); dispatch(importFetchedAccounts(data.reduce((aggr: Array<APIEntity>, item: APIEntity) => aggr.concat(item.accounts), [])));
dispatch(importFetchedStatuses(response.data.map((item: Record<string, any>) => item.last_status).filter((x?: APIEntity) => !!x))); dispatch(importFetchedStatuses(data.map((item: Record<string, any>) => item.last_status).filter((x?: APIEntity) => !!x)));
dispatch(expandConversationsSuccess(response.data, next ? next.uri : null, isLoadingRecent)); dispatch(expandConversationsSuccess(data, next, isLoadingRecent));
}) })
.catch(err => dispatch(expandConversationsFail(err))); .catch(err => dispatch(expandConversationsFail(err)));
}; };

View File

@ -18,7 +18,7 @@ const fetchDirectory = (params: Record<string, any>) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch(fetchDirectoryRequest()); dispatch(fetchDirectoryRequest());
api(getState).get('/api/v1/directory', { params: { ...params, limit: 20 } }).then(({ data }) => { api(getState).get('/api/v1/directory', { searchParams: { ...params, limit: 20 } }).then((response) => response.json()).then((data) => {
dispatch(importFetchedAccounts(data)); dispatch(importFetchedAccounts(data));
dispatch(fetchDirectorySuccess(data)); dispatch(fetchDirectorySuccess(data));
dispatch(fetchRelationships(data.map((x: APIEntity) => x.id))); dispatch(fetchRelationships(data.map((x: APIEntity) => x.id)));
@ -45,7 +45,7 @@ const expandDirectory = (params: Record<string, any>) =>
const loadedItems = getState().user_lists.directory.items.size; const loadedItems = getState().user_lists.directory.items.size;
api(getState).get('/api/v1/directory', { params: { ...params, offset: loadedItems, limit: 20 } }).then(({ data }) => { api(getState).get('/api/v1/directory', { searchParams: { ...params, offset: loadedItems, limit: 20 } }).then((response) => response.json()).then((data) => {
dispatch(importFetchedAccounts(data)); dispatch(importFetchedAccounts(data));
dispatch(expandDirectorySuccess(data)); dispatch(expandDirectorySuccess(data));
dispatch(fetchRelationships(data.map((x: APIEntity) => x.id))); dispatch(fetchRelationships(data.map((x: APIEntity) => x.id)));

View File

@ -1,7 +1,7 @@
import { Entities } from 'soapbox/entity-store/entities.ts'; import { Entities } from 'soapbox/entity-store/entities.ts';
import { isLoggedIn } from 'soapbox/utils/auth.ts'; import { isLoggedIn } from 'soapbox/utils/auth.ts';
import api, { getLinks } from '../api/index.ts'; import api from '../api/index.ts';
import type { EntityStore } from 'soapbox/entity-store/types.ts'; import type { EntityStore } from 'soapbox/entity-store/types.ts';
import type { Account } from 'soapbox/schemas/index.ts'; import type { Account } from 'soapbox/schemas/index.ts';
@ -61,13 +61,10 @@ const unblockDomain = (domain: string) =>
dispatch(unblockDomainRequest(domain)); dispatch(unblockDomainRequest(domain));
// Do it both ways for maximum compatibility const data = new FormData();
const params = { data.append('domain', domain);
params: { domain },
data: { domain },
};
api(getState).delete('/api/v1/domain_blocks', params).then(() => { api(getState).request('DELETE', '/api/v1/domain_blocks', data).then(() => {
const accounts = selectAccountsByDomain(getState(), domain); const accounts = selectAccountsByDomain(getState(), domain);
if (!accounts) return; if (!accounts) return;
dispatch(unblockDomainSuccess(domain, accounts)); dispatch(unblockDomainSuccess(domain, accounts));
@ -99,9 +96,10 @@ const fetchDomainBlocks = () =>
dispatch(fetchDomainBlocksRequest()); dispatch(fetchDomainBlocksRequest());
api(getState).get('/api/v1/domain_blocks').then(response => { api(getState).get('/api/v1/domain_blocks').then(async (response) => {
const next = getLinks(response).refs.find(link => link.rel === 'next'); const next = response.next();
dispatch(fetchDomainBlocksSuccess(response.data, next ? next.uri : null)); const data = await response.json();
dispatch(fetchDomainBlocksSuccess(data, next));
}).catch(err => { }).catch(err => {
dispatch(fetchDomainBlocksFail(err)); dispatch(fetchDomainBlocksFail(err));
}); });
@ -134,9 +132,10 @@ const expandDomainBlocks = () =>
dispatch(expandDomainBlocksRequest()); dispatch(expandDomainBlocksRequest());
api(getState).get(url).then(response => { api(getState).get(url).then(async (response) => {
const next = getLinks(response).refs.find(link => link.rel === 'next'); const next = response.next();
dispatch(expandDomainBlocksSuccess(response.data, next ? next.uri : null)); const data = await response.json();
dispatch(expandDomainBlocksSuccess(data, next));
}).catch(err => { }).catch(err => {
dispatch(expandDomainBlocksFail(err)); dispatch(expandDomainBlocksFail(err));
}); });

View File

@ -59,11 +59,11 @@ const fetchEmojiReacts = (id: string, emoji: string) =>
? `/api/v1/pleroma/statuses/${id}/reactions/${emoji}` ? `/api/v1/pleroma/statuses/${id}/reactions/${emoji}`
: `/api/v1/pleroma/statuses/${id}/reactions`; : `/api/v1/pleroma/statuses/${id}/reactions`;
return api(getState).get(url).then(response => { return api(getState).get(url).then((response) => response.json()).then((data) => {
response.data.forEach((emojiReact: APIEntity) => { data.forEach((emojiReact: APIEntity) => {
dispatch(importFetchedAccounts(emojiReact.accounts)); dispatch(importFetchedAccounts(emojiReact.accounts));
}); });
dispatch(fetchEmojiReactsSuccess(id, response.data)); dispatch(fetchEmojiReactsSuccess(id, data));
}).catch(error => { }).catch(error => {
dispatch(fetchEmojiReactsFail(id, error)); dispatch(fetchEmojiReactsFail(id, error));
}); });
@ -77,10 +77,10 @@ const emojiReact = (status: Status, emoji: string, custom?: string) =>
return api(getState) return api(getState)
.put(`/api/v1/pleroma/statuses/${status.id}/reactions/${emoji}`) .put(`/api/v1/pleroma/statuses/${status.id}/reactions/${emoji}`)
.then(function(response) { .then((response) => response.json()).then((data) => {
dispatch(importFetchedStatus(response.data)); dispatch(importFetchedStatus(data));
dispatch(emojiReactSuccess(status, emoji)); dispatch(emojiReactSuccess(status, emoji));
}).catch(function(error) { }).catch((error) => {
dispatch(emojiReactFail(status, emoji, error)); dispatch(emojiReactFail(status, emoji, error));
}); });
}; };
@ -93,8 +93,8 @@ const unEmojiReact = (status: Status, emoji: string) =>
return api(getState) return api(getState)
.delete(`/api/v1/pleroma/statuses/${status.id}/reactions/${emoji}`) .delete(`/api/v1/pleroma/statuses/${status.id}/reactions/${emoji}`)
.then(response => { .then((response) => response.json()).then((data) => {
dispatch(importFetchedStatus(response.data)); dispatch(importFetchedStatus(data));
dispatch(unEmojiReactSuccess(status, emoji)); dispatch(unEmojiReactSuccess(status, emoji));
}).catch(error => { }).catch(error => {
dispatch(unEmojiReactFail(status, emoji, error)); dispatch(unEmojiReactFail(status, emoji, error));

View File

@ -1,6 +1,6 @@
import { defineMessages, IntlShape } from 'react-intl'; import { defineMessages, IntlShape } from 'react-intl';
import api, { getLinks } from 'soapbox/api/index.ts'; import api from 'soapbox/api/index.ts';
import toast from 'soapbox/toast.tsx'; import toast from 'soapbox/toast.tsx';
import { importFetchedAccounts, importFetchedStatus, importFetchedStatuses } from './importer/index.ts'; import { importFetchedAccounts, importFetchedStatus, importFetchedStatuses } from './importer/index.ts';
@ -97,7 +97,7 @@ const messages = defineMessages({
const locationSearch = (query: string, signal?: AbortSignal) => const locationSearch = (query: string, signal?: AbortSignal) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: LOCATION_SEARCH_REQUEST, query }); dispatch({ type: LOCATION_SEARCH_REQUEST, query });
return api(getState).get('/api/v1/pleroma/search/location', { params: { q: query }, signal }).then(({ data: locations }) => { return api(getState).get('/api/v1/pleroma/search/location', { searchParams: { q: query }, signal }).then((response) => response.json()).then((locations) => {
dispatch({ type: LOCATION_SEARCH_SUCCESS, locations }); dispatch({ type: LOCATION_SEARCH_SUCCESS, locations });
return locations; return locations;
}).catch(error => { }).catch(error => {
@ -161,7 +161,7 @@ const uploadEventBanner = (file: File, intl: IntlShape) =>
intl, intl,
(data) => dispatch(uploadEventBannerSuccess(data, file)), (data) => dispatch(uploadEventBannerSuccess(data, file)),
(error) => dispatch(uploadEventBannerFail(error)), (error) => dispatch(uploadEventBannerFail(error)),
({ loaded }: any) => { ({ loaded }: ProgressEvent) => {
progress = loaded; progress = loaded;
dispatch(uploadEventBannerProgress(progress)); dispatch(uploadEventBannerProgress(progress));
}, },
@ -223,11 +223,10 @@ const submitEvent = () =>
if (banner) params.banner_id = banner.id; if (banner) params.banner_id = banner.id;
if (location) params.location_id = location.origin_id; if (location) params.location_id = location.origin_id;
return api(getState).request({ const method = id === null ? 'POST' : 'PUT';
url: id === null ? '/api/v1/pleroma/events' : `/api/v1/pleroma/events/${id}`, const path = id === null ? '/api/v1/pleroma/events' : `/api/v1/pleroma/events/${id}`;
method: id === null ? 'post' : 'put',
data: params, return api(getState).request(method, path, params).then((response) => response.json()).then((data) => {
}).then(({ data }) => {
dispatch(closeModal('COMPOSE_EVENT')); dispatch(closeModal('COMPOSE_EVENT'));
dispatch(importFetchedStatus(data)); dispatch(importFetchedStatus(data));
dispatch(submitEventSuccess(data)); dispatch(submitEventSuccess(data));
@ -269,7 +268,7 @@ const joinEvent = (id: string, participationMessage?: string) =>
return api(getState).post(`/api/v1/pleroma/events/${id}/join`, { return api(getState).post(`/api/v1/pleroma/events/${id}/join`, {
participation_message: participationMessage, participation_message: participationMessage,
}).then(({ data }) => { }).then((response) => response.json()).then((data) => {
dispatch(importFetchedStatus(data)); dispatch(importFetchedStatus(data));
dispatch(joinEventSuccess(data)); dispatch(joinEventSuccess(data));
toast.success( toast.success(
@ -311,7 +310,7 @@ const leaveEvent = (id: string) =>
dispatch(leaveEventRequest(status)); dispatch(leaveEventRequest(status));
return api(getState).post(`/api/v1/pleroma/events/${id}/leave`).then(({ data }) => { return api(getState).post(`/api/v1/pleroma/events/${id}/leave`).then((response) => response.json()).then((data) => {
dispatch(importFetchedStatus(data)); dispatch(importFetchedStatus(data));
dispatch(leaveEventSuccess(data)); dispatch(leaveEventSuccess(data));
}).catch(function(error) { }).catch(function(error) {
@ -339,10 +338,11 @@ const fetchEventParticipations = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch(fetchEventParticipationsRequest(id)); dispatch(fetchEventParticipationsRequest(id));
return api(getState).get(`/api/v1/pleroma/events/${id}/participations`).then(response => { return api(getState).get(`/api/v1/pleroma/events/${id}/participations`).then(async (response) => {
const next = getLinks(response).refs.find(link => link.rel === 'next'); const next = response.next();
dispatch(importFetchedAccounts(response.data)); const data = await response.json();
return dispatch(fetchEventParticipationsSuccess(id, response.data, next ? next.uri : null)); dispatch(importFetchedAccounts(data));
return dispatch(fetchEventParticipationsSuccess(id, data, next));
}).catch(error => { }).catch(error => {
dispatch(fetchEventParticipationsFail(id, error)); dispatch(fetchEventParticipationsFail(id, error));
}); });
@ -376,10 +376,11 @@ const expandEventParticipations = (id: string) =>
dispatch(expandEventParticipationsRequest(id)); dispatch(expandEventParticipationsRequest(id));
return api(getState).get(url).then(response => { return api(getState).get(url).then(async (response) => {
const next = getLinks(response).refs.find(link => link.rel === 'next'); const next = response.next();
dispatch(importFetchedAccounts(response.data)); const data = await response.json();
return dispatch(expandEventParticipationsSuccess(id, response.data, next ? next.uri : null)); dispatch(importFetchedAccounts(data));
return dispatch(expandEventParticipationsSuccess(id, data, next));
}).catch(error => { }).catch(error => {
dispatch(expandEventParticipationsFail(id, error)); dispatch(expandEventParticipationsFail(id, error));
}); });
@ -407,10 +408,11 @@ const fetchEventParticipationRequests = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch(fetchEventParticipationRequestsRequest(id)); dispatch(fetchEventParticipationRequestsRequest(id));
return api(getState).get(`/api/v1/pleroma/events/${id}/participation_requests`).then(response => { return api(getState).get(`/api/v1/pleroma/events/${id}/participation_requests`).then(async (response) => {
const next = getLinks(response).refs.find(link => link.rel === 'next'); const next = response.next();
dispatch(importFetchedAccounts(response.data.map(({ account }: APIEntity) => account))); const data = await response.json();
return dispatch(fetchEventParticipationRequestsSuccess(id, response.data, next ? next.uri : null)); dispatch(importFetchedAccounts(data.map(({ account }: APIEntity) => account)));
return dispatch(fetchEventParticipationRequestsSuccess(id, data, next));
}).catch(error => { }).catch(error => {
dispatch(fetchEventParticipationRequestsFail(id, error)); dispatch(fetchEventParticipationRequestsFail(id, error));
}); });
@ -444,10 +446,11 @@ const expandEventParticipationRequests = (id: string) =>
dispatch(expandEventParticipationRequestsRequest(id)); dispatch(expandEventParticipationRequestsRequest(id));
return api(getState).get(url).then(response => { return api(getState).get(url).then(async (response) => {
const next = getLinks(response).refs.find(link => link.rel === 'next'); const next = response.next();
dispatch(importFetchedAccounts(response.data.map(({ account }: APIEntity) => account))); const data = await response.json();
return dispatch(expandEventParticipationRequestsSuccess(id, response.data, next ? next.uri : null)); dispatch(importFetchedAccounts(data.map(({ account }: APIEntity) => account)));
return dispatch(expandEventParticipationRequestsSuccess(id, data, next));
}).catch(error => { }).catch(error => {
dispatch(expandEventParticipationRequestsFail(id, error)); dispatch(expandEventParticipationRequestsFail(id, error));
}); });
@ -555,13 +558,13 @@ const editEvent = (id: string) => (dispatch: AppDispatch, getState: () => RootSt
dispatch({ type: STATUS_FETCH_SOURCE_REQUEST }); dispatch({ type: STATUS_FETCH_SOURCE_REQUEST });
api(getState).get(`/api/v1/statuses/${id}/source`).then(response => { api(getState).get(`/api/v1/statuses/${id}/source`).then((response) => response.json()).then((data) => {
dispatch({ type: STATUS_FETCH_SOURCE_SUCCESS }); dispatch({ type: STATUS_FETCH_SOURCE_SUCCESS });
dispatch({ dispatch({
type: EVENT_FORM_SET, type: EVENT_FORM_SET,
status, status,
text: response.data.text, text: data.text,
location: response.data.location, location: data.location,
}); });
dispatch(openModal('COMPOSE_EVENT')); dispatch(openModal('COMPOSE_EVENT'));
}).catch(error => { }).catch(error => {
@ -577,13 +580,15 @@ const fetchRecentEvents = () =>
dispatch({ type: RECENT_EVENTS_FETCH_REQUEST }); dispatch({ type: RECENT_EVENTS_FETCH_REQUEST });
api(getState).get('/api/v1/timelines/public?only_events=true').then(response => { api(getState).get('/api/v1/timelines/public?only_events=true').then(async (response) => {
const next = getLinks(response).refs.find(link => link.rel === 'next'); const next = response.next();
dispatch(importFetchedStatuses(response.data)); const data = await response.json();
dispatch(importFetchedStatuses(data));
dispatch({ dispatch({
type: RECENT_EVENTS_FETCH_SUCCESS, type: RECENT_EVENTS_FETCH_SUCCESS,
statuses: response.data, statuses: data,
next: next ? next.uri : null, next,
}); });
}).catch(error => { }).catch(error => {
dispatch({ type: RECENT_EVENTS_FETCH_FAIL, error }); dispatch({ type: RECENT_EVENTS_FETCH_FAIL, error });
@ -598,13 +603,15 @@ const fetchJoinedEvents = () =>
dispatch({ type: JOINED_EVENTS_FETCH_REQUEST }); dispatch({ type: JOINED_EVENTS_FETCH_REQUEST });
api(getState).get('/api/v1/pleroma/events/joined_events').then(response => { api(getState).get('/api/v1/pleroma/events/joined_events').then(async (response) => {
const next = getLinks(response).refs.find(link => link.rel === 'next'); const next = response.next();
dispatch(importFetchedStatuses(response.data)); const data = await response.json();
dispatch(importFetchedStatuses(data));
dispatch({ dispatch({
type: JOINED_EVENTS_FETCH_SUCCESS, type: JOINED_EVENTS_FETCH_SUCCESS,
statuses: response.data, statuses: data,
next: next ? next.uri : null, next,
}); });
}).catch(error => { }).catch(error => {
dispatch({ type: JOINED_EVENTS_FETCH_FAIL, error }); dispatch({ type: JOINED_EVENTS_FETCH_FAIL, error });

View File

@ -1,10 +1,10 @@
import { defineMessages } from 'react-intl'; import { defineMessages } from 'react-intl';
import api, { getLinks } from 'soapbox/api/index.ts'; import { MastodonResponse } from 'soapbox/api/MastodonResponse.ts';
import api from 'soapbox/api/index.ts';
import { normalizeAccount } from 'soapbox/normalizers/index.ts'; import { normalizeAccount } from 'soapbox/normalizers/index.ts';
import toast from 'soapbox/toast.tsx'; import toast from 'soapbox/toast.tsx';
import type { AxiosResponse } from 'axios';
import type { RootState } from 'soapbox/store.ts'; import type { RootState } from 'soapbox/store.ts';
export const EXPORT_FOLLOWS_REQUEST = 'EXPORT_FOLLOWS_REQUEST'; export const EXPORT_FOLLOWS_REQUEST = 'EXPORT_FOLLOWS_REQUEST';
@ -49,18 +49,31 @@ function fileExport(content: string, fileName: string) {
document.body.removeChild(fileToDownload); document.body.removeChild(fileToDownload);
} }
const listAccounts = (getState: () => RootState) => async(apiResponse: AxiosResponse<any, any>) => { const listAccounts = (getState: () => RootState) => {
const followings = apiResponse.data; return async(response: MastodonResponse) => {
let accounts = []; let { next } = response.pagination();
let next = getLinks(apiResponse).refs.find(link => link.rel === 'next'); const data = await response.json();
while (next) {
apiResponse = await api(getState).get(next.uri); const map = new Map<string, Record<string, any>>();
next = getLinks(apiResponse).refs.find(link => link.rel === 'next');
Array.prototype.push.apply(followings, apiResponse.data); for (const account of data) {
map.set(account.id, account);
} }
accounts = followings.map((account: any) => normalizeAccount(account).fqn); while (next) {
return Array.from(new Set(accounts)); const response = await api(getState).get(next);
next = response.pagination().next;
const data = await response.json();
for (const account of data) {
map.set(account.id, account);
}
}
const accts = [...map.values()].map((account) => normalizeAccount(account).fqn);
return accts;
};
}; };
export const exportFollows = () => (dispatch: React.Dispatch<ExportDataActions>, getState: () => RootState) => { export const exportFollows = () => (dispatch: React.Dispatch<ExportDataActions>, getState: () => RootState) => {

View File

@ -21,7 +21,7 @@ import type { AppDispatch, RootState } from 'soapbox/store.ts';
const fetchExternalInstance = (baseURL?: string) => { const fetchExternalInstance = (baseURL?: string) => {
return baseClient(null, baseURL) return baseClient(null, baseURL)
.get('/api/v1/instance') .get('/api/v1/instance')
.then(({ data: instance }) => instanceV1Schema.parse(instance)) .then((response) => response.json()).then((instance) => instanceV1Schema.parse(instance))
.catch(error => { .catch(error => {
if (error.response?.status === 401) { if (error.response?.status === 401) {
// Authenticated fetch is enabled. // Authenticated fetch is enabled.

View File

@ -18,7 +18,7 @@ export const fetchAccountFamiliarFollowers = (accountId: string) => (dispatch: A
}); });
api(getState).get(`/api/v1/accounts/familiar_followers?id[]=${accountId}`) api(getState).get(`/api/v1/accounts/familiar_followers?id[]=${accountId}`)
.then(({ data }) => { .then((response) => response.json()).then((data) => {
const accounts = data.find(({ id }: { id: string }) => id === accountId).accounts; const accounts = data.find(({ id }: { id: string }) => id === accountId).accounts;
dispatch(importFetchedAccounts(accounts)); dispatch(importFetchedAccounts(accounts));

View File

@ -1,6 +1,6 @@
import { isLoggedIn } from 'soapbox/utils/auth.ts'; import { isLoggedIn } from 'soapbox/utils/auth.ts';
import api, { getLinks } from '../api/index.ts'; import api from '../api/index.ts';
import { importFetchedStatuses } from './importer/index.ts'; import { importFetchedStatuses } from './importer/index.ts';
@ -33,10 +33,11 @@ const fetchFavouritedStatuses = () =>
dispatch(fetchFavouritedStatusesRequest()); dispatch(fetchFavouritedStatusesRequest());
api(getState).get('/api/v1/favourites').then(response => { api(getState).get('/api/v1/favourites').then(async (response) => {
const next = getLinks(response).refs.find(link => link.rel === 'next'); const next = response.next();
dispatch(importFetchedStatuses(response.data)); const data = await response.json();
dispatch(fetchFavouritedStatusesSuccess(response.data, next ? next.uri : null)); dispatch(importFetchedStatuses(data));
dispatch(fetchFavouritedStatusesSuccess(data, next));
}).catch(error => { }).catch(error => {
dispatch(fetchFavouritedStatusesFail(error)); dispatch(fetchFavouritedStatusesFail(error));
}); });
@ -72,10 +73,11 @@ const expandFavouritedStatuses = () =>
dispatch(expandFavouritedStatusesRequest()); dispatch(expandFavouritedStatusesRequest());
api(getState).get(url).then(response => { api(getState).get(url).then(async (response) => {
const next = getLinks(response).refs.find(link => link.rel === 'next'); const next = response.next();
dispatch(importFetchedStatuses(response.data)); const data = await response.json();
dispatch(expandFavouritedStatusesSuccess(response.data, next ? next.uri : null)); dispatch(importFetchedStatuses(data));
dispatch(expandFavouritedStatusesSuccess(data, next));
}).catch(error => { }).catch(error => {
dispatch(expandFavouritedStatusesFail(error)); dispatch(expandFavouritedStatusesFail(error));
}); });
@ -106,10 +108,11 @@ const fetchAccountFavouritedStatuses = (accountId: string) =>
dispatch(fetchAccountFavouritedStatusesRequest(accountId)); dispatch(fetchAccountFavouritedStatusesRequest(accountId));
api(getState).get(`/api/v1/pleroma/accounts/${accountId}/favourites`).then(response => { api(getState).get(`/api/v1/pleroma/accounts/${accountId}/favourites`).then(async (response) => {
const next = getLinks(response).refs.find(link => link.rel === 'next'); const next = response.next();
dispatch(importFetchedStatuses(response.data)); const data = await response.json();
dispatch(fetchAccountFavouritedStatusesSuccess(accountId, response.data, next ? next.uri : null)); dispatch(importFetchedStatuses(data));
dispatch(fetchAccountFavouritedStatusesSuccess(accountId, data, next));
}).catch(error => { }).catch(error => {
dispatch(fetchAccountFavouritedStatusesFail(accountId, error)); dispatch(fetchAccountFavouritedStatusesFail(accountId, error));
}); });
@ -148,10 +151,11 @@ const expandAccountFavouritedStatuses = (accountId: string) =>
dispatch(expandAccountFavouritedStatusesRequest(accountId)); dispatch(expandAccountFavouritedStatusesRequest(accountId));
api(getState).get(url).then(response => { api(getState).get(url).then(async (response) => {
const next = getLinks(response).refs.find(link => link.rel === 'next'); const next = response.next();
dispatch(importFetchedStatuses(response.data)); const data = await response.json();
dispatch(expandAccountFavouritedStatusesSuccess(accountId, response.data, next ? next.uri : null)); dispatch(importFetchedStatuses(data));
dispatch(expandAccountFavouritedStatusesSuccess(accountId, data, next));
}).catch(error => { }).catch(error => {
dispatch(expandAccountFavouritedStatusesFail(accountId, error)); dispatch(expandAccountFavouritedStatusesFail(accountId, error));
}); });

View File

@ -44,7 +44,7 @@ const fetchFiltersV1 = () =>
return api(getState) return api(getState)
.get('/api/v1/filters') .get('/api/v1/filters')
.then(({ data }) => dispatch({ .then((response) => response.json()).then((data) => dispatch({
type: FILTERS_FETCH_SUCCESS, type: FILTERS_FETCH_SUCCESS,
filters: data, filters: data,
skipLoading: true, skipLoading: true,
@ -66,7 +66,7 @@ const fetchFiltersV2 = () =>
return api(getState) return api(getState)
.get('/api/v2/filters') .get('/api/v2/filters')
.then(({ data }) => dispatch({ .then((response) => response.json()).then((data) => dispatch({
type: FILTERS_FETCH_SUCCESS, type: FILTERS_FETCH_SUCCESS,
filters: data, filters: data,
skipLoading: true, skipLoading: true,
@ -101,7 +101,7 @@ const fetchFilterV1 = (id: string) =>
return api(getState) return api(getState)
.get(`/api/v1/filters/${id}`) .get(`/api/v1/filters/${id}`)
.then(({ data }) => dispatch({ .then((response) => response.json()).then((data) => dispatch({
type: FILTER_FETCH_SUCCESS, type: FILTER_FETCH_SUCCESS,
filter: data, filter: data,
skipLoading: true, skipLoading: true,
@ -123,7 +123,7 @@ const fetchFilterV2 = (id: string) =>
return api(getState) return api(getState)
.get(`/api/v2/filters/${id}`) .get(`/api/v2/filters/${id}`)
.then(({ data }) => dispatch({ .then((response) => response.json()).then((data) => dispatch({
type: FILTER_FETCH_SUCCESS, type: FILTER_FETCH_SUCCESS,
filter: data, filter: data,
skipLoading: true, skipLoading: true,
@ -156,8 +156,8 @@ const createFilterV1 = (title: string, expires_in: string | null, context: Array
irreversible: hide, irreversible: hide,
whole_word: keywords[0].whole_word, whole_word: keywords[0].whole_word,
expires_in, expires_in,
}).then(response => { }).then((response) => response.json()).then((data) => {
dispatch({ type: FILTERS_CREATE_SUCCESS, filter: response.data }); dispatch({ type: FILTERS_CREATE_SUCCESS, filter: data });
toast.success(messages.added); toast.success(messages.added);
}).catch(error => { }).catch(error => {
dispatch({ type: FILTERS_CREATE_FAIL, error }); dispatch({ type: FILTERS_CREATE_FAIL, error });
@ -173,8 +173,8 @@ const createFilterV2 = (title: string, expires_in: string | null, context: Array
filter_action: hide ? 'hide' : 'warn', filter_action: hide ? 'hide' : 'warn',
expires_in, expires_in,
keywords_attributes, keywords_attributes,
}).then(response => { }).then((response) => response.json()).then((data) => {
dispatch({ type: FILTERS_CREATE_SUCCESS, filter: response.data }); dispatch({ type: FILTERS_CREATE_SUCCESS, filter: data });
toast.success(messages.added); toast.success(messages.added);
}).catch(error => { }).catch(error => {
dispatch({ type: FILTERS_CREATE_FAIL, error }); dispatch({ type: FILTERS_CREATE_FAIL, error });
@ -201,8 +201,8 @@ const updateFilterV1 = (id: string, title: string, expires_in: string | null, co
irreversible: hide, irreversible: hide,
whole_word: keywords[0].whole_word, whole_word: keywords[0].whole_word,
expires_in, expires_in,
}).then(response => { }).then((response) => response.json()).then((data) => {
dispatch({ type: FILTERS_UPDATE_SUCCESS, filter: response.data }); dispatch({ type: FILTERS_UPDATE_SUCCESS, filter: data });
toast.success(messages.added); toast.success(messages.added);
}).catch(error => { }).catch(error => {
dispatch({ type: FILTERS_UPDATE_FAIL, error }); dispatch({ type: FILTERS_UPDATE_FAIL, error });
@ -218,8 +218,8 @@ const updateFilterV2 = (id: string, title: string, expires_in: string | null, co
filter_action: hide ? 'hide' : 'warn', filter_action: hide ? 'hide' : 'warn',
expires_in, expires_in,
keywords_attributes, keywords_attributes,
}).then(response => { }).then((response) => response.json()).then((data) => {
dispatch({ type: FILTERS_UPDATE_SUCCESS, filter: response.data }); dispatch({ type: FILTERS_UPDATE_SUCCESS, filter: data });
toast.success(messages.added); toast.success(messages.added);
}).catch(error => { }).catch(error => {
dispatch({ type: FILTERS_UPDATE_FAIL, error }); dispatch({ type: FILTERS_UPDATE_FAIL, error });
@ -240,8 +240,8 @@ const updateFilter = (id: string, title: string, expires_in: string | null, cont
const deleteFilterV1 = (id: string) => const deleteFilterV1 = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: FILTERS_DELETE_REQUEST }); dispatch({ type: FILTERS_DELETE_REQUEST });
return api(getState).delete(`/api/v1/filters/${id}`).then(response => { return api(getState).delete(`/api/v1/filters/${id}`).then((response) => response.json()).then((data) => {
dispatch({ type: FILTERS_DELETE_SUCCESS, filter: response.data }); dispatch({ type: FILTERS_DELETE_SUCCESS, filter: data });
toast.success(messages.removed); toast.success(messages.removed);
}).catch(error => { }).catch(error => {
dispatch({ type: FILTERS_DELETE_FAIL, error }); dispatch({ type: FILTERS_DELETE_FAIL, error });
@ -251,8 +251,8 @@ const deleteFilterV1 = (id: string) =>
const deleteFilterV2 = (id: string) => const deleteFilterV2 = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: FILTERS_DELETE_REQUEST }); dispatch({ type: FILTERS_DELETE_REQUEST });
return api(getState).delete(`/api/v2/filters/${id}`).then(response => { return api(getState).delete(`/api/v2/filters/${id}`).then((response) => response.json()).then((data) => {
dispatch({ type: FILTERS_DELETE_SUCCESS, filter: response.data }); dispatch({ type: FILTERS_DELETE_SUCCESS, filter: data });
toast.success(messages.removed); toast.success(messages.removed);
}).catch(error => { }).catch(error => {
dispatch({ type: FILTERS_DELETE_FAIL, error }); dispatch({ type: FILTERS_DELETE_FAIL, error });

View File

@ -1,6 +1,6 @@
import { deleteEntities } from 'soapbox/entity-store/actions.ts'; import { deleteEntities } from 'soapbox/entity-store/actions.ts';
import api, { getLinks } from '../api/index.ts'; import api from '../api/index.ts';
import { fetchRelationships } from './accounts.ts'; import { fetchRelationships } from './accounts.ts';
import { importFetchedGroups, importFetchedAccounts } from './importer/index.ts'; import { importFetchedGroups, importFetchedAccounts } from './importer/index.ts';
@ -114,7 +114,7 @@ const fetchGroup = (id: string) => (dispatch: AppDispatch, getState: () => RootS
dispatch(fetchGroupRequest(id)); dispatch(fetchGroupRequest(id));
return api(getState).get(`/api/v1/groups/${id}`) return api(getState).get(`/api/v1/groups/${id}`)
.then(({ data }) => { .then((response) => response.json()).then((data) => {
dispatch(importFetchedGroups([data])); dispatch(importFetchedGroups([data]));
dispatch(fetchGroupSuccess(data)); dispatch(fetchGroupSuccess(data));
}) })
@ -141,7 +141,7 @@ const fetchGroups = () => (dispatch: AppDispatch, getState: () => RootState) =>
dispatch(fetchGroupsRequest()); dispatch(fetchGroupsRequest());
return api(getState).get('/api/v1/groups') return api(getState).get('/api/v1/groups')
.then(({ data }) => { .then((response) => response.json()).then((data) => {
dispatch(importFetchedGroups(data)); dispatch(importFetchedGroups(data));
dispatch(fetchGroupsSuccess(data)); dispatch(fetchGroupsSuccess(data));
dispatch(fetchGroupRelationships(data.map((item: APIEntity) => item.id))); dispatch(fetchGroupRelationships(data.map((item: APIEntity) => item.id)));
@ -174,8 +174,8 @@ const fetchGroupRelationships = (groupIds: string[]) =>
dispatch(fetchGroupRelationshipsRequest(newGroupIds)); dispatch(fetchGroupRelationshipsRequest(newGroupIds));
return api(getState).get(`/api/v1/groups/relationships?${newGroupIds.map(id => `id[]=${id}`).join('&')}`).then(response => { return api(getState).get(`/api/v1/groups/relationships?${newGroupIds.map(id => `id[]=${id}`).join('&')}`).then((response) => response.json()).then((data) => {
dispatch(fetchGroupRelationshipsSuccess(response.data)); dispatch(fetchGroupRelationshipsSuccess(data));
}).catch(error => { }).catch(error => {
dispatch(fetchGroupRelationshipsFail(error)); dispatch(fetchGroupRelationshipsFail(error));
}); });
@ -232,11 +232,12 @@ const fetchGroupBlocks = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch(fetchGroupBlocksRequest(id)); dispatch(fetchGroupBlocksRequest(id));
return api(getState).get(`/api/v1/groups/${id}/blocks`).then(response => { return api(getState).get(`/api/v1/groups/${id}/blocks`).then(async (response) => {
const next = getLinks(response).refs.find(link => link.rel === 'next'); const next = response.next();
const data = await response.json();
dispatch(importFetchedAccounts(response.data)); dispatch(importFetchedAccounts(data));
dispatch(fetchGroupBlocksSuccess(id, response.data, next ? next.uri : null)); dispatch(fetchGroupBlocksSuccess(id, data, next));
}).catch(error => { }).catch(error => {
dispatch(fetchGroupBlocksFail(id, error)); dispatch(fetchGroupBlocksFail(id, error));
}); });
@ -271,12 +272,13 @@ const expandGroupBlocks = (id: string) =>
dispatch(expandGroupBlocksRequest(id)); dispatch(expandGroupBlocksRequest(id));
return api(getState).get(url).then(response => { return api(getState).get(url).then(async (response) => {
const next = getLinks(response).refs.find(link => link.rel === 'next'); const next = response.next();
const data = await response.json();
dispatch(importFetchedAccounts(response.data)); dispatch(importFetchedAccounts(data));
dispatch(expandGroupBlocksSuccess(id, response.data, next ? next.uri : null)); dispatch(expandGroupBlocksSuccess(id, data, next));
dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id))); dispatch(fetchRelationships(data.map((item: APIEntity) => item.id)));
}).catch(error => { }).catch(error => {
dispatch(expandGroupBlocksFail(id, error)); dispatch(expandGroupBlocksFail(id, error));
}); });
@ -361,7 +363,8 @@ const groupPromoteAccount = (groupId: string, accountId: string, role: GroupRole
dispatch(groupPromoteAccountRequest(groupId, accountId)); dispatch(groupPromoteAccountRequest(groupId, accountId));
return api(getState).post(`/api/v1/groups/${groupId}/promote`, { account_ids: [accountId], role: role }) return api(getState).post(`/api/v1/groups/${groupId}/promote`, { account_ids: [accountId], role: role })
.then((response) => dispatch(groupPromoteAccountSuccess(groupId, accountId, response.data))) .then((response) => response.json())
.then((data) => dispatch(groupPromoteAccountSuccess(groupId, accountId, data)))
.catch(err => dispatch(groupPromoteAccountFail(groupId, accountId, err))); .catch(err => dispatch(groupPromoteAccountFail(groupId, accountId, err)));
}; };
@ -390,7 +393,8 @@ const groupDemoteAccount = (groupId: string, accountId: string, role: GroupRole)
dispatch(groupDemoteAccountRequest(groupId, accountId)); dispatch(groupDemoteAccountRequest(groupId, accountId));
return api(getState).post(`/api/v1/groups/${groupId}/demote`, { account_ids: [accountId], role: role }) return api(getState).post(`/api/v1/groups/${groupId}/demote`, { account_ids: [accountId], role: role })
.then((response) => dispatch(groupDemoteAccountSuccess(groupId, accountId, response.data))) .then((response) => response.json())
.then((data) => dispatch(groupDemoteAccountSuccess(groupId, accountId, data)))
.catch(err => dispatch(groupDemoteAccountFail(groupId, accountId, err))); .catch(err => dispatch(groupDemoteAccountFail(groupId, accountId, err)));
}; };
@ -418,11 +422,12 @@ const fetchGroupMemberships = (id: string, role: GroupRole) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch(fetchGroupMembershipsRequest(id, role)); dispatch(fetchGroupMembershipsRequest(id, role));
return api(getState).get(`/api/v1/groups/${id}/memberships`, { params: { role } }).then(response => { return api(getState).get(`/api/v1/groups/${id}/memberships`, { searchParams: { role } }).then(async (response) => {
const next = getLinks(response).refs.find(link => link.rel === 'next'); const next = response.next();
const data = await response.json();
dispatch(importFetchedAccounts(response.data.map((membership: APIEntity) => membership.account))); dispatch(importFetchedAccounts(data.map((membership: APIEntity) => membership.account)));
dispatch(fetchGroupMembershipsSuccess(id, role, response.data, next ? next.uri : null)); dispatch(fetchGroupMembershipsSuccess(id, role, data, next));
}).catch(error => { }).catch(error => {
dispatch(fetchGroupMembershipsFail(id, role, error)); dispatch(fetchGroupMembershipsFail(id, role, error));
}); });
@ -460,12 +465,13 @@ const expandGroupMemberships = (id: string, role: GroupRole) =>
dispatch(expandGroupMembershipsRequest(id, role)); dispatch(expandGroupMembershipsRequest(id, role));
return api(getState).get(url).then(response => { return api(getState).get(url).then(async (response) => {
const next = getLinks(response).refs.find(link => link.rel === 'next'); const next = response.next();
const data = await response.json();
dispatch(importFetchedAccounts(response.data.map((membership: APIEntity) => membership.account))); dispatch(importFetchedAccounts(data.map((membership: APIEntity) => membership.account)));
dispatch(expandGroupMembershipsSuccess(id, role, response.data, next ? next.uri : null)); dispatch(expandGroupMembershipsSuccess(id, role, data, next));
dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id))); dispatch(fetchRelationships(data.map((item: APIEntity) => item.id)));
}).catch(error => { }).catch(error => {
dispatch(expandGroupMembershipsFail(id, role, error)); dispatch(expandGroupMembershipsFail(id, role, error));
}); });
@ -496,11 +502,12 @@ const fetchGroupMembershipRequests = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch(fetchGroupMembershipRequestsRequest(id)); dispatch(fetchGroupMembershipRequestsRequest(id));
return api(getState).get(`/api/v1/groups/${id}/membership_requests`).then(response => { return api(getState).get(`/api/v1/groups/${id}/membership_requests`).then(async (response) => {
const next = getLinks(response).refs.find(link => link.rel === 'next'); const next = response.next();
const data = await response.json();
dispatch(importFetchedAccounts(response.data)); dispatch(importFetchedAccounts(data));
dispatch(fetchGroupMembershipRequestsSuccess(id, response.data, next ? next.uri : null)); dispatch(fetchGroupMembershipRequestsSuccess(id, data, next));
}).catch(error => { }).catch(error => {
dispatch(fetchGroupMembershipRequestsFail(id, error)); dispatch(fetchGroupMembershipRequestsFail(id, error));
}); });
@ -535,12 +542,13 @@ const expandGroupMembershipRequests = (id: string) =>
dispatch(expandGroupMembershipRequestsRequest(id)); dispatch(expandGroupMembershipRequestsRequest(id));
return api(getState).get(url).then(response => { return api(getState).get(url).then(async (response) => {
const next = getLinks(response).refs.find(link => link.rel === 'next'); const next = response.next();
const data = await response.json();
dispatch(importFetchedAccounts(response.data)); dispatch(importFetchedAccounts(data));
dispatch(expandGroupMembershipRequestsSuccess(id, response.data, next ? next.uri : null)); dispatch(expandGroupMembershipRequestsSuccess(id, data, next));
dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id))); dispatch(fetchRelationships(data.map((item: APIEntity) => item.id)));
}).catch(error => { }).catch(error => {
dispatch(expandGroupMembershipRequestsFail(id, error)); dispatch(expandGroupMembershipRequestsFail(id, error));
}); });

View File

@ -19,7 +19,7 @@ const fetchHistory = (statusId: string) =>
dispatch(fetchHistoryRequest(statusId)); dispatch(fetchHistoryRequest(statusId));
api(getState).get(`/api/v1/statuses/${statusId}/history`).then(({ data }) => { api(getState).get(`/api/v1/statuses/${statusId}/history`).then((response) => response.json()).then((data) => {
dispatch(importFetchedAccounts(data.map((x: APIEntity) => x.account))); dispatch(importFetchedAccounts(data.map((x: APIEntity) => x.account)));
dispatch(fetchHistorySuccess(statusId, data)); dispatch(fetchHistorySuccess(statusId, data));
}).catch(error => dispatch(fetchHistoryFail(error))); }).catch(error => dispatch(fetchHistoryFail(error)));

View File

@ -43,9 +43,9 @@ export const importFollows = (params: FormData) =>
dispatch({ type: IMPORT_FOLLOWS_REQUEST }); dispatch({ type: IMPORT_FOLLOWS_REQUEST });
return api(getState) return api(getState)
.post('/api/pleroma/follow_import', params) .post('/api/pleroma/follow_import', params)
.then(response => { .then((response) => response.json()).then((data) => {
toast.success(messages.followersSuccess); toast.success(messages.followersSuccess);
dispatch({ type: IMPORT_FOLLOWS_SUCCESS, config: response.data }); dispatch({ type: IMPORT_FOLLOWS_SUCCESS, config: data });
}).catch(error => { }).catch(error => {
dispatch({ type: IMPORT_FOLLOWS_FAIL, error }); dispatch({ type: IMPORT_FOLLOWS_FAIL, error });
}); });
@ -56,9 +56,9 @@ export const importBlocks = (params: FormData) =>
dispatch({ type: IMPORT_BLOCKS_REQUEST }); dispatch({ type: IMPORT_BLOCKS_REQUEST });
return api(getState) return api(getState)
.post('/api/pleroma/blocks_import', params) .post('/api/pleroma/blocks_import', params)
.then(response => { .then((response) => response.json()).then((data) => {
toast.success(messages.blocksSuccess); toast.success(messages.blocksSuccess);
dispatch({ type: IMPORT_BLOCKS_SUCCESS, config: response.data }); dispatch({ type: IMPORT_BLOCKS_SUCCESS, config: data });
}).catch(error => { }).catch(error => {
dispatch({ type: IMPORT_BLOCKS_FAIL, error }); dispatch({ type: IMPORT_BLOCKS_FAIL, error });
}); });
@ -69,9 +69,9 @@ export const importMutes = (params: FormData) =>
dispatch({ type: IMPORT_MUTES_REQUEST }); dispatch({ type: IMPORT_MUTES_REQUEST });
return api(getState) return api(getState)
.post('/api/pleroma/mutes_import', params) .post('/api/pleroma/mutes_import', params)
.then(response => { .then((response) => response.json()).then((data) => {
toast.success(messages.mutesSuccess); toast.success(messages.mutesSuccess);
dispatch({ type: IMPORT_MUTES_SUCCESS, config: response.data }); dispatch({ type: IMPORT_MUTES_SUCCESS, config: data });
}).catch(error => { }).catch(error => {
dispatch({ type: IMPORT_MUTES_FAIL, error }); dispatch({ type: IMPORT_MUTES_FAIL, error });
}); });

View File

@ -27,7 +27,8 @@ export const fetchInstance = createAsyncThunk<InstanceData, InstanceData['host']
'instance/fetch', 'instance/fetch',
async(host, { dispatch, getState, rejectWithValue }) => { async(host, { dispatch, getState, rejectWithValue }) => {
try { try {
const { data } = await api(getState).get('/api/v1/instance'); const response = await api(getState).get('/api/v1/instance');
const data = await response.json();
const instance = instanceV1Schema.parse(data); const instance = instanceV1Schema.parse(data);
const features = getFeatures(instance); const features = getFeatures(instance);
@ -46,7 +47,8 @@ export const fetchInstanceV2 = createAsyncThunk<InstanceData, InstanceData['host
'instanceV2/fetch', 'instanceV2/fetch',
async(host, { getState, rejectWithValue }) => { async(host, { getState, rejectWithValue }) => {
try { try {
const { data } = await api(getState).get('/api/v2/instance'); const response = await api(getState).get('/api/v2/instance');
const data = await response.json();
const instance = instanceV2Schema.parse(data); const instance = instanceV2Schema.parse(data);
return { instance, host }; return { instance, host };
} catch (e) { } catch (e) {

View File

@ -3,7 +3,7 @@ import { defineMessages } from 'react-intl';
import toast from 'soapbox/toast.tsx'; import toast from 'soapbox/toast.tsx';
import { isLoggedIn } from 'soapbox/utils/auth.ts'; import { isLoggedIn } from 'soapbox/utils/auth.ts';
import api, { getLinks } from '../api/index.ts'; import api from '../api/index.ts';
import { fetchRelationships } from './accounts.ts'; import { fetchRelationships } from './accounts.ts';
import { importFetchedAccounts, importFetchedStatus } from './importer/index.ts'; import { importFetchedAccounts, importFetchedStatus } from './importer/index.ts';
@ -101,10 +101,10 @@ const reblog = (status: StatusEntity) =>
dispatch(reblogRequest(status)); dispatch(reblogRequest(status));
api(getState).post(`/api/v1/statuses/${status.id}/reblog`).then(function(response) { api(getState).post(`/api/v1/statuses/${status.id}/reblog`).then((response) => response.json()).then((data) => {
// The reblog API method returns a new status wrapped around the original. In this case we are only // The reblog API method returns a new status wrapped around the original. In this case we are only
// interested in how the original is modified, hence passing it skipping the wrapper // interested in how the original is modified, hence passing it skipping the wrapper
dispatch(importFetchedStatus(response.data.reblog)); dispatch(importFetchedStatus(data.reblog));
dispatch(reblogSuccess(status)); dispatch(reblogSuccess(status));
}).catch(error => { }).catch(error => {
dispatch(reblogFail(status, error)); dispatch(reblogFail(status, error));
@ -322,8 +322,8 @@ const zap = (account: AccountEntity, status: StatusEntity | undefined, amount: n
if (status) dispatch(zapRequest(status)); if (status) dispatch(zapRequest(status));
return api(getState).post('/api/v1/ditto/zap', { amount, comment, account_id: account.id, status_id: status?.id }).then(async function(response) { return api(getState).post('/api/v1/ditto/zap', { amount, comment, account_id: account.id, status_id: status?.id }).then(async (response) => {
const { invoice } = response.data; const { invoice } = await response.json();
if (!invoice) throw Error('Could not generate invoice'); if (!invoice) throw Error('Could not generate invoice');
if (!window.webln) return invoice; if (!window.webln) return invoice;
@ -363,9 +363,9 @@ const bookmark = (status: StatusEntity) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch(bookmarkRequest(status)); dispatch(bookmarkRequest(status));
return api(getState).post(`/api/v1/statuses/${status.id}/bookmark`).then(function(response) { return api(getState).post(`/api/v1/statuses/${status.id}/bookmark`).then((response) => response.json()).then((data) => {
dispatch(importFetchedStatus(response.data)); dispatch(importFetchedStatus(data));
dispatch(bookmarkSuccess(status, response.data)); dispatch(bookmarkSuccess(status, data));
toast.success(messages.bookmarkAdded, { toast.success(messages.bookmarkAdded, {
actionLink: '/bookmarks', actionLink: '/bookmarks',
@ -379,9 +379,9 @@ const unbookmark = (status: StatusEntity) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch(unbookmarkRequest(status)); dispatch(unbookmarkRequest(status));
api(getState).post(`/api/v1/statuses/${status.id}/unbookmark`).then(response => { api(getState).post(`/api/v1/statuses/${status.id}/unbookmark`).then((response) => response.json()).then((data) => {
dispatch(importFetchedStatus(response.data)); dispatch(importFetchedStatus(data));
dispatch(unbookmarkSuccess(status, response.data)); dispatch(unbookmarkSuccess(status, data));
toast.success(messages.bookmarkRemoved); toast.success(messages.bookmarkRemoved);
}).catch(error => { }).catch(error => {
dispatch(unbookmarkFail(status, error)); dispatch(unbookmarkFail(status, error));
@ -437,11 +437,12 @@ const fetchReblogs = (id: string) =>
dispatch(fetchReblogsRequest(id)); dispatch(fetchReblogsRequest(id));
api(getState).get(`/api/v1/statuses/${id}/reblogged_by`).then(response => { api(getState).get(`/api/v1/statuses/${id}/reblogged_by`).then(async (response) => {
const next = getLinks(response).refs.find(link => link.rel === 'next'); const next = response.next();
dispatch(importFetchedAccounts(response.data)); const data = await response.json();
dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id))); dispatch(importFetchedAccounts(data));
dispatch(fetchReblogsSuccess(id, response.data, next ? next.uri : null)); dispatch(fetchRelationships(data.map((item: APIEntity) => item.id)));
dispatch(fetchReblogsSuccess(id, data, next));
}).catch(error => { }).catch(error => {
dispatch(fetchReblogsFail(id, error)); dispatch(fetchReblogsFail(id, error));
}); });
@ -467,11 +468,12 @@ const fetchReblogsFail = (id: string, error: unknown) => ({
const expandReblogs = (id: string, path: string) => const expandReblogs = (id: string, path: string) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
api(getState).get(path).then(response => { api(getState).get(path).then(async (response) => {
const next = getLinks(response).refs.find(link => link.rel === 'next'); const next = response.next();
dispatch(importFetchedAccounts(response.data)); const data = await response.json();
dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id))); dispatch(importFetchedAccounts(data));
dispatch(expandReblogsSuccess(id, response.data, next ? next.uri : null)); dispatch(fetchRelationships(data.map((item: APIEntity) => item.id)));
dispatch(expandReblogsSuccess(id, data, next));
}).catch(error => { }).catch(error => {
dispatch(expandReblogsFail(id, error)); dispatch(expandReblogsFail(id, error));
}); });
@ -496,11 +498,12 @@ const fetchFavourites = (id: string) =>
dispatch(fetchFavouritesRequest(id)); dispatch(fetchFavouritesRequest(id));
api(getState).get(`/api/v1/statuses/${id}/favourited_by`).then(response => { api(getState).get(`/api/v1/statuses/${id}/favourited_by`).then(async (response) => {
const next = getLinks(response).refs.find(link => link.rel === 'next'); const next = response.next();
dispatch(importFetchedAccounts(response.data)); const data = await response.json();
dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id))); dispatch(importFetchedAccounts(data));
dispatch(fetchFavouritesSuccess(id, response.data, next ? next.uri : null)); dispatch(fetchRelationships(data.map((item: APIEntity) => item.id)));
dispatch(fetchFavouritesSuccess(id, data, next));
}).catch(error => { }).catch(error => {
dispatch(fetchFavouritesFail(id, error)); dispatch(fetchFavouritesFail(id, error));
}); });
@ -526,11 +529,12 @@ const fetchFavouritesFail = (id: string, error: unknown) => ({
const expandFavourites = (id: string, path: string) => const expandFavourites = (id: string, path: string) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
api(getState).get(path).then(response => { api(getState).get(path).then(async (response) => {
const next = getLinks(response).refs.find(link => link.rel === 'next'); const next = response.next();
dispatch(importFetchedAccounts(response.data)); const data = await response.json();
dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id))); dispatch(importFetchedAccounts(data));
dispatch(expandFavouritesSuccess(id, response.data, next ? next.uri : null)); dispatch(fetchRelationships(data.map((item: APIEntity) => item.id)));
dispatch(expandFavouritesSuccess(id, data, next));
}).catch(error => { }).catch(error => {
dispatch(expandFavouritesFail(id, error)); dispatch(expandFavouritesFail(id, error));
}); });
@ -555,10 +559,10 @@ const fetchDislikes = (id: string) =>
dispatch(fetchDislikesRequest(id)); dispatch(fetchDislikesRequest(id));
api(getState).get(`/api/friendica/statuses/${id}/disliked_by`).then(response => { api(getState).get(`/api/friendica/statuses/${id}/disliked_by`).then((response) => response.json()).then((data) => {
dispatch(importFetchedAccounts(response.data)); dispatch(importFetchedAccounts(data));
dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id))); dispatch(fetchRelationships(data.map((item: APIEntity) => item.id)));
dispatch(fetchDislikesSuccess(id, response.data)); dispatch(fetchDislikesSuccess(id, data));
}).catch(error => { }).catch(error => {
dispatch(fetchDislikesFail(id, error)); dispatch(fetchDislikesFail(id, error));
}); });
@ -585,9 +589,9 @@ const fetchReactions = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch(fetchReactionsRequest(id)); dispatch(fetchReactionsRequest(id));
api(getState).get(`/api/v1/pleroma/statuses/${id}/reactions`).then(response => { api(getState).get(`/api/v1/pleroma/statuses/${id}/reactions`).then((response) => response.json()).then((data) => {
dispatch(importFetchedAccounts((response.data as APIEntity[]).map(({ accounts }) => accounts).flat())); dispatch(importFetchedAccounts((data as APIEntity[]).map(({ accounts }) => accounts).flat()));
dispatch(fetchReactionsSuccess(id, response.data)); dispatch(fetchReactionsSuccess(id, data));
}).catch(error => { }).catch(error => {
dispatch(fetchReactionsFail(id, error)); dispatch(fetchReactionsFail(id, error));
}); });
@ -614,10 +618,11 @@ const fetchZaps = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch(fetchZapsRequest(id)); dispatch(fetchZapsRequest(id));
api(getState).get(`/api/v1/ditto/statuses/${id}/zapped_by`).then(response => { api(getState).get(`/api/v1/ditto/statuses/${id}/zapped_by`).then(async (response) => {
const next = getLinks(response).refs.find(link => link.rel === 'next'); const next = response.next();
dispatch(importFetchedAccounts((response.data as APIEntity[]).map(({ account }) => account).flat())); const data = await response.json();
dispatch(fetchZapsSuccess(id, response.data, next ? next.uri : null)); dispatch(importFetchedAccounts((data as APIEntity[]).map(({ account }) => account).flat()));
dispatch(fetchZapsSuccess(id, data, next));
}).catch(error => { }).catch(error => {
dispatch(fetchZapsFail(id, error)); dispatch(fetchZapsFail(id, error));
}); });
@ -643,11 +648,12 @@ const fetchZapsFail = (id: string, error: unknown) => ({
const expandZaps = (id: string, path: string) => const expandZaps = (id: string, path: string) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
api(getState).get(path).then(response => { api(getState).get(path).then(async (response) => {
const next = getLinks(response).refs.find(link => link.rel === 'next'); const next = response.next();
dispatch(importFetchedAccounts(response.data.map((item: APIEntity) => item.account))); const data = await response.json();
dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.account.id))); dispatch(importFetchedAccounts(data.map((item: APIEntity) => item.account)));
dispatch(expandZapsSuccess(id, response.data, next ? next.uri : null)); dispatch(fetchRelationships(data.map((item: APIEntity) => item.account.id)));
dispatch(expandZapsSuccess(id, data, next));
}).catch(error => { }).catch(error => {
dispatch(expandZapsFail(id, error)); dispatch(expandZapsFail(id, error));
}); });
@ -672,8 +678,8 @@ const pin = (status: StatusEntity) =>
dispatch(pinRequest(status)); dispatch(pinRequest(status));
api(getState).post(`/api/v1/statuses/${status.id}/pin`).then(response => { api(getState).post(`/api/v1/statuses/${status.id}/pin`).then((response) => response.json()).then((data) => {
dispatch(importFetchedStatus(response.data)); dispatch(importFetchedStatus(data));
dispatch(pinSuccess(status)); dispatch(pinSuccess(status));
}).catch(error => { }).catch(error => {
dispatch(pinFail(status, error)); dispatch(pinFail(status, error));
@ -719,8 +725,8 @@ const unpin = (status: StatusEntity) =>
dispatch(unpinRequest(status)); dispatch(unpinRequest(status));
api(getState).post(`/api/v1/statuses/${status.id}/unpin`).then(response => { api(getState).post(`/api/v1/statuses/${status.id}/unpin`).then((response) => response.json()).then((data) => {
dispatch(importFetchedStatus(response.data)); dispatch(importFetchedStatus(data));
dispatch(unpinSuccess(status)); dispatch(unpinSuccess(status));
}).catch(error => { }).catch(error => {
dispatch(unpinFail(status, error)); dispatch(unpinFail(status, error));
@ -759,7 +765,7 @@ const remoteInteraction = (ap_id: string, profile: string) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch(remoteInteractionRequest(ap_id, profile)); dispatch(remoteInteractionRequest(ap_id, profile));
return api(getState).post('/api/v1/pleroma/remote_interaction', { ap_id, profile }).then(({ data }) => { return api(getState).post('/api/v1/pleroma/remote_interaction', { ap_id, profile }).then((response) => response.json()).then((data) => {
if (data.error) throw new Error(data.error); if (data.error) throw new Error(data.error);
dispatch(remoteInteractionSuccess(ap_id, profile, data.url)); dispatch(remoteInteractionSuccess(ap_id, profile, data.url));

View File

@ -66,7 +66,7 @@ const fetchList = (id: string | number) => (dispatch: AppDispatch, getState: ()
dispatch(fetchListRequest(id)); dispatch(fetchListRequest(id));
api(getState).get(`/api/v1/lists/${id}`) api(getState).get(`/api/v1/lists/${id}`)
.then(({ data }) => dispatch(fetchListSuccess(data))) .then((response) => response.json()).then((data) => dispatch(fetchListSuccess(data)))
.catch(err => dispatch(fetchListFail(id, err))); .catch(err => dispatch(fetchListFail(id, err)));
}; };
@ -92,7 +92,7 @@ const fetchLists = () => (dispatch: AppDispatch, getState: () => RootState) => {
dispatch(fetchListsRequest()); dispatch(fetchListsRequest());
api(getState).get('/api/v1/lists') api(getState).get('/api/v1/lists')
.then(({ data }) => dispatch(fetchListsSuccess(data))) .then((response) => response.json()).then((data) => dispatch(fetchListsSuccess(data)))
.catch(err => dispatch(fetchListsFail(err))); .catch(err => dispatch(fetchListsFail(err)));
}; };
@ -140,7 +140,7 @@ const createList = (title: string, shouldReset?: boolean) => (dispatch: AppDispa
dispatch(createListRequest()); dispatch(createListRequest());
api(getState).post('/api/v1/lists', { title }).then(({ data }) => { api(getState).post('/api/v1/lists', { title }).then((response) => response.json()).then((data) => {
dispatch(createListSuccess(data)); dispatch(createListSuccess(data));
if (shouldReset) { if (shouldReset) {
@ -168,7 +168,7 @@ const updateList = (id: string | number, title: string, shouldReset?: boolean) =
dispatch(updateListRequest(id)); dispatch(updateListRequest(id));
api(getState).put(`/api/v1/lists/${id}`, { title }).then(({ data }) => { api(getState).put(`/api/v1/lists/${id}`, { title }).then((response) => response.json()).then((data) => {
dispatch(updateListSuccess(data)); dispatch(updateListSuccess(data));
if (shouldReset) { if (shouldReset) {
@ -228,7 +228,7 @@ const fetchListAccounts = (listId: string | number) => (dispatch: AppDispatch, g
dispatch(fetchListAccountsRequest(listId)); dispatch(fetchListAccountsRequest(listId));
api(getState).get(`/api/v1/lists/${listId}/accounts`, { params: { limit: 0 } }).then(({ data }) => { api(getState).get(`/api/v1/lists/${listId}/accounts`, { searchParams: { limit: 0 } }).then((response) => response.json()).then((data) => {
dispatch(importFetchedAccounts(data)); dispatch(importFetchedAccounts(data));
dispatch(fetchListAccountsSuccess(listId, data, null)); dispatch(fetchListAccountsSuccess(listId, data, null));
}).catch(err => dispatch(fetchListAccountsFail(listId, err))); }).catch(err => dispatch(fetchListAccountsFail(listId, err)));
@ -255,14 +255,14 @@ const fetchListAccountsFail = (id: string | number, error: unknown) => ({
const fetchListSuggestions = (q: string) => (dispatch: AppDispatch, getState: () => RootState) => { const fetchListSuggestions = (q: string) => (dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return; if (!isLoggedIn(getState)) return;
const params = { const searchParams = {
q, q,
resolve: false, resolve: false,
limit: 4, limit: 4,
following: true, following: true,
}; };
api(getState).get('/api/v1/accounts/search', { params }).then(({ data }) => { api(getState).get('/api/v1/accounts/search', { searchParams }).then((response) => response.json()).then((data) => {
dispatch(importFetchedAccounts(data)); dispatch(importFetchedAccounts(data));
dispatch(fetchListSuggestionsReady(q, data)); dispatch(fetchListSuggestionsReady(q, data));
}).catch(error => toast.showAlertForError(error)); }).catch(error => toast.showAlertForError(error));
@ -325,7 +325,10 @@ const removeFromList = (listId: string | number, accountId: string) => (dispatch
dispatch(removeFromListRequest(listId, accountId)); dispatch(removeFromListRequest(listId, accountId));
api(getState).delete(`/api/v1/lists/${listId}/accounts`, { params: { account_ids: [accountId] } }) const data = new FormData();
data.append('account_ids[]', accountId);
api(getState).request('DELETE', `/api/v1/lists/${listId}/accounts`, data)
.then(() => dispatch(removeFromListSuccess(listId, accountId))) .then(() => dispatch(removeFromListSuccess(listId, accountId)))
.catch(err => dispatch(removeFromListFail(listId, accountId, err))); .catch(err => dispatch(removeFromListFail(listId, accountId, err)));
}; };
@ -368,7 +371,7 @@ const fetchAccountLists = (accountId: string) => (dispatch: AppDispatch, getStat
dispatch(fetchAccountListsRequest(accountId)); dispatch(fetchAccountListsRequest(accountId));
api(getState).get(`/api/v1/accounts/${accountId}/lists`) api(getState).get(`/api/v1/accounts/${accountId}/lists`)
.then(({ data }) => dispatch(fetchAccountListsSuccess(accountId, data))) .then((response) => response.json()).then((data) => dispatch(fetchAccountListsSuccess(accountId, data)))
.catch(err => dispatch(fetchAccountListsFail(accountId, err))); .catch(err => dispatch(fetchAccountListsFail(accountId, err)));
}; };

View File

@ -14,9 +14,7 @@ const MARKER_SAVE_FAIL = 'MARKER_SAVE_FAIL';
const fetchMarker = (timeline: Array<string>) => const fetchMarker = (timeline: Array<string>) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: MARKER_FETCH_REQUEST }); dispatch({ type: MARKER_FETCH_REQUEST });
return api(getState).get('/api/v1/markers', { return api(getState).get('/api/v1/markers', { searchParams: { timeline } }).then((response) => response.json()).then((marker) => {
params: { timeline },
}).then(({ data: marker }) => {
dispatch({ type: MARKER_FETCH_SUCCESS, marker }); dispatch({ type: MARKER_FETCH_SUCCESS, marker });
}).catch(error => { }).catch(error => {
dispatch({ type: MARKER_FETCH_FAIL, error }); dispatch({ type: MARKER_FETCH_FAIL, error });
@ -26,7 +24,7 @@ const fetchMarker = (timeline: Array<string>) =>
const saveMarker = (marker: APIEntity) => const saveMarker = (marker: APIEntity) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: MARKER_SAVE_REQUEST, marker }); dispatch({ type: MARKER_SAVE_REQUEST, marker });
return api(getState).post('/api/v1/markers', marker).then(({ data: marker }) => { return api(getState).post('/api/v1/markers', marker).then((response) => response.json()).then((marker) => {
dispatch({ type: MARKER_SAVE_SUCCESS, marker }); dispatch({ type: MARKER_SAVE_SUCCESS, marker });
}).catch(error => { }).catch(error => {
dispatch({ type: MARKER_SAVE_FAIL, error }); dispatch({ type: MARKER_SAVE_FAIL, error });

View File

@ -7,7 +7,6 @@ import api from '../api/index.ts';
import { verifyCredentials } from './auth.ts'; import { verifyCredentials } from './auth.ts';
import { importFetchedAccount } from './importer/index.ts'; import { importFetchedAccount } from './importer/index.ts';
import type { RawAxiosRequestHeaders } from 'axios';
import type { Account } from 'soapbox/schemas/index.ts'; import type { Account } from 'soapbox/schemas/index.ts';
import type { AppDispatch, RootState } from 'soapbox/store.ts'; import type { AppDispatch, RootState } from 'soapbox/store.ts';
import type { APIEntity } from 'soapbox/types/entities.ts'; import type { APIEntity } from 'soapbox/types/entities.ts';
@ -58,14 +57,14 @@ const patchMe = (params: Record<string, any>, isFormData = false) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch(patchMeRequest()); dispatch(patchMeRequest());
const headers: RawAxiosRequestHeaders = isFormData ? { const headers = isFormData ? {
'Content-Type': 'multipart/form-data', 'Content-Type': 'multipart/form-data',
} : {}; } : undefined;
return api(getState) return api(getState)
.patch('/api/v1/accounts/update_credentials', params, { headers }) .patch('/api/v1/accounts/update_credentials', params, { headers })
.then(response => { .then((response) => response.json()).then((data) => {
dispatch(patchMeSuccess(response.data)); dispatch(patchMeSuccess(data));
}).catch(error => { }).catch(error => {
dispatch(patchMeFail(error)); dispatch(patchMeFail(error));
throw error; throw error;

View File

@ -31,15 +31,11 @@ const updateMedia = (mediaId: string, params: Record<string, any>) =>
const uploadMediaV1 = (data: FormData, onUploadProgress = noOp) => const uploadMediaV1 = (data: FormData, onUploadProgress = noOp) =>
(dispatch: any, getState: () => RootState) => (dispatch: any, getState: () => RootState) =>
api(getState).post('/api/v1/media', data, { api(getState).post('/api/v1/media', data, { onUploadProgress });
onUploadProgress: onUploadProgress,
});
const uploadMediaV2 = (data: FormData, onUploadProgress = noOp) => const uploadMediaV2 = (data: FormData, onUploadProgress = noOp) =>
(dispatch: any, getState: () => RootState) => (dispatch: any, getState: () => RootState) =>
api(getState).post('/api/v2/media', data, { api(getState).post('/api/v2/media', data, { onUploadProgress });
onUploadProgress: onUploadProgress,
});
const uploadMedia = (data: FormData, onUploadProgress = noOp) => const uploadMedia = (data: FormData, onUploadProgress = noOp) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
@ -59,8 +55,7 @@ const uploadFile = (
intl: IntlShape, intl: IntlShape,
onSuccess: (data: APIEntity) => void = () => {}, onSuccess: (data: APIEntity) => void = () => {},
onFail: (error: unknown) => void = () => {}, onFail: (error: unknown) => void = () => {},
onProgress: (loaded: number) => void = () => {}, onUploadProgress: (e: ProgressEvent) => void = () => {},
changeTotal: (value: number) => void = () => {},
) => ) =>
async (dispatch: AppDispatch, getState: () => RootState) => { async (dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return; if (!isLoggedIn(getState)) return;
@ -92,21 +87,23 @@ const uploadFile = (
} }
// FIXME: Don't define const in loop // FIXME: Don't define const in loop
resizeImage(file).then(resized => { resizeImage(file).then((resized) => {
const data = new FormData(); const data = new FormData();
data.append('file', resized); data.append('file', resized);
// Account for disparity in size of original image and resized data
changeTotal(resized.size - file.size);
return dispatch(uploadMedia(data, onProgress)) return dispatch(uploadMedia(data, onUploadProgress))
.then(({ status, data }) => { .then(async (response) => {
const { status } = response;
const data = await response.json();
// If server-side processing of the media attachment has not completed yet, // If server-side processing of the media attachment has not completed yet,
// poll the server until it is, before showing the media attachment as uploaded // poll the server until it is, before showing the media attachment as uploaded
if (status === 200) { if (status === 200) {
onSuccess(data); onSuccess(data);
} else if (status === 202) { } else if (status === 202) {
const poll = () => { const poll = () => {
dispatch(fetchMedia(data.id)).then(({ status, data }) => { dispatch(fetchMedia(data.id)).then(async (response) => {
const { status } = response;
const data = await response.json();
if (status === 200) { if (status === 200) {
onSuccess(data); onSuccess(data);
} else if (status === 206) { } else if (status === 206) {

View File

@ -25,7 +25,7 @@ const MFA_DISABLE_FAIL = 'MFA_DISABLE_FAIL';
const fetchMfa = () => const fetchMfa = () =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: MFA_FETCH_REQUEST }); dispatch({ type: MFA_FETCH_REQUEST });
return api(getState).get('/api/pleroma/accounts/mfa').then(({ data }) => { return api(getState).get('/api/pleroma/accounts/mfa').then((response) => response.json()).then((data) => {
dispatch({ type: MFA_FETCH_SUCCESS, data }); dispatch({ type: MFA_FETCH_SUCCESS, data });
}).catch(() => { }).catch(() => {
dispatch({ type: MFA_FETCH_FAIL }); dispatch({ type: MFA_FETCH_FAIL });
@ -35,7 +35,7 @@ const fetchMfa = () =>
const fetchBackupCodes = () => const fetchBackupCodes = () =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: MFA_BACKUP_CODES_FETCH_REQUEST }); dispatch({ type: MFA_BACKUP_CODES_FETCH_REQUEST });
return api(getState).get('/api/pleroma/accounts/mfa/backup_codes').then(({ data }) => { return api(getState).get('/api/pleroma/accounts/mfa/backup_codes').then((response) => response.json()).then((data) => {
dispatch({ type: MFA_BACKUP_CODES_FETCH_SUCCESS, data }); dispatch({ type: MFA_BACKUP_CODES_FETCH_SUCCESS, data });
return data; return data;
}).catch(() => { }).catch(() => {
@ -46,7 +46,7 @@ const fetchBackupCodes = () =>
const setupMfa = (method: string) => const setupMfa = (method: string) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: MFA_SETUP_REQUEST, method }); dispatch({ type: MFA_SETUP_REQUEST, method });
return api(getState).get(`/api/pleroma/accounts/mfa/setup/${method}`).then(({ data }) => { return api(getState).get(`/api/pleroma/accounts/mfa/setup/${method}`).then((response) => response.json()).then((data) => {
dispatch({ type: MFA_SETUP_SUCCESS, data }); dispatch({ type: MFA_SETUP_SUCCESS, data });
return data; return data;
}).catch((error: unknown) => { }).catch((error: unknown) => {
@ -59,7 +59,7 @@ const confirmMfa = (method: string, code: string, password: string) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
const params = { code, password }; const params = { code, password };
dispatch({ type: MFA_CONFIRM_REQUEST, method, code }); dispatch({ type: MFA_CONFIRM_REQUEST, method, code });
return api(getState).post(`/api/pleroma/accounts/mfa/confirm/${method}`, params).then(({ data }) => { return api(getState).post(`/api/pleroma/accounts/mfa/confirm/${method}`, params).then((response) => response.json()).then((data) => {
dispatch({ type: MFA_CONFIRM_SUCCESS, method, code }); dispatch({ type: MFA_CONFIRM_SUCCESS, method, code });
return data; return data;
}).catch((error: unknown) => { }).catch((error: unknown) => {
@ -71,7 +71,7 @@ const confirmMfa = (method: string, code: string, password: string) =>
const disableMfa = (method: string, password: string) => const disableMfa = (method: string, password: string) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: MFA_DISABLE_REQUEST, method }); dispatch({ type: MFA_DISABLE_REQUEST, method });
return api(getState).delete(`/api/pleroma/accounts/mfa/${method}`, { data: { password } }).then(({ data }) => { return api(getState).request('DELETE', `/api/pleroma/accounts/mfa/${method}`, { password }).then((response) => response.json()).then((data) => {
dispatch({ type: MFA_DISABLE_SUCCESS, method }); dispatch({ type: MFA_DISABLE_SUCCESS, method });
return data; return data;
}).catch((error: unknown) => { }).catch((error: unknown) => {

View File

@ -1,43 +0,0 @@
import { OrderedMap as ImmutableOrderedMap } from 'immutable';
import { describe, expect, it } from 'vitest';
import { __stub } from 'soapbox/api/index.ts';
import { mockStore, rootState } from 'soapbox/jest/test-helpers.tsx';
import { normalizeNotification } from 'soapbox/normalizers/index.ts';
import { markReadNotifications } from './notifications.ts';
describe('markReadNotifications()', () => {
it('fires off marker when top notification is newer than lastRead', async() => {
__stub((mock) => mock.onPost('/api/v1/markers').reply(200, {}));
const items = ImmutableOrderedMap({
'10': normalizeNotification({ id: '10' }),
});
const state = {
...rootState,
me: '123',
notifications: rootState.notifications.merge({
lastRead: '9',
items,
}),
};
const store = mockStore(state);
const expectedActions = [{
type: 'MARKER_SAVE_REQUEST',
marker: {
notifications: {
last_read_id: '10',
},
},
}];
store.dispatch(markReadNotifications());
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});

View File

@ -2,7 +2,7 @@ import IntlMessageFormat from 'intl-messageformat';
import 'intl-pluralrules'; import 'intl-pluralrules';
import { defineMessages } from 'react-intl'; import { defineMessages } from 'react-intl';
import api, { getLinks } from 'soapbox/api/index.ts'; import api from 'soapbox/api/index.ts';
import { getFilters, regexFromFilters } from 'soapbox/selectors/index.ts'; import { getFilters, regexFromFilters } from 'soapbox/selectors/index.ts';
import { isLoggedIn } from 'soapbox/utils/auth.ts'; import { isLoggedIn } from 'soapbox/utils/auth.ts';
import { compareId } from 'soapbox/utils/comparators.ts'; import { compareId } from 'soapbox/utils/comparators.ts';
@ -213,10 +213,11 @@ const expandNotifications = ({ maxId }: Record<string, any> = {}, done: () => an
dispatch(expandNotificationsRequest(isLoadingMore)); dispatch(expandNotificationsRequest(isLoadingMore));
return api(getState).get('/api/v1/notifications', { params }).then(response => { return api(getState).get('/api/v1/notifications', { searchParams: params }).then(async (response) => {
const next = getLinks(response).refs.find(link => link.rel === 'next'); const next = response.next();
const data = await response.json();
const entries = (response.data as APIEntity[]).reduce((acc, item) => { const entries = (data as APIEntity[]).reduce((acc, item) => {
if (item.account?.id) { if (item.account?.id) {
acc.accounts[item.account.id] = item.account; acc.accounts[item.account.id] = item.account;
} }
@ -239,8 +240,8 @@ const expandNotifications = ({ maxId }: Record<string, any> = {}, done: () => an
const statusesFromGroups = (Object.values(entries.statuses) as Status[]).filter((status) => !!status.group); const statusesFromGroups = (Object.values(entries.statuses) as Status[]).filter((status) => !!status.group);
dispatch(fetchGroupRelationships(statusesFromGroups.map((status: any) => status.group?.id))); dispatch(fetchGroupRelationships(statusesFromGroups.map((status: any) => status.group?.id)));
dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore)); dispatch(expandNotificationsSuccess(data, next, isLoadingMore));
fetchRelatedRelationships(dispatch, response.data); fetchRelatedRelationships(dispatch, data);
done(); done();
}).catch(error => { }).catch(error => {
dispatch(expandNotificationsFail(error, isLoadingMore)); dispatch(expandNotificationsFail(error, isLoadingMore));

View File

@ -23,7 +23,7 @@ export const OAUTH_TOKEN_REVOKE_FAIL = 'OAUTH_TOKEN_REVOKE_FAIL';
export const obtainOAuthToken = (params: Record<string, unknown>, baseURL?: string) => export const obtainOAuthToken = (params: Record<string, unknown>, baseURL?: string) =>
(dispatch: AppDispatch) => { (dispatch: AppDispatch) => {
dispatch({ type: OAUTH_TOKEN_CREATE_REQUEST, params }); dispatch({ type: OAUTH_TOKEN_CREATE_REQUEST, params });
return baseClient(null, baseURL).post('/oauth/token', params).then(({ data: token }) => { return baseClient(null, baseURL).post('/oauth/token', params).then((response) => response.json()).then((token) => {
dispatch({ type: OAUTH_TOKEN_CREATE_SUCCESS, params, token }); dispatch({ type: OAUTH_TOKEN_CREATE_SUCCESS, params, token });
return token; return token;
}).catch(error => { }).catch(error => {
@ -36,7 +36,7 @@ export const revokeOAuthToken = (params: Record<string, string>) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: OAUTH_TOKEN_REVOKE_REQUEST, params }); dispatch({ type: OAUTH_TOKEN_REVOKE_REQUEST, params });
const baseURL = getBaseURL(getState()); const baseURL = getBaseURL(getState());
return baseClient(null, baseURL).post('/oauth/revoke', params).then(({ data }) => { return baseClient(null, baseURL).post('/oauth/revoke', params).then((response) => response.json()).then((data) => {
dispatch({ type: OAUTH_TOKEN_REVOKE_SUCCESS, params, data }); dispatch({ type: OAUTH_TOKEN_REVOKE_SUCCESS, params, data });
return data; return data;
}).catch(error => { }).catch(error => {

View File

@ -14,8 +14,8 @@ const PATRON_ACCOUNT_FETCH_FAIL = 'PATRON_ACCOUNT_FETCH_FAIL';
const fetchPatronInstance = () => const fetchPatronInstance = () =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: PATRON_INSTANCE_FETCH_REQUEST }); dispatch({ type: PATRON_INSTANCE_FETCH_REQUEST });
return api(getState).get('/api/patron/v1/instance').then(response => { return api(getState).get('/api/patron/v1/instance').then((response) => response.json()).then((data) => {
dispatch(importFetchedInstance(response.data)); dispatch(importFetchedInstance(data));
}).catch(error => { }).catch(error => {
dispatch(fetchInstanceFail(error)); dispatch(fetchInstanceFail(error));
}); });
@ -25,8 +25,8 @@ const fetchPatronAccount = (apId: string) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
apId = encodeURIComponent(apId); apId = encodeURIComponent(apId);
dispatch({ type: PATRON_ACCOUNT_FETCH_REQUEST }); dispatch({ type: PATRON_ACCOUNT_FETCH_REQUEST });
api(getState).get(`/api/patron/v1/accounts/${apId}`).then(response => { api(getState).get(`/api/patron/v1/accounts/${apId}`).then((response) => response.json()).then((data) => {
dispatch(importFetchedAccount(response.data)); dispatch(importFetchedAccount(data));
}).catch(error => { }).catch(error => {
dispatch(fetchAccountFail(error)); dispatch(fetchAccountFail(error));
}); });

View File

@ -18,9 +18,9 @@ const fetchPinnedStatuses = () =>
dispatch(fetchPinnedStatusesRequest()); dispatch(fetchPinnedStatusesRequest());
api(getState).get(`/api/v1/accounts/${me}/statuses`, { params: { pinned: true } }).then(response => { api(getState).get(`/api/v1/accounts/${me}/statuses`, { searchParams: { pinned: true } }).then((response) => response.json()).then((data) => {
dispatch(importFetchedStatuses(response.data)); dispatch(importFetchedStatuses(data));
dispatch(fetchPinnedStatusesSuccess(response.data, null)); dispatch(fetchPinnedStatusesSuccess(data, null));
}).catch(error => { }).catch(error => {
dispatch(fetchPinnedStatusesFail(error)); dispatch(fetchPinnedStatusesFail(error));
}); });

View File

@ -18,7 +18,7 @@ const vote = (pollId: string, choices: string[]) =>
dispatch(voteRequest()); dispatch(voteRequest());
api(getState).post(`/api/v1/polls/${pollId}/votes`, { choices }) api(getState).post(`/api/v1/polls/${pollId}/votes`, { choices })
.then(({ data }) => { .then((response) => response.json()).then((data) => {
dispatch(importFetchedPoll(data)); dispatch(importFetchedPoll(data));
dispatch(voteSuccess(data)); dispatch(voteSuccess(data));
}) })
@ -30,7 +30,7 @@ const fetchPoll = (pollId: string) =>
dispatch(fetchPollRequest()); dispatch(fetchPollRequest());
api(getState).get(`/api/v1/polls/${pollId}`) api(getState).get(`/api/v1/polls/${pollId}`)
.then(({ data }) => { .then((response) => response.json()).then((data) => {
dispatch(importFetchedPoll(data)); dispatch(importFetchedPoll(data));
dispatch(fetchPollSuccess(data)); dispatch(fetchPollSuccess(data));
}) })

View File

@ -1,37 +0,0 @@
import { describe, expect, it } from 'vitest';
import { __stub } from 'soapbox/api/index.ts';
import { mockStore } from 'soapbox/jest/test-helpers.tsx';
import { VERIFY_CREDENTIALS_REQUEST } from './auth.ts';
import { ACCOUNTS_IMPORT } from './importer/index.ts';
import {
MASTODON_PRELOAD_IMPORT,
preloadMastodon,
} from './preload.ts';
describe('preloadMastodon()', () => {
it('creates the expected actions', async () => {
const data = await import('soapbox/__fixtures__/mastodon_initial_state.json');
__stub(mock => {
mock.onGet('/api/v1/accounts/verify_credentials')
.reply(200, {});
});
const store = mockStore({});
store.dispatch(preloadMastodon(data));
const actions = store.getActions();
expect(actions[0].type).toEqual(ACCOUNTS_IMPORT);
expect(actions[0].accounts[0].username).toEqual('Gargron');
expect(actions[0].accounts[1].username).toEqual('benis911');
expect(actions[1]).toEqual({
type: VERIFY_CREDENTIALS_REQUEST,
token: 'Nh15V9JWyY5Fshf2OJ_feNvOIkTV7YGVfEJFr0Y0D6Q',
});
expect(actions[2]).toEqual({ type: MASTODON_PRELOAD_IMPORT, data });
});
});

View File

@ -1,6 +1,6 @@
import { getFeatures } from 'soapbox/utils/features.ts'; import { getFeatures } from 'soapbox/utils/features.ts';
import api, { getLinks } from '../api/index.ts'; import api from '../api/index.ts';
import type { AppDispatch, RootState } from 'soapbox/store.ts'; import type { AppDispatch, RootState } from 'soapbox/store.ts';
import type { APIEntity } from 'soapbox/types/entities.ts'; import type { APIEntity } from 'soapbox/types/entities.ts';
@ -32,9 +32,10 @@ const fetchScheduledStatuses = () =>
dispatch(fetchScheduledStatusesRequest()); dispatch(fetchScheduledStatusesRequest());
api(getState).get('/api/v1/scheduled_statuses').then(response => { api(getState).get('/api/v1/scheduled_statuses').then(async (response) => {
const next = getLinks(response).refs.find(link => link.rel === 'next'); const next = response.next();
dispatch(fetchScheduledStatusesSuccess(response.data, next ? next.uri : null)); const data = await response.json();
dispatch(fetchScheduledStatusesSuccess(data, next));
}).catch(error => { }).catch(error => {
dispatch(fetchScheduledStatusesFail(error)); dispatch(fetchScheduledStatusesFail(error));
}); });
@ -43,7 +44,7 @@ const fetchScheduledStatuses = () =>
const cancelScheduledStatus = (id: string) => const cancelScheduledStatus = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: SCHEDULED_STATUS_CANCEL_REQUEST, id }); dispatch({ type: SCHEDULED_STATUS_CANCEL_REQUEST, id });
api(getState).delete(`/api/v1/scheduled_statuses/${id}`).then(({ data }) => { api(getState).delete(`/api/v1/scheduled_statuses/${id}`).then((response) => response.json()).then((data) => {
dispatch({ type: SCHEDULED_STATUS_CANCEL_SUCCESS, id, data }); dispatch({ type: SCHEDULED_STATUS_CANCEL_SUCCESS, id, data });
}).catch(error => { }).catch(error => {
dispatch({ type: SCHEDULED_STATUS_CANCEL_FAIL, id, error }); dispatch({ type: SCHEDULED_STATUS_CANCEL_FAIL, id, error });
@ -75,9 +76,10 @@ const expandScheduledStatuses = () =>
dispatch(expandScheduledStatusesRequest()); dispatch(expandScheduledStatusesRequest());
api(getState).get(url).then(response => { api(getState).get(url).then(async (response) => {
const next = getLinks(response).refs.find(link => link.rel === 'next'); const next = response.next();
dispatch(expandScheduledStatusesSuccess(response.data, next ? next.uri : null)); const data = await response.json();
dispatch(expandScheduledStatusesSuccess(data, next));
}).catch(error => { }).catch(error => {
dispatch(expandScheduledStatusesFail(error)); dispatch(expandScheduledStatusesFail(error));
}); });

View File

@ -1,4 +1,4 @@
import api, { getLinks } from '../api/index.ts'; import api from '../api/index.ts';
import { fetchRelationships } from './accounts.ts'; import { fetchRelationships } from './accounts.ts';
import { importFetchedAccounts, importFetchedStatuses } from './importer/index.ts'; import { importFetchedAccounts, importFetchedStatuses } from './importer/index.ts';
@ -72,20 +72,21 @@ const submitSearch = (filter?: SearchFilter) =>
if (accountId) params.account_id = accountId; if (accountId) params.account_id = accountId;
api(getState).get('/api/v2/search', { api(getState).get('/api/v2/search', {
params, searchParams: params,
}).then(response => { }).then(async (response) => {
if (response.data.accounts) { const next = response.next();
dispatch(importFetchedAccounts(response.data.accounts)); const data = await response.json();
if (data.accounts) {
dispatch(importFetchedAccounts(data.accounts));
} }
if (response.data.statuses) { if (data.statuses) {
dispatch(importFetchedStatuses(response.data.statuses)); dispatch(importFetchedStatuses(data.statuses));
} }
const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(fetchSearchSuccess(data, value, type, next));
dispatch(fetchRelationships(data.accounts.map((item: APIEntity) => item.id)));
dispatch(fetchSearchSuccess(response.data, value, type, next ? next.uri : null));
dispatch(fetchRelationships(response.data.accounts.map((item: APIEntity) => item.id)));
}).catch(error => { }).catch(error => {
dispatch(fetchSearchFail(error)); dispatch(fetchSearchFail(error));
}); });
@ -143,9 +144,10 @@ const expandSearch = (type: SearchFilter) => (dispatch: AppDispatch, getState: (
} }
api(getState).get(url, { api(getState).get(url, {
params, searchParams: params,
}).then(response => { }).then(async (response) => {
const data = response.data; const next = response.next();
const data = await response.json();
if (data.accounts) { if (data.accounts) {
dispatch(importFetchedAccounts(data.accounts)); dispatch(importFetchedAccounts(data.accounts));
@ -155,9 +157,7 @@ const expandSearch = (type: SearchFilter) => (dispatch: AppDispatch, getState: (
dispatch(importFetchedStatuses(data.statuses)); dispatch(importFetchedStatuses(data.statuses));
} }
const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(expandSearchSuccess(data, value, type, next));
dispatch(expandSearchSuccess(data, value, type, next ? next.uri : null));
dispatch(fetchRelationships(data.accounts.map((item: APIEntity) => item.id))); dispatch(fetchRelationships(data.accounts.map((item: APIEntity) => item.id)));
}).catch(error => { }).catch(error => {
dispatch(expandSearchFail(error)); dispatch(expandSearchFail(error));

View File

@ -50,7 +50,7 @@ const MOVE_ACCOUNT_FAIL = 'MOVE_ACCOUNT_FAIL';
const fetchOAuthTokens = () => const fetchOAuthTokens = () =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: FETCH_TOKENS_REQUEST }); dispatch({ type: FETCH_TOKENS_REQUEST });
return api(getState).get('/api/oauth_tokens').then(({ data: tokens }) => { return api(getState).get('/api/oauth_tokens').then((response) => response.json()).then((tokens) => {
dispatch({ type: FETCH_TOKENS_SUCCESS, tokens }); dispatch({ type: FETCH_TOKENS_SUCCESS, tokens });
}).catch(() => { }).catch(() => {
dispatch({ type: FETCH_TOKENS_FAIL }); dispatch({ type: FETCH_TOKENS_FAIL });
@ -74,9 +74,9 @@ const changePassword = (oldPassword: string, newPassword: string, confirmation:
password: oldPassword, password: oldPassword,
new_password: newPassword, new_password: newPassword,
new_password_confirmation: confirmation, new_password_confirmation: confirmation,
}).then(response => { }).then((response) => response.json()).then((data) => {
if (response.data.error) throw response.data.error; // This endpoint returns HTTP 200 even on failure if (data.error) throw data.error; // This endpoint returns HTTP 200 even on failure
dispatch({ type: CHANGE_PASSWORD_SUCCESS, response }); dispatch({ type: CHANGE_PASSWORD_SUCCESS, data });
}).catch(error => { }).catch(error => {
dispatch({ type: CHANGE_PASSWORD_FAIL, error, skipAlert: true }); dispatch({ type: CHANGE_PASSWORD_FAIL, error, skipAlert: true });
throw error; throw error;
@ -128,9 +128,9 @@ const changeEmail = (email: string, password: string) =>
return api(getState).post('/api/pleroma/change_email', { return api(getState).post('/api/pleroma/change_email', {
email, email,
password, password,
}).then(response => { }).then((response) => response.json()).then((data) => {
if (response.data.error) throw response.data.error; // This endpoint returns HTTP 200 even on failure if (data.error) throw data.error; // This endpoint returns HTTP 200 even on failure
dispatch({ type: CHANGE_EMAIL_SUCCESS, email, response }); dispatch({ type: CHANGE_EMAIL_SUCCESS, email, data });
}).catch(error => { }).catch(error => {
dispatch({ type: CHANGE_EMAIL_FAIL, email, error, skipAlert: true }); dispatch({ type: CHANGE_EMAIL_FAIL, email, error, skipAlert: true });
throw error; throw error;
@ -148,9 +148,9 @@ const deleteAccount = (password: string) =>
dispatch({ type: DELETE_ACCOUNT_REQUEST }); dispatch({ type: DELETE_ACCOUNT_REQUEST });
return api(getState).post('/api/pleroma/delete_account', { return api(getState).post('/api/pleroma/delete_account', {
password, password,
}).then(response => { }).then((response) => response.json()).then((data) => {
if (response.data.error) throw response.data.error; // This endpoint returns HTTP 200 even on failure if (data.error) throw data.error; // This endpoint returns HTTP 200 even on failure
dispatch({ type: DELETE_ACCOUNT_SUCCESS, response }); dispatch({ type: DELETE_ACCOUNT_SUCCESS, data });
dispatch({ type: AUTH_LOGGED_OUT, account }); dispatch({ type: AUTH_LOGGED_OUT, account });
toast.success(messages.loggedOut); toast.success(messages.loggedOut);
}).catch(error => { }).catch(error => {
@ -165,9 +165,9 @@ const moveAccount = (targetAccount: string, password: string) =>
return api(getState).post('/api/pleroma/move_account', { return api(getState).post('/api/pleroma/move_account', {
password, password,
target_account: targetAccount, target_account: targetAccount,
}).then(response => { }).then((response) => response.json()).then((data) => {
if (response.data.error) throw response.data.error; // This endpoint returns HTTP 200 even on failure if (data.error) throw data.error; // This endpoint returns HTTP 200 even on failure
dispatch({ type: MOVE_ACCOUNT_SUCCESS, response }); dispatch({ type: MOVE_ACCOUNT_SUCCESS, data });
}).catch(error => { }).catch(error => {
dispatch({ type: MOVE_ACCOUNT_FAIL, error, skipAlert: true }); dispatch({ type: MOVE_ACCOUNT_FAIL, error, skipAlert: true });
throw error; throw error;

View File

@ -33,7 +33,7 @@ const fetchFrontendConfigurations = () =>
(dispatch: AppDispatch, getState: () => RootState) => (dispatch: AppDispatch, getState: () => RootState) =>
api(getState) api(getState)
.get('/api/pleroma/frontend_configurations') .get('/api/pleroma/frontend_configurations')
.then(({ data }) => data); .then((response) => response.json()).then((data) => data);
/** Conditionally fetches Soapbox config depending on backend features */ /** Conditionally fetches Soapbox config depending on backend features */
const fetchSoapboxConfig = (host: string | null = null) => const fetchSoapboxConfig = (host: string | null = null) =>

View File

@ -1,158 +0,0 @@
import { Map as ImmutableMap } from 'immutable';
import { beforeEach, describe, expect, it } from 'vitest';
import { __stub } from 'soapbox/api/index.ts';
import { mockStore, rootState } from 'soapbox/jest/test-helpers.tsx';
import { StatusListRecord } from 'soapbox/reducers/status-lists.ts';
import { fetchStatusQuotes, expandStatusQuotes } from './status-quotes.ts';
const status = {
account: {
id: 'ABDSjI3Q0R8aDaz1U0',
},
content: 'quoast',
id: 'AJsajx9hY4Q7IKQXEe',
pleroma: {
quote: {
content: '<p>10</p>',
id: 'AJmoVikzI3SkyITyim',
},
},
};
const statusId = 'AJmoVikzI3SkyITyim';
describe('fetchStatusQuotes()', () => {
let store: ReturnType<typeof mockStore>;
beforeEach(() => {
const state = { ...rootState, me: '1234' };
store = mockStore(state);
});
describe('with a successful API request', () => {
beforeEach(async () => {
const quotes = await import('soapbox/__fixtures__/status-quotes.json');
__stub((mock) => {
mock.onGet(`/api/v1/pleroma/statuses/${statusId}/quotes`).reply(200, quotes, {
link: `<https://example.com/api/v1/pleroma/statuses/${statusId}/quotes?since_id=1>; rel='prev'`,
});
});
});
it('should fetch quotes from the API', async() => {
const expectedActions = [
{ type: 'STATUS_QUOTES_FETCH_REQUEST', statusId },
{ type: 'POLLS_IMPORT', polls: [] },
{ type: 'ACCOUNTS_IMPORT', accounts: [status.account] },
{ type: 'STATUSES_IMPORT', statuses: [status], expandSpoilers: false },
{ type: 'STATUS_QUOTES_FETCH_SUCCESS', statusId, statuses: [status], next: null },
];
await store.dispatch(fetchStatusQuotes(statusId));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
describe('with an unsuccessful API request', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/pleroma/statuses/${statusId}/quotes`).networkError();
});
});
it('should dispatch failed action', async() => {
const expectedActions = [
{ type: 'STATUS_QUOTES_FETCH_REQUEST', statusId },
{ type: 'STATUS_QUOTES_FETCH_FAIL', statusId, error: new Error('Network Error') },
];
await store.dispatch(fetchStatusQuotes(statusId));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
});
describe('expandStatusQuotes()', () => {
let store: ReturnType<typeof mockStore>;
describe('without a url', () => {
beforeEach(() => {
const state = {
...rootState,
me: '1234',
status_lists: ImmutableMap({ [`quotes:${statusId}`]: StatusListRecord({ next: null }) }),
};
store = mockStore(state);
});
it('should do nothing', async() => {
await store.dispatch(expandStatusQuotes(statusId));
const actions = store.getActions();
expect(actions).toEqual([]);
});
});
describe('with a url', () => {
beforeEach(() => {
const state = {
...rootState,
status_lists: ImmutableMap({ [`quotes:${statusId}`]: StatusListRecord({ next: 'example' }) }),
me: '1234',
};
store = mockStore(state);
});
describe('with a successful API request', () => {
beforeEach(async () => {
const quotes = await import('soapbox/__fixtures__/status-quotes.json');
__stub((mock) => {
mock.onGet('example').reply(200, quotes, {
link: `<https://example.com/api/v1/pleroma/statuses/${statusId}/quotes?since_id=1>; rel='prev'`,
});
});
});
it('should fetch quotes from the API', async() => {
const expectedActions = [
{ type: 'STATUS_QUOTES_EXPAND_REQUEST', statusId },
{ type: 'POLLS_IMPORT', polls: [] },
{ type: 'ACCOUNTS_IMPORT', accounts: [status.account] },
{ type: 'STATUSES_IMPORT', statuses: [status], expandSpoilers: false },
{ type: 'STATUS_QUOTES_EXPAND_SUCCESS', statusId, statuses: [status], next: null },
];
await store.dispatch(expandStatusQuotes(statusId));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
describe('with an unsuccessful API request', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('example').networkError();
});
});
it('should dispatch failed action', async() => {
const expectedActions = [
{ type: 'STATUS_QUOTES_EXPAND_REQUEST', statusId },
{ type: 'STATUS_QUOTES_EXPAND_FAIL', statusId, error: new Error('Network Error') },
];
await store.dispatch(expandStatusQuotes(statusId));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
});
});

View File

@ -1,4 +1,4 @@
import api, { getLinks } from '../api/index.ts'; import api from '../api/index.ts';
import { importFetchedStatuses } from './importer/index.ts'; import { importFetchedStatuses } from './importer/index.ts';
@ -25,14 +25,15 @@ export const fetchStatusQuotes = (statusId: string) =>
type: STATUS_QUOTES_FETCH_REQUEST, type: STATUS_QUOTES_FETCH_REQUEST,
}); });
return api(getState).get(`/api/v1/pleroma/statuses/${statusId}/quotes`).then(response => { return api(getState).get(`/api/v1/pleroma/statuses/${statusId}/quotes`).then(async (response) => {
const next = getLinks(response).refs.find(link => link.rel === 'next'); const next = response.next();
dispatch(importFetchedStatuses(response.data)); const data = await response.json();
dispatch(importFetchedStatuses(data));
return dispatch({ return dispatch({
type: STATUS_QUOTES_FETCH_SUCCESS, type: STATUS_QUOTES_FETCH_SUCCESS,
statusId, statusId,
statuses: response.data, statuses: data,
next: next ? next.uri : null, next,
}); });
}).catch(error => { }).catch(error => {
dispatch({ dispatch({
@ -56,14 +57,14 @@ export const expandStatusQuotes = (statusId: string) =>
statusId, statusId,
}); });
return api(getState).get(url).then(response => { return api(getState).get(url).then(async (response) => {
const next = getLinks(response).refs.find(link => link.rel === 'next'); const data = await response.json();
dispatch(importFetchedStatuses(response.data)); dispatch(importFetchedStatuses(data));
dispatch({ dispatch({
type: STATUS_QUOTES_EXPAND_SUCCESS, type: STATUS_QUOTES_EXPAND_SUCCESS,
statusId, statusId,
statuses: response.data, statuses: data,
next: next ? next.uri : null, next: response.next(),
}); });
}).catch(error => { }).catch(error => {
dispatch({ dispatch({

View File

@ -1,162 +0,0 @@
import { fromJS, Map as ImmutableMap } from 'immutable';
import { beforeEach, describe, expect, it } from 'vitest';
import { STATUSES_IMPORT } from 'soapbox/actions/importer/index.ts';
import { __stub } from 'soapbox/api/index.ts';
import { mockStore, rootState } from 'soapbox/jest/test-helpers.tsx';
import { normalizeStatus } from 'soapbox/normalizers/status.ts';
import { deleteStatus, fetchContext } from './statuses.ts';
describe('fetchContext()', () => {
it('handles Mitra context', async () => {
const statuses = await import('soapbox/__fixtures__/mitra-context.json');
__stub(mock => {
mock.onGet('/api/v1/statuses/017ed505-5926-392f-256a-f86d5075df70/context')
.reply(200, statuses);
});
const store = mockStore(rootState);
await store.dispatch(fetchContext('017ed505-5926-392f-256a-f86d5075df70'));
const actions = store.getActions();
expect(actions[3].type).toEqual(STATUSES_IMPORT);
expect(actions[3].statuses[0].id).toEqual('017ed503-bc96-301a-e871-2c23b30ddd05');
});
});
describe('deleteStatus()', () => {
let store: ReturnType<typeof mockStore>;
describe('if logged out', () => {
beforeEach(() => {
const state = { ...rootState, me: null };
store = mockStore(state);
});
it('should do nothing', async() => {
await store.dispatch(deleteStatus('1'));
const actions = store.getActions();
expect(actions).toEqual([]);
});
});
describe('if logged in', () => {
const statusId = 'AHU2RrX0wdcwzCYjFQ';
const cachedStatus = normalizeStatus({
id: statusId,
});
beforeEach(() => {
const state = {
...rootState,
me: '1234',
statuses: fromJS({
[statusId]: cachedStatus,
}) as any,
};
store = mockStore(state);
});
describe('with a successful API request', () => {
let status: any;
beforeEach(async () => {
status = await import('soapbox/__fixtures__/pleroma-status-deleted.json');
__stub((mock) => {
mock.onDelete(`/api/v1/statuses/${statusId}`).reply(200, status);
});
});
it('should delete the status from the API', async() => {
const expectedActions = [
{
type: 'STATUS_DELETE_REQUEST',
params: cachedStatus,
},
{ type: 'STATUS_DELETE_SUCCESS', id: statusId },
{
type: 'TIMELINE_DELETE',
id: statusId,
accountId: null,
references: ImmutableMap({}),
reblogOf: null,
},
];
await store.dispatch(deleteStatus(statusId));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
it('should handle redraft', async() => {
const expectedActions = [
{
type: 'STATUS_DELETE_REQUEST',
params: cachedStatus,
},
{ type: 'STATUS_DELETE_SUCCESS', id: statusId },
{
type: 'TIMELINE_DELETE',
id: statusId,
accountId: null,
references: ImmutableMap({}),
reblogOf: null,
},
{
type: 'COMPOSE_SET_STATUS',
status: cachedStatus,
rawText: status.text,
explicitAddressing: false,
spoilerText: '',
contentType: 'text/markdown',
v: {
build: undefined,
compatVersion: '0.0.0',
software: 'Mastodon',
version: '0.0.0',
},
withRedraft: true,
id: 'compose-modal',
},
{ type: 'MODAL_CLOSE', modalType: 'COMPOSE', modalProps: undefined },
{ type: 'MODAL_OPEN', modalType: 'COMPOSE', modalProps: undefined },
];
await store.dispatch(deleteStatus(statusId, true));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
describe('with an unsuccessful API request', () => {
beforeEach(() => {
__stub((mock) => {
mock.onDelete(`/api/v1/statuses/${statusId}`).networkError();
});
});
it('should dispatch failed action', async() => {
const expectedActions = [
{
type: 'STATUS_DELETE_REQUEST',
params: cachedStatus,
},
{
type: 'STATUS_DELETE_FAIL',
params: cachedStatus,
error: new Error('Network Error'),
},
];
await store.dispatch(deleteStatus(statusId, true));
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
});
});

View File

@ -2,7 +2,7 @@ import { isLoggedIn } from 'soapbox/utils/auth.ts';
import { getFeatures } from 'soapbox/utils/features.ts'; import { getFeatures } from 'soapbox/utils/features.ts';
import { shouldHaveCard } from 'soapbox/utils/status.ts'; import { shouldHaveCard } from 'soapbox/utils/status.ts';
import api, { getNextLink } from '../api/index.ts'; import api from '../api/index.ts';
import { setComposeToStatus } from './compose-status.ts'; import { setComposeToStatus } from './compose-status.ts';
import { fetchGroupRelationships } from './groups.ts'; import { fetchGroupRelationships } from './groups.ts';
@ -59,12 +59,13 @@ const createStatus = (params: Record<string, any>, idempotencyKey: string, statu
return (dispatch: AppDispatch, getState: () => RootState) => { return (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: STATUS_CREATE_REQUEST, params, idempotencyKey, editing: !!statusId }); dispatch({ type: STATUS_CREATE_REQUEST, params, idempotencyKey, editing: !!statusId });
return api(getState).request({ const method = statusId === null ? 'POST' : 'PUT';
url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`, const path = statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`;
method: statusId === null ? 'post' : 'put', const headers = { 'Idempotency-Key': idempotencyKey };
data: params,
headers: { 'Idempotency-Key': idempotencyKey }, return api(getState).request(method, path, params, { headers }).then(async (response) => {
}).then(({ data: status }) => { const status = await response.json();
// The backend might still be processing the rich media attachment // The backend might still be processing the rich media attachment
if (!status.card && shouldHaveCard(status)) { if (!status.card && shouldHaveCard(status)) {
status.expectsCard = true; status.expectsCard = true;
@ -78,9 +79,9 @@ const createStatus = (params: Record<string, any>, idempotencyKey: string, statu
const delay = 1000; const delay = 1000;
const poll = (retries = 5) => { const poll = (retries = 5) => {
api(getState).get(`/api/v1/statuses/${status.id}`).then(response => { api(getState).get(`/api/v1/statuses/${status.id}`).then((response) => response.json()).then((data) => {
if (response.data?.card) { if (data?.card) {
dispatch(importFetchedStatus(response.data)); dispatch(importFetchedStatus(data));
} else if (retries > 0 && response.status === 200) { } else if (retries > 0 && response.status === 200) {
setTimeout(() => poll(retries - 1), delay); setTimeout(() => poll(retries - 1), delay);
} }
@ -107,9 +108,9 @@ const editStatus = (id: string) => (dispatch: AppDispatch, getState: () => RootS
dispatch({ type: STATUS_FETCH_SOURCE_REQUEST }); dispatch({ type: STATUS_FETCH_SOURCE_REQUEST });
api(getState).get(`/api/v1/statuses/${id}/source`).then(response => { api(getState).get(`/api/v1/statuses/${id}/source`).then((response) => response.json()).then((data) => {
dispatch({ type: STATUS_FETCH_SOURCE_SUCCESS }); dispatch({ type: STATUS_FETCH_SOURCE_SUCCESS });
dispatch(setComposeToStatus(status, response.data.text, response.data.spoiler_text, response.data.content_type, false)); dispatch(setComposeToStatus(status, data.text, data.spoiler_text, data.content_type, false));
dispatch(openModal('COMPOSE')); dispatch(openModal('COMPOSE'));
}).catch(error => { }).catch(error => {
dispatch({ type: STATUS_FETCH_SOURCE_FAIL, error }); dispatch({ type: STATUS_FETCH_SOURCE_FAIL, error });
@ -123,7 +124,7 @@ const fetchStatus = (id: string) => {
dispatch({ type: STATUS_FETCH_REQUEST, id, skipLoading }); dispatch({ type: STATUS_FETCH_REQUEST, id, skipLoading });
return api(getState).get(`/api/v1/statuses/${id}`).then(({ data: status }) => { return api(getState).get(`/api/v1/statuses/${id}`).then((response) => response.json()).then((status) => {
dispatch(importFetchedStatus(status)); dispatch(importFetchedStatus(status));
if (status.group) { if (status.group) {
dispatch(fetchGroupRelationships([status.group.id])); dispatch(fetchGroupRelationships([status.group.id]));
@ -150,12 +151,12 @@ const deleteStatus = (id: string, withRedraft = false) => {
return api(getState) return api(getState)
.delete(`/api/v1/statuses/${id}`) .delete(`/api/v1/statuses/${id}`)
.then(response => { .then((response) => response.json()).then((data) => {
dispatch({ type: STATUS_DELETE_SUCCESS, id }); dispatch({ type: STATUS_DELETE_SUCCESS, id });
dispatch(deleteFromTimelines(id)); dispatch(deleteFromTimelines(id));
if (withRedraft) { if (withRedraft) {
dispatch(setComposeToStatus(status, response.data.text, response.data.spoiler_text, response.data.pleroma?.content_type, withRedraft)); dispatch(setComposeToStatus(status, data.text, data.spoiler_text, data.pleroma?.content_type, withRedraft));
dispatch(openModal('COMPOSE')); dispatch(openModal('COMPOSE'));
} }
}) })
@ -172,7 +173,7 @@ const fetchContext = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: CONTEXT_FETCH_REQUEST, id }); dispatch({ type: CONTEXT_FETCH_REQUEST, id });
return api(getState).get(`/api/v1/statuses/${id}/context`).then(({ data: context }) => { return api(getState).get(`/api/v1/statuses/${id}/context`).then((response) => response.json()).then((context) => {
if (Array.isArray(context)) { if (Array.isArray(context)) {
// Mitra: returns a list of statuses // Mitra: returns a list of statuses
dispatch(importFetchedStatuses(context)); dispatch(importFetchedStatuses(context));
@ -198,29 +199,33 @@ const fetchContext = (id: string) =>
const fetchNext = (statusId: string, next: string) => const fetchNext = (statusId: string, next: string) =>
async(dispatch: AppDispatch, getState: () => RootState) => { async(dispatch: AppDispatch, getState: () => RootState) => {
const response = await api(getState).get(next); const response = await api(getState).get(next);
dispatch(importFetchedStatuses(response.data)); const data = await response.json();
dispatch(importFetchedStatuses(data));
dispatch({ dispatch({
type: CONTEXT_FETCH_SUCCESS, type: CONTEXT_FETCH_SUCCESS,
id: statusId, id: statusId,
ancestors: [], ancestors: [],
descendants: response.data, descendants: data,
}); });
return { next: getNextLink(response) }; return { next: response.pagination().next };
}; };
const fetchAncestors = (id: string) => const fetchAncestors = (id: string) =>
async(dispatch: AppDispatch, getState: () => RootState) => { async(dispatch: AppDispatch, getState: () => RootState) => {
const response = await api(getState).get(`/api/v1/statuses/${id}/context/ancestors`); const response = await api(getState).get(`/api/v1/statuses/${id}/context/ancestors`);
dispatch(importFetchedStatuses(response.data)); const data = await response.json();
dispatch(importFetchedStatuses(data));
return response; return response;
}; };
const fetchDescendants = (id: string) => const fetchDescendants = (id: string) =>
async(dispatch: AppDispatch, getState: () => RootState) => { async(dispatch: AppDispatch, getState: () => RootState) => {
const response = await api(getState).get(`/api/v1/statuses/${id}/context/descendants`); const response = await api(getState).get(`/api/v1/statuses/${id}/context/descendants`);
dispatch(importFetchedStatuses(response.data)); const data = await response.json();
dispatch(importFetchedStatuses(data));
return response; return response;
}; };
@ -230,7 +235,8 @@ const fetchStatusWithContext = (id: string) =>
if (features.paginatedContext) { if (features.paginatedContext) {
await dispatch(fetchStatus(id)); await dispatch(fetchStatus(id));
const responses = await Promise.all([
const [ancestors, descendants] = await Promise.all([
dispatch(fetchAncestors(id)), dispatch(fetchAncestors(id)),
dispatch(fetchDescendants(id)), dispatch(fetchDescendants(id)),
]); ]);
@ -238,18 +244,17 @@ const fetchStatusWithContext = (id: string) =>
dispatch({ dispatch({
type: CONTEXT_FETCH_SUCCESS, type: CONTEXT_FETCH_SUCCESS,
id, id,
ancestors: responses[0].data, ancestors: await ancestors.json(),
descendants: responses[1].data, descendants: await descendants.json(),
}); });
const next = getNextLink(responses[1]); return descendants.pagination();
return { next };
} else { } else {
await Promise.all([ await Promise.all([
dispatch(fetchContext(id)), dispatch(fetchContext(id)),
dispatch(fetchStatus(id)), dispatch(fetchStatus(id)),
]); ]);
return { next: undefined }; return { next: null, prev: null };
} }
}; };
@ -322,11 +327,11 @@ const translateStatus = (id: string, lang?: string) => (dispatch: AppDispatch, g
api(getState).post(`/api/v1/statuses/${id}/translate`, { api(getState).post(`/api/v1/statuses/${id}/translate`, {
lang, // Mastodon API lang, // Mastodon API
target_language: lang, // HACK: Rebased and Pleroma compatibility target_language: lang, // HACK: Rebased and Pleroma compatibility
}).then(response => { }).then((response) => response.json()).then((data) => {
dispatch({ dispatch({
type: STATUS_TRANSLATE_SUCCESS, type: STATUS_TRANSLATE_SUCCESS,
id, id,
translation: response.data, translation: data,
}); });
}).catch(error => { }).catch(error => {
dispatch({ dispatch({

View File

@ -1,7 +1,7 @@
import { isLoggedIn } from 'soapbox/utils/auth.ts'; import { isLoggedIn } from 'soapbox/utils/auth.ts';
import { getFeatures } from 'soapbox/utils/features.ts'; import { getFeatures } from 'soapbox/utils/features.ts';
import api, { getLinks } from '../api/index.ts'; import api from '../api/index.ts';
import { fetchRelationships } from './accounts.ts'; import { fetchRelationships } from './accounts.ts';
import { importFetchedAccounts } from './importer/index.ts'; import { importFetchedAccounts } from './importer/index.ts';
@ -23,7 +23,7 @@ const SUGGESTIONS_V2_FETCH_FAIL = 'SUGGESTIONS_V2_FETCH_FAIL';
const fetchSuggestionsV1 = (params: Record<string, any> = {}) => const fetchSuggestionsV1 = (params: Record<string, any> = {}) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: SUGGESTIONS_FETCH_REQUEST, skipLoading: true }); dispatch({ type: SUGGESTIONS_FETCH_REQUEST, skipLoading: true });
return api(getState).get('/api/v1/suggestions', { params }).then(({ data: accounts }) => { return api(getState).get('/api/v1/suggestions', { searchParams: params }).then((response) => response.json()).then((accounts) => {
dispatch(importFetchedAccounts(accounts)); dispatch(importFetchedAccounts(accounts));
dispatch({ type: SUGGESTIONS_FETCH_SUCCESS, accounts, skipLoading: true }); dispatch({ type: SUGGESTIONS_FETCH_SUCCESS, accounts, skipLoading: true });
return accounts; return accounts;
@ -39,10 +39,10 @@ const fetchSuggestionsV2 = (params: Record<string, any> = {}) =>
dispatch({ type: SUGGESTIONS_V2_FETCH_REQUEST, skipLoading: true }); dispatch({ type: SUGGESTIONS_V2_FETCH_REQUEST, skipLoading: true });
return api(getState).get(next ? next : '/api/v2/suggestions', next ? {} : { params }).then((response) => { return api(getState).get(next ?? '/api/v2/suggestions', next ? {} : { searchParams: params }).then(async (response) => {
const suggestions: APIEntity[] = response.data; const suggestions: APIEntity[] = await response.json();
const accounts = suggestions.map(({ account }) => account); const accounts = suggestions.map(({ account }) => account);
const next = getLinks(response).refs.find(link => link.rel === 'next')?.uri; const next = response.next();
dispatch(importFetchedAccounts(accounts)); dispatch(importFetchedAccounts(accounts));
dispatch({ type: SUGGESTIONS_V2_FETCH_SUCCESS, suggestions, next, skipLoading: true }); dispatch({ type: SUGGESTIONS_V2_FETCH_SUCCESS, suggestions, next, skipLoading: true });

View File

@ -1,4 +1,4 @@
import api, { getLinks } from '../api/index.ts'; import api from '../api/index.ts';
import type { AppDispatch, RootState } from 'soapbox/store.ts'; import type { AppDispatch, RootState } from 'soapbox/store.ts';
import type { APIEntity } from 'soapbox/types/entities.ts'; import type { APIEntity } from 'soapbox/types/entities.ts';
@ -26,7 +26,7 @@ const FOLLOWED_HASHTAGS_EXPAND_FAIL = 'FOLLOWED_HASHTAGS_EXPAND_FAIL';
const fetchHashtag = (name: string) => (dispatch: AppDispatch, getState: () => RootState) => { const fetchHashtag = (name: string) => (dispatch: AppDispatch, getState: () => RootState) => {
dispatch(fetchHashtagRequest()); dispatch(fetchHashtagRequest());
api(getState).get(`/api/v1/tags/${name}`).then(({ data }) => { api(getState).get(`/api/v1/tags/${name}`).then((response) => response.json()).then((data) => {
dispatch(fetchHashtagSuccess(name, data)); dispatch(fetchHashtagSuccess(name, data));
}).catch(err => { }).catch(err => {
dispatch(fetchHashtagFail(err)); dispatch(fetchHashtagFail(err));
@ -51,7 +51,7 @@ const fetchHashtagFail = (error: unknown) => ({
const followHashtag = (name: string) => (dispatch: AppDispatch, getState: () => RootState) => { const followHashtag = (name: string) => (dispatch: AppDispatch, getState: () => RootState) => {
dispatch(followHashtagRequest(name)); dispatch(followHashtagRequest(name));
api(getState).post(`/api/v1/tags/${name}/follow`).then(({ data }) => { api(getState).post(`/api/v1/tags/${name}/follow`).then((response) => response.json()).then((data) => {
dispatch(followHashtagSuccess(name, data)); dispatch(followHashtagSuccess(name, data));
}).catch(err => { }).catch(err => {
dispatch(followHashtagFail(name, err)); dispatch(followHashtagFail(name, err));
@ -78,7 +78,7 @@ const followHashtagFail = (name: string, error: unknown) => ({
const unfollowHashtag = (name: string) => (dispatch: AppDispatch, getState: () => RootState) => { const unfollowHashtag = (name: string) => (dispatch: AppDispatch, getState: () => RootState) => {
dispatch(unfollowHashtagRequest(name)); dispatch(unfollowHashtagRequest(name));
api(getState).post(`/api/v1/tags/${name}/unfollow`).then(({ data }) => { api(getState).post(`/api/v1/tags/${name}/unfollow`).then((response) => response.json()).then((data) => {
dispatch(unfollowHashtagSuccess(name, data)); dispatch(unfollowHashtagSuccess(name, data));
}).catch(err => { }).catch(err => {
dispatch(unfollowHashtagFail(name, err)); dispatch(unfollowHashtagFail(name, err));
@ -105,9 +105,10 @@ const unfollowHashtagFail = (name: string, error: unknown) => ({
const fetchFollowedHashtags = () => (dispatch: AppDispatch, getState: () => RootState) => { const fetchFollowedHashtags = () => (dispatch: AppDispatch, getState: () => RootState) => {
dispatch(fetchFollowedHashtagsRequest()); dispatch(fetchFollowedHashtagsRequest());
api(getState).get('/api/v1/followed_tags').then(response => { api(getState).get('/api/v1/followed_tags').then(async (response) => {
const next = getLinks(response).refs.find(link => link.rel === 'next'); const next = response.next();
dispatch(fetchFollowedHashtagsSuccess(response.data, next ? next.uri : null)); const data = await response.json();
dispatch(fetchFollowedHashtagsSuccess(data, next));
}).catch(err => { }).catch(err => {
dispatch(fetchFollowedHashtagsFail(err)); dispatch(fetchFollowedHashtagsFail(err));
}); });
@ -137,9 +138,10 @@ const expandFollowedHashtags = () => (dispatch: AppDispatch, getState: () => Roo
dispatch(expandFollowedHashtagsRequest()); dispatch(expandFollowedHashtagsRequest());
api(getState).get(url).then(response => { api(getState).get(url).then(async (response) => {
const next = getLinks(response).refs.find(link => link.rel === 'next'); const next = response.next();
dispatch(expandFollowedHashtagsSuccess(response.data, next ? next.uri : null)); const data = await response.json();
dispatch(expandFollowedHashtagsSuccess(data, next));
}).catch(error => { }).catch(error => {
dispatch(expandFollowedHashtagsFail(error)); dispatch(expandFollowedHashtagsFail(error));
}); });

View File

@ -4,7 +4,7 @@ import { getSettings } from 'soapbox/actions/settings.ts';
import { normalizeStatus } from 'soapbox/normalizers/index.ts'; import { normalizeStatus } from 'soapbox/normalizers/index.ts';
import { shouldFilter } from 'soapbox/utils/timelines.ts'; import { shouldFilter } from 'soapbox/utils/timelines.ts';
import api, { getNextLink, getPrevLink } from '../api/index.ts'; import api from '../api/index.ts';
import { fetchGroupRelationships } from './groups.ts'; import { fetchGroupRelationships } from './groups.ts';
import { importFetchedStatus, importFetchedStatuses } from './importer/index.ts'; import { importFetchedStatus, importFetchedStatuses } from './importer/index.ts';
@ -169,17 +169,20 @@ const expandTimeline = (timelineId: string, path: string, params: Record<string,
dispatch(expandTimelineRequest(timelineId, isLoadingMore)); dispatch(expandTimelineRequest(timelineId, isLoadingMore));
return api(getState).get(path, { params }).then(response => { return api(getState).get(path, { searchParams: params }).then(async (response) => {
dispatch(importFetchedStatuses(response.data)); const { next, prev } = response.pagination();
const data: APIEntity[] = await response.json();
const statusesFromGroups = (response.data as Status[]).filter((status) => !!status.group); dispatch(importFetchedStatuses(data));
const statusesFromGroups = (data as Status[]).filter((status) => !!status.group);
dispatch(fetchGroupRelationships(statusesFromGroups.map((status: any) => status.group?.id))); dispatch(fetchGroupRelationships(statusesFromGroups.map((status: any) => status.group?.id)));
dispatch(expandTimelineSuccess( dispatch(expandTimelineSuccess(
timelineId, timelineId,
response.data, data,
getNextLink(response), next,
getPrevLink(response), prev,
response.status === 206, response.status === 206,
isLoadingRecent, isLoadingRecent,
isLoadingMore, isLoadingMore,
@ -267,8 +270,8 @@ const expandTimelineRequest = (timeline: string, isLoadingMore: boolean) => ({
const expandTimelineSuccess = ( const expandTimelineSuccess = (
timeline: string, timeline: string,
statuses: APIEntity[], statuses: APIEntity[],
next: string | undefined, next: string | null,
prev: string | undefined, prev: string | null,
partial: boolean, partial: boolean,
isLoadingRecent: boolean, isLoadingRecent: boolean,
isLoadingMore: boolean, isLoadingMore: boolean,

View File

@ -1,7 +1,7 @@
import { APIEntity } from 'soapbox/types/entities.ts'; import { APIEntity } from 'soapbox/types/entities.ts';
import { getFeatures } from 'soapbox/utils/features.ts'; import { getFeatures } from 'soapbox/utils/features.ts';
import api, { getLinks } from '../api/index.ts'; import api from '../api/index.ts';
import { importFetchedStatuses } from './importer/index.ts'; import { importFetchedStatuses } from './importer/index.ts';
@ -23,13 +23,14 @@ const fetchTrendingStatuses = () =>
if (!features.trendingStatuses) return; if (!features.trendingStatuses) return;
dispatch({ type: TRENDING_STATUSES_FETCH_REQUEST }); dispatch({ type: TRENDING_STATUSES_FETCH_REQUEST });
return api(getState).get('/api/v1/trends/statuses').then((response) => { return api(getState).get('/api/v1/trends/statuses').then(async (response) => {
const next = getLinks(response).refs.find(link => link.rel === 'next'); const next = response.next();
const data = await response.json();
const statuses = response.data; const statuses = data;
dispatch(importFetchedStatuses(statuses)); dispatch(importFetchedStatuses(statuses));
dispatch(fetchTrendingStatusesSuccess(statuses, next ? next.uri : null)); dispatch(fetchTrendingStatusesSuccess(statuses, next));
return statuses; return statuses;
}).catch(error => { }).catch(error => {
dispatch(fetchTrendingStatusesFail(error)); dispatch(fetchTrendingStatusesFail(error));
@ -50,13 +51,14 @@ const fetchTrendingStatusesFail = (error: unknown) => ({
const expandTrendingStatuses = (path: string) => const expandTrendingStatuses = (path: string) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
api(getState).get(path).then(response => { api(getState).get(path).then(async (response) => {
const next = getLinks(response).refs.find(link => link.rel === 'next'); const next = response.next();
const data = await response.json();
const statuses = response.data; const statuses = data;
dispatch(importFetchedStatuses(statuses)); dispatch(importFetchedStatuses(statuses));
dispatch(expandTrendingStatusesSuccess(statuses, next ? next.uri : null)); dispatch(expandTrendingStatusesSuccess(statuses, next));
}).catch(error => { }).catch(error => {
dispatch(expandTrendingStatusesFail(error)); dispatch(expandTrendingStatusesFail(error));
}); });

View File

@ -11,8 +11,8 @@ const fetchTrends = () =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch(fetchTrendsRequest()); dispatch(fetchTrendsRequest());
api(getState).get('/api/v1/trends').then(response => { api(getState).get('/api/v1/trends').then((response) => response.json()).then(data => {
dispatch(fetchTrendsSuccess(response.data)); dispatch(fetchTrendsSuccess(data));
}).catch(error => dispatch(fetchTrendsFail(error))); }).catch(error => dispatch(fetchTrendsFail(error)));
}; };

View File

@ -1,9 +1,11 @@
import { MastodonResponse } from 'soapbox/api/MastodonResponse.ts';
export class HTTPError extends Error { export class HTTPError extends Error {
response: Response; response: MastodonResponse;
request: Request; request: Request;
constructor(response: Response, request: Request) { constructor(response: MastodonResponse, request: Request) {
super(response.statusText); super(response.statusText);
this.response = response; this.response = response;
this.request = request; this.request = request;

View File

@ -2,7 +2,8 @@ import { HTTPError } from './HTTPError.ts';
import { MastodonResponse } from './MastodonResponse.ts'; import { MastodonResponse } from './MastodonResponse.ts';
interface Opts { interface Opts {
searchParams?: URLSearchParams | Record<string, string | number | boolean>; searchParams?: URLSearchParams | Record<string, string | number | boolean | string[] | number[] | boolean[] | null | undefined>;
onUploadProgress?: (e: ProgressEvent) => void;
headers?: Record<string, string>; headers?: Record<string, string>;
signal?: AbortSignal; signal?: AbortSignal;
} }
@ -56,7 +57,16 @@ export class MastodonClient {
? opts.searchParams ? opts.searchParams
: Object : Object
.entries(opts.searchParams) .entries(opts.searchParams)
.map(([key, value]) => ([key, String(value)])); .reduce<[string, string][]>((acc, [key, value]) => {
if (Array.isArray(value)) {
for (const v of value) {
acc.push([`${key}[]`, String(v)]);
}
} else if (value !== undefined && value !== null) {
acc.push([key, String(value)]);
}
return acc;
}, []);
url.search = new URLSearchParams(params).toString(); url.search = new URLSearchParams(params).toString();
} }
@ -70,7 +80,6 @@ export class MastodonClient {
let body: BodyInit | undefined; let body: BodyInit | undefined;
if (data instanceof FormData) { if (data instanceof FormData) {
headers.set('Content-Type', 'multipart/form-data');
body = data; body = data;
} else if (data !== undefined) { } else if (data !== undefined) {
headers.set('Content-Type', 'application/json'); headers.set('Content-Type', 'application/json');
@ -84,19 +93,77 @@ export class MastodonClient {
body, body,
}); });
const response = await this.fetch(request); const response = opts.onUploadProgress
? await this.xhr(request, opts)
: MastodonResponse.fromResponse(await this.fetch(request));
if (!response.ok) { if (!response.ok) {
throw new HTTPError(response, request); throw new HTTPError(response, request);
} }
// Fix for non-compliant browsers. return response;
// https://developer.mozilla.org/en-US/docs/Web/API/Response/body
if (response.status === 204 || request.method === 'HEAD') {
return new MastodonResponse(null, response);
} }
return new MastodonResponse(response.body, response); /**
* Perform an XHR request from the native `Request` object and get back a `MastodonResponse`.
* This is needed because unfortunately `fetch` does not support upload progress.
*/
private async xhr(request: Request, opts: Opts = {}): Promise<MastodonResponse> {
const xhr = new XMLHttpRequest();
const { resolve, reject, promise } = Promise.withResolvers<MastodonResponse>();
xhr.responseType = 'arraybuffer';
xhr.onreadystatechange = () => {
if (xhr.readyState !== XMLHttpRequest.DONE) {
return;
}
const headers = new Headers(
xhr.getAllResponseHeaders()
.trim()
.split(/[\r\n]+/)
.map((line): [string, string] => {
const [name, ...rest] = line.split(': ');
const value = rest.join(': ');
return [name, value];
}),
);
const response = new MastodonResponse(xhr.response, {
status: xhr.status,
statusText: xhr.statusText,
headers,
});
resolve(response);
};
xhr.onerror = () => {
reject(new TypeError('Network request failed'));
};
xhr.onabort = () => {
reject(new DOMException('The request was aborted', 'AbortError'));
};
if (opts.onUploadProgress) {
xhr.upload.onprogress = opts.onUploadProgress;
}
if (opts.signal) {
opts.signal.addEventListener('abort', () => xhr.abort(), { once: true });
}
xhr.open(request.method, request.url, true);
for (const [name, value] of request.headers) {
xhr.setRequestHeader(name, value);
}
xhr.send(await request.arrayBuffer());
return promise;
} }
} }

View File

@ -1,16 +1,87 @@
import LinkHeader from 'http-link-header'; import LinkHeader from 'http-link-header';
import { z } from 'zod';
/** Mastodon JSON error response. */
export interface MastodonError {
/** Error message in plaintext, to be displayed in the UI. */
error: string;
/** Map of field validation errors. See: https://github.com/mastodon/mastodon/pull/15803 */
detail?: Record<string, { error: string; description: string }[]>;
}
/** Parsed Mastodon `Link` header. */
export interface MastodonLink {
rel: string;
uri: string;
}
export class MastodonResponse extends Response { export class MastodonResponse extends Response {
/** Parses the `Link` header and returns URLs for the `prev` and `next` pages of this response, if any. */ /** Construct a `MastodonResponse` from a regular `Response` object. */
pagination(): { prev?: string; next?: string } { static fromResponse(response: Response): MastodonResponse {
// Fix for non-compliant browsers.
// https://developer.mozilla.org/en-US/docs/Web/API/Response/body
if (response.status === 204) {
return new MastodonResponse(null, response);
}
return new MastodonResponse(response.body, response);
}
/** Parses the `Link` header and returns an array of URLs and their rel values. */
links(): MastodonLink[] {
const header = this.headers.get('link'); const header = this.headers.get('link');
const links = header ? new LinkHeader(header) : undefined;
if (header) {
return new LinkHeader(header).refs;
} else {
return [];
}
}
/** Parses the `Link` header and returns URLs for the `prev` and `next` pages of this response, if any. */
pagination(): { prev: string | null; next: string | null } {
const links = this.links();
return { return {
next: links?.refs.find((link) => link.rel === 'next')?.uri, next: links.find((link) => link.rel === 'next')?.uri ?? null,
prev: links?.refs.find((link) => link.rel === 'prev')?.uri, prev: links.find((link) => link.rel === 'prev')?.uri ?? null,
}; };
} }
/** Returns the `next` URI from the `Link` header, if applicable. */
next(): string | null {
const links = this.links();
return links.find((link) => link.rel === 'next')?.uri ?? null;
}
/** Returns the `prev` URI from the `Link` header, if applicable. */
prev(): string | null {
const links = this.links();
return links.find((link) => link.rel === 'prev')?.uri ?? null;
}
/** Extracts the error JSON from the response body, if possible. Otherwise returns `null`. */
async error(): Promise<MastodonError | null> {
const data = await this.json();
const result = MastodonResponse.errorSchema().safeParse(data);
if (result.success) {
return result.data;
} else {
return null;
}
}
/** Validates the error response schema. */
private static errorSchema(): z.ZodType<MastodonError> {
return z.object({
error: z.string(),
detail: z.record(
z.string(),
z.object({ error: z.string(), description: z.string() }).array(),
).optional(),
});
}
} }

View File

@ -1,42 +0,0 @@
import MockAdapter from 'axios-mock-adapter';
import LinkHeader from 'http-link-header';
import { vi } from 'vitest';
import type { AxiosInstance, AxiosResponse } from 'axios';
const api = await vi.importActual('../index') as Record<string, Function>;
let mocks: Array<Function> = [];
export const __stub = (func: (mock: MockAdapter) => void) => mocks.push(func);
export const __clear = (): Function[] => mocks = [];
const setupMock = (axios: AxiosInstance) => {
const mock = new MockAdapter(axios, { onNoMatch: 'throwException' });
mocks.map(func => func(mock));
};
export const getLinks = (response: AxiosResponse): LinkHeader => {
return new LinkHeader(response.headers?.link);
};
export const getNextLink = (response: AxiosResponse) => {
const nextLink = new LinkHeader(response.headers?.link);
return nextLink.refs.find(link => link.rel === 'next')?.uri;
};
export const getPrevLink = (response: AxiosResponse) => {
const prevLink = new LinkHeader(response.headers?.link);
return prevLink.refs.find(link => link.rel === 'prev')?.uri;
};
export const baseClient = (...params: any[]) => {
const axios = api.baseClient(...params);
setupMock(axios);
return axios;
};
export default (...params: any[]) => {
const axios = api.default(...params);
setupMock(axios);
return axios;
};

View File

@ -1,43 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { __stub } from 'soapbox/api/index.ts';
import { buildGroup } from 'soapbox/jest/factory.ts';
import { renderHook, waitFor } from 'soapbox/jest/test-helpers.tsx';
import { useGroup } from './useGroup.ts';
const group = buildGroup({ id: '1', display_name: 'soapbox' });
describe('useGroup hook', () => {
describe('with a successful request', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/groups/${group.id}`).reply(200, group);
});
});
it('is successful', async () => {
const { result } = renderHook(() => useGroup(group.id));
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(result.current.group?.id).toBe(group.id);
});
});
describe('with an unsuccessful query', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/groups/${group.id}`).networkError();
});
});
it('is has error state', async() => {
const { result } = renderHook(() => useGroup(group.id));
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(result.current.group).toBeUndefined();
});
});
});

View File

@ -1,50 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { __stub } from 'soapbox/api/index.ts';
import { buildGroup } from 'soapbox/jest/factory.ts';
import { renderHook, rootState, waitFor } from 'soapbox/jest/test-helpers.tsx';
import { useGroupLookup } from './useGroupLookup.ts';
const group = buildGroup({ id: '1', slug: 'soapbox' });
const state = {
...rootState,
instance: {
...rootState.instance,
version: '3.4.1 (compatible; TruthSocial 1.0.0)',
},
};
describe('useGroupLookup hook', () => {
describe('with a successful request', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/groups/lookup?name=${group.slug}`).reply(200, group);
});
});
it('is successful', async () => {
const { result } = renderHook(() => useGroupLookup(group.slug), undefined, state);
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(result.current.entity?.id).toBe(group.id);
});
});
describe('with an unsuccessful query', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/groups/lookup?name=${group.slug}`).networkError();
});
});
it('is has error state', async() => {
const { result } = renderHook(() => useGroupLookup(group.slug), undefined, state);
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(result.current.entity).toBeUndefined();
});
});
});

View File

@ -1,46 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { __stub } from 'soapbox/api/index.ts';
import { buildStatus } from 'soapbox/jest/factory.ts';
import { renderHook, waitFor } from 'soapbox/jest/test-helpers.tsx';
import { useGroupMedia } from './useGroupMedia.ts';
const status = buildStatus();
const groupId = '1';
describe('useGroupMedia hook', () => {
describe('with a successful request', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/timelines/group/${groupId}?only_media=true`).reply(200, [status]);
});
});
it('is successful', async () => {
const { result } = renderHook(() => useGroupMedia(groupId));
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(result.current.entities.length).toBe(1);
expect(result.current.entities[0].id).toBe(status.id);
});
});
describe('with an unsuccessful query', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/timelines/group/${groupId}?only_media=true`).networkError();
});
});
it('is has error state', async() => {
const { result } = renderHook(() => useGroupMedia(groupId));
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(result.current.entities.length).toBe(0);
expect(result.current.isError).toBeTruthy();
});
});
});

View File

@ -1,47 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { __stub } from 'soapbox/api/index.ts';
import { buildGroupMember } from 'soapbox/jest/factory.ts';
import { renderHook, waitFor } from 'soapbox/jest/test-helpers.tsx';
import { GroupRoles } from 'soapbox/schemas/group-member.ts';
import { useGroupMembers } from './useGroupMembers.ts';
const groupMember = buildGroupMember();
const groupId = '1';
describe('useGroupMembers hook', () => {
describe('with a successful request', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/groups/${groupId}/memberships?role=${GroupRoles.ADMIN}`).reply(200, [groupMember]);
});
});
it('is successful', async () => {
const { result } = renderHook(() => useGroupMembers(groupId, GroupRoles.ADMIN));
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(result.current.groupMembers.length).toBe(1);
expect(result.current.groupMembers[0].id).toBe(groupMember.id);
});
});
describe('with an unsuccessful query', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/groups/${groupId}/memberships?role=${GroupRoles.ADMIN}`).networkError();
});
});
it('is has error state', async() => {
const { result } = renderHook(() => useGroupMembers(groupId, GroupRoles.ADMIN));
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(result.current.groupMembers.length).toBe(0);
expect(result.current.isError).toBeTruthy();
});
});
});

View File

@ -1,49 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { __stub } from 'soapbox/api/index.ts';
import { buildGroup } from 'soapbox/jest/factory.ts';
import { renderHook, waitFor } from 'soapbox/jest/test-helpers.tsx';
import { instanceV1Schema } from 'soapbox/schemas/instance.ts';
import { useGroups } from './useGroups.ts';
const group = buildGroup({ id: '1', display_name: 'soapbox' });
const store = {
instance: instanceV1Schema.parse({
version: '3.4.1 (compatible; TruthSocial 1.0.0+unreleased)',
}),
};
describe('useGroups hook', () => {
describe('with a successful request', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v1/groups').reply(200, [group]);
});
});
it('is successful', async () => {
const { result } = renderHook(useGroups, undefined, store);
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(result.current.groups).toHaveLength(1);
});
});
describe('with an unsuccessful query', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v1/groups').networkError();
});
});
it('is has error state', async() => {
const { result } = renderHook(useGroups, undefined, store);
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(result.current.groups).toHaveLength(0);
});
});
});

View File

@ -1,66 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { __stub } from 'soapbox/api/index.ts';
import { Entities } from 'soapbox/entity-store/entities.ts';
import { buildAccount, buildGroup } from 'soapbox/jest/factory.ts';
import { renderHook, waitFor } from 'soapbox/jest/test-helpers.tsx';
import { instanceV1Schema } from 'soapbox/schemas/instance.ts';
import { usePendingGroups } from './usePendingGroups.ts';
const id = '1';
const group = buildGroup({ id, display_name: 'soapbox' });
const store = {
instance: instanceV1Schema.parse({
version: '3.4.1 (compatible; TruthSocial 1.0.0+unreleased)',
}),
me: '1',
entities: {
[Entities.ACCOUNTS]: {
store: {
[id]: buildAccount({
id,
acct: 'tiger',
display_name: 'Tiger',
avatar: 'test.jpg',
verified: true,
}),
},
lists: {},
},
},
};
describe('usePendingGroups hook', () => {
describe('with a successful request', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v1/groups').reply(200, [group]);
});
});
it('is successful', async () => {
const { result } = renderHook(usePendingGroups, undefined, store);
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(result.current.groups).toHaveLength(1);
});
});
describe('with an unsuccessful query', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v1/groups').networkError();
});
});
it('is has error state', async() => {
const { result } = renderHook(usePendingGroups, undefined, store);
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(result.current.groups).toHaveLength(0);
});
});
});

View File

@ -1,50 +1,14 @@
/**
* API: HTTP client and utilities.
* @see {@link https://github.com/axios/axios}
* @module soapbox/api
*/
import axios, { type AxiosInstance, type AxiosResponse } from 'axios';
import LinkHeader from 'http-link-header';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import * as BuildConfig from 'soapbox/build-config.ts'; import { MastodonClient } from 'soapbox/api/MastodonClient.ts';
import { selectAccount } from 'soapbox/selectors/index.ts'; import { selectAccount } from 'soapbox/selectors/index.ts';
import { RootState } from 'soapbox/store.ts'; import { RootState } from 'soapbox/store.ts';
import { getAccessToken, getAppToken, isURL, parseBaseURL } from 'soapbox/utils/auth.ts'; import { getAccessToken, getAppToken, parseBaseURL } from 'soapbox/utils/auth.ts';
import type MockAdapter from 'axios-mock-adapter';
/**
Parse Link headers, mostly for pagination.
@see {@link https://www.npmjs.com/package/http-link-header}
@param {object} response - Axios response object
@returns {object} Link object
*/
export const getLinks = (response: AxiosResponse): LinkHeader => {
return new LinkHeader(response.headers?.link);
};
export const getNextLink = (response: AxiosResponse): string | undefined => {
return getLinks(response).refs.find(link => link.rel === 'next')?.uri;
};
export const getPrevLink = (response: AxiosResponse): string | undefined => {
return getLinks(response).refs.find(link => link.rel === 'prev')?.uri;
};
const getToken = (state: RootState, authType: string) => { const getToken = (state: RootState, authType: string) => {
return authType === 'app' ? getAppToken(state) : getAccessToken(state); return authType === 'app' ? getAppToken(state) : getAccessToken(state);
}; };
const maybeParseJSON = (data: string) => {
try {
return JSON.parse(data);
} catch (Exception) {
return data;
}
};
const getAuthBaseURL = createSelector([ const getAuthBaseURL = createSelector([
(state: RootState, me: string | false | null) => me ? selectAccount(state, me)?.url : undefined, (state: RootState, me: string | false | null) => me ? selectAccount(state, me)?.url : undefined,
(state: RootState, _me: string | false | null) => state.auth.me, (state: RootState, _me: string | false | null) => state.auth.me,
@ -53,55 +17,23 @@ const getAuthBaseURL = createSelector([
return baseURL !== window.location.origin ? baseURL : ''; return baseURL !== window.location.origin ? baseURL : '';
}); });
/** /** Base client for HTTP requests. */
* Base client for HTTP requests.
* @param {string} accessToken
* @param {string} baseURL
* @returns {object} Axios instance
*/
export const baseClient = ( export const baseClient = (
accessToken?: string | null, accessToken?: string | null,
baseURL: string = '', baseURL: string = '',
nostrSign = false, ): MastodonClient => {
): AxiosInstance => { return new MastodonClient(baseURL, accessToken || undefined);
const headers: Record<string, string> = {};
if (accessToken) {
headers.Authorization = `Bearer ${accessToken}`;
}
if (nostrSign) {
headers['X-Nostr-Sign'] = 'true';
}
return axios.create({
// When BACKEND_URL is set, always use it.
baseURL: isURL(BuildConfig.BACKEND_URL) ? BuildConfig.BACKEND_URL : baseURL,
headers,
transformResponse: [maybeParseJSON],
});
}; };
/** /**
* Stateful API client. * Stateful API client.
* Uses credentials from the Redux store if available. * Uses credentials from the Redux store if available.
* @param {function} getState - Must return the Redux state
* @param {string} authType - Either 'user' or 'app'
* @returns {object} Axios instance
*/ */
export default (getState: () => RootState, authType: string = 'user'): AxiosInstance => { export default (getState: () => RootState, authType: string = 'user'): MastodonClient => {
const state = getState(); const state = getState();
const accessToken = getToken(state, authType); const accessToken = getToken(state, authType);
const me = state.me; const me = state.me;
const baseURL = me ? getAuthBaseURL(state, me) : ''; const baseURL = me ? getAuthBaseURL(state, me) : '';
const relayUrl = state.instance?.nostr?.relay; return baseClient(accessToken, baseURL);
const pubkey = state.instance?.nostr?.pubkey;
const nostrSign = Boolean(relayUrl && pubkey);
return baseClient(accessToken, baseURL, nostrSign);
}; };
// The Jest mock exports these, so they're needed for TypeScript.
export const __stub = (_func: (mock: MockAdapter) => void) => 0;
export const __clear = (): Function[] => [];

View File

@ -32,7 +32,7 @@ interface EntityCallbacks<Value, Error = unknown> {
/** /**
* Passed into hooks to make requests. * Passed into hooks to make requests.
* Must return an Axios response. * Must return a Response object.
*/ */
type EntityFn<T> = (value: T) => Promise<Response> type EntityFn<T> = (value: T) => Promise<Response>

View File

@ -19,7 +19,6 @@ import userCheckIcon from '@tabler/icons/outline/user-check.svg';
import userXIcon from '@tabler/icons/outline/user-x.svg'; import userXIcon from '@tabler/icons/outline/user-x.svg';
import userIcon from '@tabler/icons/outline/user.svg'; import userIcon from '@tabler/icons/outline/user.svg';
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList } from 'immutable';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
@ -33,6 +32,7 @@ import { initMuteModal } from 'soapbox/actions/mutes.ts';
import { initReport, ReportableEntities } from 'soapbox/actions/reports.ts'; import { initReport, ReportableEntities } from 'soapbox/actions/reports.ts';
import { setSearchAccount } from 'soapbox/actions/search.ts'; import { setSearchAccount } from 'soapbox/actions/search.ts';
import { getSettings } from 'soapbox/actions/settings.ts'; import { getSettings } from 'soapbox/actions/settings.ts';
import { HTTPError } from 'soapbox/api/HTTPError.ts';
import { useFollow } from 'soapbox/api/hooks/index.ts'; import { useFollow } from 'soapbox/api/hooks/index.ts';
import Badge from 'soapbox/components/badge.tsx'; import Badge from 'soapbox/components/badge.tsx';
import DropdownMenu, { Menu } from 'soapbox/components/dropdown-menu/index.ts'; import DropdownMenu, { Menu } from 'soapbox/components/dropdown-menu/index.ts';
@ -121,9 +121,10 @@ const Header: React.FC<IHeader> = ({ account }) => {
const createAndNavigateToChat = useMutation({ const createAndNavigateToChat = useMutation({
mutationFn: (accountId: string) => getOrCreateChatByAccountId(accountId), mutationFn: (accountId: string) => getOrCreateChatByAccountId(accountId),
onError: (error: AxiosError) => { onError: (error) => {
const data = error.response?.data as any; if (error instanceof HTTPError) {
toast.error(data?.error); toast.showAlertForError(error);
}
}, },
onSuccess: async (response) => { onSuccess: async (response) => {
const data = await response.json(); const data = await response.json();

View File

@ -1,4 +1,3 @@
import { AxiosError } from 'axios';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
@ -150,7 +149,8 @@ const ManageDittoServer: React.FC = () => {
try { try {
const response = await dispatch(uploadMedia(data)); const response = await dispatch(uploadMedia(data));
const attachment = normalizeAttachment(response.data); const json = await response.json();
const attachment = normalizeAttachment(json);
if (attachment.type !== 'image') { if (attachment.type !== 'image') {
throw new Error('Only images supported.'); throw new Error('Only images supported.');
@ -166,11 +166,9 @@ const ManageDittoServer: React.FC = () => {
setThumbnailLoading(false); setThumbnailLoading(false);
e.target.value = ''; e.target.value = '';
if (err instanceof AxiosError) { if (err instanceof HTTPError) {
toast.error(err.response?.data?.error || 'An error occurred'); toast.showAlertForError(err);
return;
} }
toast.error((err as Error)?.message || 'An error occurred');
} }
}; };
}; };
@ -268,7 +266,8 @@ const ScreenshotInput: StreamfieldComponent<Screenshot> = ({ value, onChange })
try { try {
const response = await dispatch(uploadMedia(data)); const response = await dispatch(uploadMedia(data));
const attachment = normalizeAttachment(response.data); const json = await response.json();
const attachment = normalizeAttachment(json);
if (attachment.type !== 'image') { if (attachment.type !== 'image') {
throw new Error('Only images supported.'); throw new Error('Only images supported.');
@ -289,11 +288,9 @@ const ScreenshotInput: StreamfieldComponent<Screenshot> = ({ value, onChange })
setLoading(false); setLoading(false);
e.target.value = ''; e.target.value = '';
if (err instanceof AxiosError) { if (err instanceof HTTPError) {
toast.error(err.response?.data?.error || 'An error occurred'); toast.showAlertForError(err);
return;
} }
toast.error((err as Error)?.message || 'An error occurred');
} }
}; };
}; };

View File

@ -27,21 +27,21 @@ const Dashboard: React.FC = () => {
const { account } = useOwnAccount(); const { account } = useOwnAccount();
const handleSubscribersClick: React.MouseEventHandler = e => { const handleSubscribersClick: React.MouseEventHandler = e => {
dispatch(getSubscribersCsv()).then(({ data }) => { dispatch(getSubscribersCsv()).then((response) => response.json()).then((data) => {
download(data, 'subscribers.csv'); download(data, 'subscribers.csv');
}).catch(() => {}); }).catch(() => {});
e.preventDefault(); e.preventDefault();
}; };
const handleUnsubscribersClick: React.MouseEventHandler = e => { const handleUnsubscribersClick: React.MouseEventHandler = e => {
dispatch(getUnsubscribersCsv()).then(({ data }) => { dispatch(getUnsubscribersCsv()).then((response) => response.json()).then((data) => {
download(data, 'unsubscribers.csv'); download(data, 'unsubscribers.csv');
}).catch(() => {}); }).catch(() => {});
e.preventDefault(); e.preventDefault();
}; };
const handleCombinedClick: React.MouseEventHandler = e => { const handleCombinedClick: React.MouseEventHandler = e => {
dispatch(getCombinedCsv()).then(({ data }) => { dispatch(getCombinedCsv()).then((response) => response.json()).then((data) => {
download(data, 'combined.csv'); download(data, 'combined.csv');
}).catch(() => {}); }).catch(() => {});
e.preventDefault(); e.preventDefault();

View File

@ -8,8 +8,6 @@ import Stack from 'soapbox/components/ui/stack.tsx';
import Text from 'soapbox/components/ui/text.tsx'; import Text from 'soapbox/components/ui/text.tsx';
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts'; import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
import type { AxiosResponse } from 'axios';
const noOp = () => {}; const noOp = () => {};
const messages = defineMessages({ const messages = defineMessages({
@ -44,8 +42,8 @@ const CaptchaField: React.FC<ICaptchaField> = ({
const [refresh, setRefresh] = useState<NodeJS.Timeout | undefined>(undefined); const [refresh, setRefresh] = useState<NodeJS.Timeout | undefined>(undefined);
const getCaptcha = () => { const getCaptcha = () => {
dispatch(fetchCaptcha()).then((response: AxiosResponse) => { dispatch(fetchCaptcha()).then((response) => response.json()).then((data) => {
const captcha = ImmutableMap<string, any>(response.data); const captcha = ImmutableMap<string, any>(data);
setCaptcha(captcha); setCaptcha(captcha);
onFetch(captcha); onFetch(captcha);
}).catch((error: Error) => { }).catch((error: Error) => {

View File

@ -2,7 +2,7 @@ import { useState } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { Redirect } from 'react-router-dom'; import { Redirect } from 'react-router-dom';
import { logIn, verifyCredentials, switchAccount } from 'soapbox/actions/auth.ts'; import { logIn, verifyCredentials, switchAccount, MfaRequiredError } from 'soapbox/actions/auth.ts';
import { fetchInstance } from 'soapbox/actions/instance.ts'; import { fetchInstance } from 'soapbox/actions/instance.ts';
import { closeModal, openModal } from 'soapbox/actions/modals.ts'; import { closeModal, openModal } from 'soapbox/actions/modals.ts';
import { BigCard } from 'soapbox/components/big-card.tsx'; import { BigCard } from 'soapbox/components/big-card.tsx';
@ -16,8 +16,6 @@ import ConsumersList from './consumers-list.tsx';
import LoginForm from './login-form.tsx'; import LoginForm from './login-form.tsx';
import OtpAuthForm from './otp-auth-form.tsx'; import OtpAuthForm from './otp-auth-form.tsx';
import type { AxiosError } from 'axios';
const LoginPage = () => { const LoginPage = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -53,11 +51,10 @@ const LoginPage = () => {
} else { } else {
setShouldRedirect(true); setShouldRedirect(true);
} }
}).catch((error: AxiosError) => { }).catch((error) => {
const data: any = error.response?.data; if (error instanceof MfaRequiredError) {
if (data?.error === 'mfa_required') {
setMfaAuthNeeded(true); setMfaAuthNeeded(true);
setMfaToken(data.mfa_token); setMfaToken(error.token);
} }
setIsLoading(false); setIsLoading(false);
}); });

View File

@ -1,66 +0,0 @@
import { Route, Switch } from 'react-router-dom';
import { describe, expect, it } from 'vitest';
import { __stub } from 'soapbox/api/index.ts';
import { fireEvent, render, screen, waitFor } from 'soapbox/jest/test-helpers.tsx';
import PasswordResetConfirm from './password-reset-confirm.tsx';
const TestableComponent = () => (
<Switch>
<Route path='/edit' exact><PasswordResetConfirm /></Route>
<Route path='/' exact><span data-testid='home'>Homepage</span></Route> {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
</Switch>
);
describe('<PasswordResetConfirm />', () => {
it('handles successful responses from the API', async() => {
__stub(mock => {
mock.onPost('/api/v1/truth/password_reset/confirm')
.reply(200, {});
});
render(
<TestableComponent />,
{},
null,
{ initialEntries: ['/edit'] },
);
fireEvent.submit(
screen.getByTestId('form'), {
preventDefault: () => {},
},
);
await waitFor(() => {
expect(screen.getByTestId('home')).toHaveTextContent('Homepage');
expect(screen.queryByTestId('form-group-error')).not.toBeInTheDocument();
});
});
it('handles failed responses from the API', async() => {
__stub(mock => {
mock.onPost('/api/v1/truth/password_reset/confirm')
.reply(403, {});
});
render(
<TestableComponent />,
{},
null,
{ initialEntries: ['/edit'] },
);
await fireEvent.submit(
screen.getByTestId('form'), {
preventDefault: () => {},
},
);
await waitFor(() => {
expect(screen.queryByTestId('home')).not.toBeInTheDocument();
expect(screen.queryByTestId('form-group-error')).toBeInTheDocument();
});
});
});

View File

@ -1,6 +1,5 @@
import atIcon from '@tabler/icons/outline/at.svg'; import atIcon from '@tabler/icons/outline/at.svg';
import checkIcon from '@tabler/icons/outline/check.svg'; import checkIcon from '@tabler/icons/outline/check.svg';
import axios from 'axios';
import { debounce } from 'es-toolkit'; import { debounce } from 'es-toolkit';
import { Map as ImmutableMap } from 'immutable'; import { Map as ImmutableMap } from 'immutable';
import { useState, useRef, useCallback } from 'react'; import { useState, useRef, useCallback } from 'react';
@ -71,12 +70,12 @@ const RegistrationForm: React.FC<IRegistrationForm> = ({ inviteToken }) => {
const [passwordConfirmation, setPasswordConfirmation] = useState(''); const [passwordConfirmation, setPasswordConfirmation] = useState('');
const [passwordMismatch, setPasswordMismatch] = useState(false); const [passwordMismatch, setPasswordMismatch] = useState(false);
const source = useRef(axios.CancelToken.source()); const controllerRef = useRef(new AbortController());
const refreshCancelToken = () => { const refreshController = () => {
source.current.cancel(); controllerRef.current.abort();
source.current = axios.CancelToken.source(); controllerRef.current = new AbortController();
return source.current; return controllerRef.current;
}; };
const updateParams = (map: any) => { const updateParams = (map: any) => {
@ -90,7 +89,6 @@ const RegistrationForm: React.FC<IRegistrationForm> = ({ inviteToken }) => {
const onUsernameChange: React.ChangeEventHandler<HTMLInputElement> = e => { const onUsernameChange: React.ChangeEventHandler<HTMLInputElement> = e => {
updateParams({ username: e.target.value }); updateParams({ username: e.target.value });
setUsernameUnavailable(false); setUsernameUnavailable(false);
source.current.cancel();
const domain = params.get('domain'); const domain = params.get('domain');
usernameAvailable(e.target.value, domain ? domains!.find(({ id }) => id === domain)?.domain : undefined); usernameAvailable(e.target.value, domain ? domains!.find(({ id }) => id === domain)?.domain : undefined);
@ -99,7 +97,6 @@ const RegistrationForm: React.FC<IRegistrationForm> = ({ inviteToken }) => {
const onDomainChange: React.ChangeEventHandler<HTMLSelectElement> = e => { const onDomainChange: React.ChangeEventHandler<HTMLSelectElement> = e => {
updateParams({ domain: e.target.value || null }); updateParams({ domain: e.target.value || null });
setUsernameUnavailable(false); setUsernameUnavailable(false);
source.current.cancel();
const username = params.get('username'); const username = params.get('username');
if (username) { if (username) {
@ -188,9 +185,9 @@ const RegistrationForm: React.FC<IRegistrationForm> = ({ inviteToken }) => {
const usernameAvailable = useCallback(debounce((username, domain?: string) => { const usernameAvailable = useCallback(debounce((username, domain?: string) => {
if (!supportsAccountLookup) return; if (!supportsAccountLookup) return;
const source = refreshCancelToken(); const controller = refreshController();
dispatch(accountLookup(`${username}${domain ? `@${domain}` : ''}`, source.token)) dispatch(accountLookup(`${username}${domain ? `@${domain}` : ''}`, controller.signal))
.then(account => { .then(account => {
setUsernameUnavailable(!!account); setUsernameUnavailable(!!account);
}) })

View File

@ -1,156 +0,0 @@
import userEvent from '@testing-library/user-event';
import { VirtuosoMockContext } from 'react-virtuoso';
import { beforeEach, describe, expect, it } from 'vitest';
import { __stub } from 'soapbox/api/index.ts';
import { ChatContext } from 'soapbox/contexts/chat-context.tsx';
import { buildAccount, buildInstance } from 'soapbox/jest/factory.ts';
import { queryClient, render, rootState, screen, waitFor } from 'soapbox/jest/test-helpers.tsx';
import { normalizeChatMessage } from 'soapbox/normalizers/index.ts';
import { IChat } from 'soapbox/queries/chats.ts';
import { ChatMessage } from 'soapbox/types/entities.ts';
import ChatMessageList from './chat-message-list.tsx';
const chat: IChat = {
accepted: true,
account: buildAccount({
username: 'username',
verified: true,
id: '1',
acct: 'acct',
avatar: 'avatar',
avatar_static: 'avatar',
display_name: 'my name',
}),
chat_type: 'direct',
created_at: '2020-06-10T02:05:06.000Z',
created_by_account: '2',
discarded_at: null,
id: '14',
last_message: null,
latest_read_message_by_account: [],
latest_read_message_created_at: null,
message_expiration: 1209600,
unread: 5,
};
const chatMessages: ChatMessage[] = [
normalizeChatMessage({
account_id: '1',
chat_id: '14',
content: 'this is the first chat',
created_at: '2022-09-09T16:02:26.186Z',
emoji_reactions: null,
expiration: 1209600,
id: '1',
unread: false,
pending: false,
}),
normalizeChatMessage({
account_id: '2',
chat_id: '14',
content: 'this is the second chat',
created_at: '2022-09-09T16:04:26.186Z',
emoji_reactions: null,
expiration: 1209600,
id: '2',
unread: true,
pending: false,
}),
];
// Mock scrollIntoView function.
window.HTMLElement.prototype.scrollIntoView = function () { };
Object.assign(navigator, {
clipboard: {
writeText: () => { },
},
});
const store = {
...rootState,
me: '1',
instance: buildInstance({ version: '3.4.1 (compatible; TruthSocial 1.0.0+unreleased)' }),
};
const renderComponentWithChatContext = () => render(
<VirtuosoMockContext.Provider value={{ viewportHeight: 300, itemHeight: 100 }}>
<ChatContext.Provider value={{ chat }}>
<ChatMessageList chat={chat} />
</ChatContext.Provider>
</VirtuosoMockContext.Provider>,
undefined,
store,
);
beforeEach(() => {
queryClient.clear();
});
describe('<ChatMessageList />', () => {
describe('when the query is loading', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/pleroma/chats/${chat.id}/messages`).reply(200, chatMessages, {
link: null,
});
});
});
it('displays the skeleton loader', async () => {
renderComponentWithChatContext();
expect(screen.queryAllByTestId('placeholder-chat-message')).toHaveLength(5);
await waitFor(() => {
expect(screen.getByTestId('chat-message-list-intro')).toBeInTheDocument();
expect(screen.queryAllByTestId('placeholder-chat-message')).toHaveLength(0);
});
});
});
describe('when the query is finished loading', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/pleroma/chats/${chat.id}/messages`).reply(200, chatMessages, {
link: null,
});
});
});
it('displays the intro', async () => {
renderComponentWithChatContext();
expect(screen.queryAllByTestId('chat-message-list-intro')).toHaveLength(0);
await waitFor(() => {
expect(screen.getByTestId('chat-message-list-intro')).toBeInTheDocument();
});
});
it('displays the messages', async () => {
renderComponentWithChatContext();
expect(screen.queryAllByTestId('chat-message')).toHaveLength(0);
await waitFor(() => {
expect(screen.queryAllByTestId('chat-message')).toHaveLength(chatMessages.length);
});
});
it('displays the correct menu options depending on the owner of the message', async () => {
renderComponentWithChatContext();
await waitFor(() => {
expect(screen.queryAllByTestId('chat-message-menu')).toHaveLength(2);
});
// my message
await userEvent.click(screen.queryAllByTestId('chat-message-menu')[0].querySelector('button') as any);
// other user message
await userEvent.click(screen.queryAllByTestId('chat-message-menu')[1].querySelector('button') as any);
});
});
});

View File

@ -1,65 +0,0 @@
import { VirtuosoMockContext } from 'react-virtuoso';
import { beforeEach, describe, expect, it } from 'vitest';
import { __stub } from 'soapbox/api/index.ts';
import { ChatContext } from 'soapbox/contexts/chat-context.tsx';
import { StatProvider } from 'soapbox/contexts/stat-context.tsx';
import chats from 'soapbox/jest/fixtures/chats.json';
import { render, screen, waitFor } from 'soapbox/jest/test-helpers.tsx';
import ChatPane from './chat-pane.tsx';
const renderComponentWithChatContext = (store = {}) => render(
<VirtuosoMockContext.Provider value={{ viewportHeight: 300, itemHeight: 100 }}>
<StatProvider>
<ChatContext.Provider value={{ isOpen: true }}>
<ChatPane />
</ChatContext.Provider>
</StatProvider>
</VirtuosoMockContext.Provider>,
undefined,
store,
);
describe('<ChatPane />', () => {
// describe('when there are no chats', () => {
// let store: ReturnType<typeof mockStore>;
// beforeEach(() => {
// const state = rootState.setIn(['instance', 'version'], '2.7.2 (compatible; Pleroma 2.2.0)');
// store = mockStore(state);
// __stub((mock) => {
// mock.onGet('/api/v1/pleroma/chats').reply(200, [], {
// link: null,
// });
// });
// });
// it('renders the blankslate', async () => {
// renderComponentWithChatContext(store);
// await waitFor(() => {
// expect(screen.getByTestId('chat-pane-blankslate')).toBeInTheDocument();
// });
// });
// });
describe('when the software is not Truth Social', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v1/pleroma/chats').reply(200, chats, {
link: '<https://example.com/api/v1/pleroma/chats?since_id=2>; rel=\'prev\'',
});
});
});
it('does not render the search input', async () => {
renderComponentWithChatContext();
await waitFor(() => {
expect(screen.queryAllByTestId('chat-search-input')).toHaveLength(0);
});
});
});
});

View File

@ -1,69 +0,0 @@
import userEvent from '@testing-library/user-event';
import { VirtuosoMockContext } from 'react-virtuoso';
import { beforeEach, describe, expect, it } from 'vitest';
import { __stub } from 'soapbox/api/index.ts';
import { ChatProvider } from 'soapbox/contexts/chat-context.tsx';
import { render, screen, waitFor } from 'soapbox/jest/test-helpers.tsx';
import ChatSearch from './chat-search.tsx';
const renderComponent = () => render(
<VirtuosoMockContext.Provider value={{ viewportHeight: 300, itemHeight: 100 }}>
<ChatProvider>
<ChatSearch />
</ChatProvider>, {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
</VirtuosoMockContext.Provider>,
);
describe('<ChatSearch />', () => {
beforeEach(async() => {
renderComponent();
});
it('renders the search input', () => {
expect(screen.getByTestId('search')).toBeInTheDocument();
});
describe('when searching', () => {
describe('with results', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v1/accounts/search').reply(200, [{
id: '1',
avatar: 'url',
verified: false,
display_name: 'steve',
acct: 'sjobs',
}]);
});
});
it('renders accounts', async() => {
const user = userEvent.setup();
await user.type(screen.getByTestId('search'), 'ste');
await waitFor(() => {
expect(screen.queryAllByTestId('account')).toHaveLength(1);
});
});
});
describe('without results', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v1/accounts/search').reply(200, []);
});
});
it('renders accounts', async() => {
const user = userEvent.setup();
await user.type(screen.getByTestId('search'), 'ste');
await waitFor(() => {
expect(screen.getByTestId('no-results')).toBeInTheDocument();
});
});
});
});
});

View File

@ -1,11 +1,11 @@
import searchIcon from '@tabler/icons/outline/search.svg'; import searchIcon from '@tabler/icons/outline/search.svg';
import xIcon from '@tabler/icons/outline/x.svg'; import xIcon from '@tabler/icons/outline/x.svg';
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { useState } from 'react'; import { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { HTTPError } from 'soapbox/api/HTTPError.ts';
import Icon from 'soapbox/components/ui/icon.tsx'; import Icon from 'soapbox/components/ui/icon.tsx';
import Input from 'soapbox/components/ui/input.tsx'; import Input from 'soapbox/components/ui/input.tsx';
import Stack from 'soapbox/components/ui/stack.tsx'; import Stack from 'soapbox/components/ui/stack.tsx';
@ -49,9 +49,10 @@ const ChatSearch = (props: IChatSearch) => {
const handleClickOnSearchResult = useMutation({ const handleClickOnSearchResult = useMutation({
mutationFn: (accountId: string) => getOrCreateChatByAccountId(accountId), mutationFn: (accountId: string) => getOrCreateChatByAccountId(accountId),
onError: (error: AxiosError) => { onError: (error) => {
const data = error.response?.data as any; if (error instanceof HTTPError) {
toast.error(data?.error); toast.showAlertForError(error);
}
}, },
onSuccess: async (response) => { onSuccess: async (response) => {
const data = await response.json(); const data = await response.json();

View File

@ -1,9 +1,9 @@
import { AxiosError } from 'axios';
import clsx from 'clsx'; import clsx from 'clsx';
import { MutableRefObject, useEffect, useState } from 'react'; import { MutableRefObject, useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { uploadMedia } from 'soapbox/actions/media.ts'; import { uploadMedia } from 'soapbox/actions/media.ts';
import { HTTPError } from 'soapbox/api/HTTPError.ts';
import Stack from 'soapbox/components/ui/stack.tsx'; import Stack from 'soapbox/components/ui/stack.tsx';
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts'; import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts'; import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
@ -71,10 +71,12 @@ const Chat: React.FC<ChatInterface> = ({ chat, inputRef, className }) => {
onSuccess: () => { onSuccess: () => {
setErrorMessage(undefined); setErrorMessage(undefined);
}, },
onError: (error: AxiosError<{ error: string }>, _variables, context) => { onError: async (error: unknown, _variables, context) => {
const message = error.response?.data?.error; if (error instanceof HTTPError) {
setErrorMessage(message || intl.formatMessage(messages.failedToSend)); const data = await error.response.error();
setErrorMessage(data?.error || intl.formatMessage(messages.failedToSend));
setContent(context.prevContent as string); setContent(context.prevContent as string);
}
}, },
}); });
@ -158,7 +160,8 @@ const Chat: React.FC<ChatInterface> = ({ chat, inputRef, className }) => {
const data = new FormData(); const data = new FormData();
data.append('file', file); data.append('file', file);
const response = await dispatch(uploadMedia(data, onUploadProgress)); const response = await dispatch(uploadMedia(data, onUploadProgress));
return normalizeAttachment(response.data); const json = await response.json();
return normalizeAttachment(json);
}); });
return Promise.all(promises) return Promise.all(promises)

View File

@ -60,7 +60,7 @@ const SettingsStore: React.FC = () => {
pleroma_settings_store: { pleroma_settings_store: {
[FE_NAME]: settings, [FE_NAME]: settings,
}, },
})).then(response => { })).then(() => {
dispatch({ type: SETTINGS_UPDATE, settings }); dispatch({ type: SETTINGS_UPDATE, settings });
setLoading(false); setLoading(false);
}).catch(error => { }).catch(error => {

View File

@ -37,8 +37,8 @@ const EmailConfirmation = () => {
.catch((error) => { .catch((error) => {
setStatus(Statuses.FAIL); setStatus(Statuses.FAIL);
if (error.response.data.error) { if (error.data.error) {
const message = buildErrorMessage(error.response.data.error); const message = buildErrorMessage(error.data.error);
toast.error(message); toast.error(message);
} }
}); });

View File

@ -132,7 +132,7 @@ const EventHeader: React.FC<IEventHeader> = ({ status }) => {
}; };
const handleExportClick = () => { const handleExportClick = () => {
dispatch(fetchEventIcs(status.id)).then(({ data }) => { dispatch(fetchEventIcs(status.id)).then((response) => response.json()).then((data) => {
download(data, 'calendar.ics'); download(data, 'calendar.ics');
}).catch(() => {}); }).catch(() => {});
}; };

View File

@ -51,7 +51,7 @@ const EventDiscussion: React.FC<IEventDiscussion> = (props) => {
}); });
const [isLoaded, setIsLoaded] = useState<boolean>(!!status); const [isLoaded, setIsLoaded] = useState<boolean>(!!status);
const [next, setNext] = useState<string>(); const [next, setNext] = useState<string | null>(null);
const node = useRef<HTMLDivElement>(null); const node = useRef<HTMLDivElement>(null);
const scroller = useRef<VirtuosoHandle>(null); const scroller = useRef<VirtuosoHandle>(null);

View File

@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { useIntl, FormattedMessage, defineMessages } from 'react-intl'; import { useIntl, FormattedMessage, defineMessages } from 'react-intl';
import { externalLogin, loginWithCode } from 'soapbox/actions/external-auth.ts'; import { externalLogin, loginWithCode } from 'soapbox/actions/external-auth.ts';
import { HTTPError } from 'soapbox/api/HTTPError.ts';
import Button from 'soapbox/components/ui/button.tsx'; import Button from 'soapbox/components/ui/button.tsx';
import FormActions from 'soapbox/components/ui/form-actions.tsx'; import FormActions from 'soapbox/components/ui/form-actions.tsx';
import FormGroup from 'soapbox/components/ui/form-group.tsx'; import FormGroup from 'soapbox/components/ui/form-group.tsx';
@ -11,8 +12,6 @@ import Spinner from 'soapbox/components/ui/spinner.tsx';
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts'; import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
import toast from 'soapbox/toast.tsx'; import toast from 'soapbox/toast.tsx';
import type { AxiosError } from 'axios';
const messages = defineMessages({ const messages = defineMessages({
instanceLabel: { id: 'login.fields.instance_label', defaultMessage: 'Instance' }, instanceLabel: { id: 'login.fields.instance_label', defaultMessage: 'Instance' },
instancePlaceholder: { id: 'login.fields.instance_placeholder', defaultMessage: 'example.com' }, instancePlaceholder: { id: 'login.fields.instance_placeholder', defaultMessage: 'example.com' },
@ -41,13 +40,14 @@ const ExternalLoginForm: React.FC = () => {
dispatch(externalLogin(host)) dispatch(externalLogin(host))
.then(() => setLoading(false)) .then(() => setLoading(false))
.catch((error: AxiosError) => { .catch((error: unknown) => {
console.error(error); console.error(error);
const status = error.response?.status;
const status = error instanceof HTTPError ? error.response.status : undefined;
if (status) { if (status) {
toast.error(intl.formatMessage(messages.instanceFailed)); toast.error(intl.formatMessage(messages.instanceFailed));
} else if (!status && error.code === 'ERR_NETWORK') { } else if (error && typeof error === 'object' && 'code' in error && error.code === 'ERR_NETWORK') {
toast.error(intl.formatMessage(messages.networkFailed)); toast.error(intl.formatMessage(messages.networkFailed));
} }

View File

@ -1,320 +0,0 @@
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it } from 'vitest';
import { __stub } from 'soapbox/api/index.ts';
import { buildGroup, buildGroupMember, buildGroupRelationship } from 'soapbox/jest/factory.ts';
import { render, screen, waitFor } from 'soapbox/jest/test-helpers.tsx';
import { GroupRoles } from 'soapbox/schemas/group-member.ts';
import GroupMemberListItem from './group-member-list-item.tsx';
describe('<GroupMemberListItem />', () => {
describe('account rendering', () => {
const accountId = '4';
const groupMember = buildGroupMember({}, {
id: accountId,
display_name: 'tiger woods',
});
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account);
});
});
it('should render the users avatar', async () => {
const group = buildGroup({
relationship: buildGroupRelationship(),
});
render(<GroupMemberListItem group={group} member={groupMember} canPromoteToAdmin />);
await waitFor(() => {
expect(screen.getByTestId('group-member-list-item')).toHaveTextContent(groupMember.account.display_name);
});
});
});
describe('role badge', () => {
const accountId = '4';
const group = buildGroup();
describe('when the user is an Owner', () => {
const groupMember = buildGroupMember({ role: GroupRoles.OWNER }, {
id: accountId,
display_name: 'tiger woods',
});
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account);
});
});
it('should render the correct badge', async () => {
render(<GroupMemberListItem group={group} member={groupMember} canPromoteToAdmin />);
await waitFor(() => {
expect(screen.getByTestId('role-badge')).toHaveTextContent('owner');
});
});
});
describe('when the user is an Admin', () => {
const groupMember = buildGroupMember({ role: GroupRoles.ADMIN }, {
id: accountId,
display_name: 'tiger woods',
});
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account);
});
});
it('should render the correct badge', async () => {
render(<GroupMemberListItem group={group} member={groupMember} canPromoteToAdmin />);
await waitFor(() => {
expect(screen.getByTestId('role-badge')).toHaveTextContent('admin');
});
});
});
describe('when the user is an User', () => {
const groupMember = buildGroupMember({ role: GroupRoles.USER }, {
id: accountId,
display_name: 'tiger woods',
});
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account);
});
});
it('should render no correct badge', async () => {
render(<GroupMemberListItem group={group} member={groupMember} canPromoteToAdmin />);
await waitFor(() => {
expect(screen.queryAllByTestId('role-badge')).toHaveLength(0);
});
});
});
});
describe('as a Group owner', () => {
const group = buildGroup({
relationship: buildGroupRelationship({
role: GroupRoles.OWNER,
member: true,
}),
});
describe('when the user has role of "user"', () => {
const accountId = '4';
const groupMember = buildGroupMember({}, {
id: accountId,
display_name: 'tiger woods',
username: 'tiger',
});
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account);
});
});
describe('when "canPromoteToAdmin is true', () => {
it('should render dropdown with correct Owner actions', async () => {
const user = userEvent.setup();
render(<GroupMemberListItem group={group} member={groupMember} canPromoteToAdmin />);
await waitFor(async() => {
await user.click(screen.getByTestId('icon-button'));
});
const dropdownMenu = screen.getByTestId('dropdown-menu');
expect(dropdownMenu).toHaveTextContent('Assign admin role');
expect(dropdownMenu).toHaveTextContent('Kick @tiger from group');
expect(dropdownMenu).toHaveTextContent('Ban from group');
});
});
describe('when "canPromoteToAdmin is false', () => {
it('should prevent promoting user to Admin', async () => {
const user = userEvent.setup();
render(<GroupMemberListItem group={group} member={groupMember} canPromoteToAdmin={false} />);
await waitFor(async() => {
await user.click(screen.getByTestId('icon-button'));
await user.click(screen.getByTitle('Assign admin role'));
});
expect(screen.getByTestId('toast')).toHaveTextContent('Admin limit reached');
});
});
});
describe('when the user has role of "admin"', () => {
const accountId = '4';
const groupMember = buildGroupMember(
{
role: GroupRoles.ADMIN,
},
{
id: accountId,
display_name: 'tiger woods',
username: 'tiger',
},
);
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account);
});
});
it('should render dropdown with correct Owner actions', async () => {
const user = userEvent.setup();
render(<GroupMemberListItem group={group} member={groupMember} canPromoteToAdmin />);
await waitFor(async() => {
await user.click(screen.getByTestId('icon-button'));
});
const dropdownMenu = screen.getByTestId('dropdown-menu');
expect(dropdownMenu).toHaveTextContent('Remove admin role');
expect(dropdownMenu).toHaveTextContent('Kick @tiger from group');
expect(dropdownMenu).toHaveTextContent('Ban from group');
});
});
});
describe('as a Group admin', () => {
const group = buildGroup({
relationship: buildGroupRelationship({
role: GroupRoles.ADMIN,
member: true,
}),
});
describe('when the user has role of "user"', () => {
const accountId = '4';
const groupMember = buildGroupMember({}, {
id: accountId,
display_name: 'tiger woods',
username: 'tiger',
});
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account);
});
});
it('should render dropdown with correct Admin actions', async () => {
const user = userEvent.setup();
render(<GroupMemberListItem group={group} member={groupMember} canPromoteToAdmin />);
await waitFor(async() => {
await user.click(screen.getByTestId('icon-button'));
});
const dropdownMenu = screen.getByTestId('dropdown-menu');
expect(dropdownMenu).not.toHaveTextContent('Assign admin role');
expect(dropdownMenu).toHaveTextContent('Kick @tiger from group');
expect(dropdownMenu).toHaveTextContent('Ban from group');
});
});
describe('when the user has role of "admin"', () => {
const accountId = '4';
const groupMember = buildGroupMember(
{
role: GroupRoles.ADMIN,
},
{
id: accountId,
display_name: 'tiger woods',
username: 'tiger',
},
);
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account);
});
});
it('should not render the dropdown', async () => {
render(<GroupMemberListItem group={group} member={groupMember} canPromoteToAdmin />);
await waitFor(async() => {
expect(screen.queryAllByTestId('icon-button')).toHaveLength(0);
});
});
});
describe('when the user has role of "owner"', () => {
const accountId = '4';
const groupMember = buildGroupMember(
{
role: GroupRoles.OWNER,
},
{
id: accountId,
display_name: 'tiger woods',
username: 'tiger',
},
);
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account);
});
});
it('should not render the dropdown', async () => {
render(<GroupMemberListItem group={group} member={groupMember} canPromoteToAdmin />);
await waitFor(async() => {
expect(screen.queryAllByTestId('icon-button')).toHaveLength(0);
});
});
});
});
describe('as a Group user', () => {
const group = buildGroup({
relationship: buildGroupRelationship({
role: GroupRoles.USER,
member: true,
}),
});
const accountId = '4';
const groupMember = buildGroupMember({}, {
id: accountId,
display_name: 'tiger woods',
username: 'tiger',
});
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account);
});
});
it('should not render the dropdown', async () => {
render(<GroupMemberListItem group={group} member={groupMember} canPromoteToAdmin />);
await waitFor(async() => {
expect(screen.queryAllByTestId('icon-button')).toHaveLength(0);
});
});
});
});

View File

@ -1,7 +1,7 @@
import { AxiosError } from 'axios';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { HTTPError } from 'soapbox/api/HTTPError.ts';
import { useGroup, useGroupMembers, useGroupMembershipRequests } from 'soapbox/api/hooks/index.ts'; import { useGroup, useGroupMembers, useGroupMembershipRequests } from 'soapbox/api/hooks/index.ts';
import Account from 'soapbox/components/account.tsx'; import Account from 'soapbox/components/account.tsx';
import { AuthorizeRejectButtons } from 'soapbox/components/authorize-reject-buttons.tsx'; import { AuthorizeRejectButtons } from 'soapbox/components/authorize-reject-buttons.tsx';
@ -85,14 +85,17 @@ const GroupMembershipRequests: React.FC<IGroupMembershipRequests> = ({ params })
async function handleAuthorize(account: AccountEntity) { async function handleAuthorize(account: AccountEntity) {
return authorize(account.id) return authorize(account.id)
.then(() => Promise.resolve()) .then(() => Promise.resolve())
.catch((error: AxiosError) => { .catch(async (error: unknown) => {
refetch(); refetch();
let message = intl.formatMessage(messages.authorizeFail, { name: account.username }); const message = intl.formatMessage(messages.authorizeFail, { name: account.username });
if (error.response?.status === 409) {
message = (error.response?.data as any).error; if (error instanceof HTTPError && error.response.status === 409) {
} const data = await error.response.error();
toast.error(data?.error || message);
} else {
toast.error(message); toast.error(message);
}
return Promise.reject(); return Promise.reject();
}); });
@ -101,14 +104,17 @@ const GroupMembershipRequests: React.FC<IGroupMembershipRequests> = ({ params })
async function handleReject(account: AccountEntity) { async function handleReject(account: AccountEntity) {
return reject(account.id) return reject(account.id)
.then(() => Promise.resolve()) .then(() => Promise.resolve())
.catch((error: AxiosError) => { .catch(async (error: unknown) => {
refetch(); refetch();
let message = intl.formatMessage(messages.rejectFail, { name: account.username }); const message = intl.formatMessage(messages.rejectFail, { name: account.username });
if (error.response?.status === 409) {
message = (error.response?.data as any).error; if (error instanceof HTTPError && error.response.status === 409) {
} const data = await error.response.error();
toast.error(data?.error || message);
} else {
toast.error(message); toast.error(message);
}
return Promise.reject(); return Promise.reject();
}); });

View File

@ -1,63 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { __stub } from 'soapbox/api/index.ts';
import { buildGroup } from 'soapbox/jest/factory.ts';
import { render, screen, waitFor } from 'soapbox/jest/test-helpers.tsx';
import { instanceV1Schema } from 'soapbox/schemas/instance.ts';
import Search from './search.tsx';
const store = {
instance: instanceV1Schema.parse({
version: '3.4.1 (compatible; TruthSocial 1.0.0+unreleased)',
}),
};
const renderApp = (children: React.ReactElement) => render(children, undefined, store);
describe('<Search />', () => {
describe('with no results', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v1/groups/search').reply(200, []);
});
});
it('should render the blankslate', async () => {
renderApp(<Search searchValue={'some-search'} onSelect={vi.fn()} />);
await waitFor(() => {
expect(screen.getByTestId('no-results')).toBeInTheDocument();
});
});
});
describe('with results', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v1/groups/search').reply(200, [
buildGroup({
display_name: 'Group',
id: '1',
}),
]);
});
});
it('should render the results', async () => {
renderApp(<Search searchValue={'some-search'} onSelect={vi.fn()} />);
await waitFor(() => {
expect(screen.getByTestId('results')).toBeInTheDocument();
});
});
});
describe('before starting a search', () => {
it('should render the RecentSearches component', () => {
renderApp(<Search searchValue={''} onSelect={vi.fn()} />);
expect(screen.getByTestId('recent-searches')).toBeInTheDocument();
});
});
});

View File

@ -79,7 +79,7 @@ interface IThread {
withMedia?: boolean; withMedia?: boolean;
useWindowScroll?: boolean; useWindowScroll?: boolean;
itemClassName?: string; itemClassName?: string;
next: string | undefined; next?: string | null;
handleLoadMore: () => void; handleLoadMore: () => void;
} }

View File

@ -57,7 +57,7 @@ const StatusDetails: React.FC<IStatusDetails> = (props) => {
const status = useAppSelector((state) => getStatus(state, { id: props.params.statusId })); const status = useAppSelector((state) => getStatus(state, { id: props.params.statusId }));
const [isLoaded, setIsLoaded] = useState<boolean>(!!status); const [isLoaded, setIsLoaded] = useState<boolean>(!!status);
const [next, setNext] = useState<string>(); const [next, setNext] = useState<string | null>(null);
/** Fetch the status (and context) from the API. */ /** Fetch the status (and context) from the API. */
const fetchData = async () => { const fetchData = async () => {

View File

@ -36,7 +36,7 @@ const TestTimeline: React.FC = () => {
useEffect(() => { useEffect(() => {
dispatch(importFetchedStatuses(MOCK_STATUSES)); dispatch(importFetchedStatuses(MOCK_STATUSES));
dispatch(expandTimelineSuccess(timelineId, MOCK_STATUSES, undefined, undefined, false, false, false)); dispatch(expandTimelineSuccess(timelineId, MOCK_STATUSES, null, null, false, false, false));
}, []); }, []);
return ( return (

View File

@ -76,7 +76,7 @@ const MediaModal: React.FC<IMediaModal> = (props) => {
const actualStatus = status ? getActualStatus(status) : undefined; const actualStatus = status ? getActualStatus(status) : undefined;
const [isLoaded, setIsLoaded] = useState<boolean>(!!status); const [isLoaded, setIsLoaded] = useState<boolean>(!!status);
const [next, setNext] = useState<string>(); const [next, setNext] = useState<string | null>(null);
const [index, setIndex] = useState<number | null>(null); const [index, setIndex] = useState<number | null>(null);
const [navigationHidden, setNavigationHidden] = useState(false); const [navigationHidden, setNavigationHidden] = useState(false);
const [isFullScreen, setIsFullScreen] = useState(!status); const [isFullScreen, setIsFullScreen] = useState(!status);

View File

@ -5,6 +5,7 @@ import { useRef, useState } from 'react';
import { FormattedMessage, defineMessages } from 'react-intl'; import { FormattedMessage, defineMessages } from 'react-intl';
import { patchMe } from 'soapbox/actions/me.ts'; import { patchMe } from 'soapbox/actions/me.ts';
import { HTTPError } from 'soapbox/api/HTTPError.ts';
import Avatar from 'soapbox/components/ui/avatar.tsx'; import Avatar from 'soapbox/components/ui/avatar.tsx';
import Button from 'soapbox/components/ui/button.tsx'; import Button from 'soapbox/components/ui/button.tsx';
import IconButton from 'soapbox/components/ui/icon-button.tsx'; import IconButton from 'soapbox/components/ui/icon-button.tsx';
@ -18,8 +19,6 @@ import toast from 'soapbox/toast.tsx';
import { isDefaultAvatar } from 'soapbox/utils/accounts.ts'; import { isDefaultAvatar } from 'soapbox/utils/accounts.ts';
import resizeImage from 'soapbox/utils/resize-image.ts'; import resizeImage from 'soapbox/utils/resize-image.ts';
import type { AxiosError } from 'axios';
const closeIcon = xIcon; const closeIcon = xIcon;
const messages = defineMessages({ const messages = defineMessages({
@ -64,13 +63,13 @@ const AvatarSelectionModal: React.FC<IAvatarSelectionModal> = ({ onClose, onNext
setDisabled(false); setDisabled(false);
setSubmitting(false); setSubmitting(false);
onNext(); onNext();
}).catch((error: AxiosError) => { }).catch((error) => {
setSubmitting(false); setSubmitting(false);
setDisabled(false); setDisabled(false);
setSelectedFile(null); setSelectedFile(null);
if (error.response?.status === 422) { if (error instanceof HTTPError && error.response.status === 422) {
toast.error((error.response.data as any).error.replace('Validation failed: ', '')); toast.showAlertForError(error);
} else { } else {
toast.error(messages.error); toast.error(messages.error);
} }

View File

@ -3,6 +3,7 @@ import { useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { patchMe } from 'soapbox/actions/me.ts'; import { patchMe } from 'soapbox/actions/me.ts';
import { HTTPError } from 'soapbox/api/HTTPError.ts';
import Button from 'soapbox/components/ui/button.tsx'; import Button from 'soapbox/components/ui/button.tsx';
import FormGroup from 'soapbox/components/ui/form-group.tsx'; import FormGroup from 'soapbox/components/ui/form-group.tsx';
import IconButton from 'soapbox/components/ui/icon-button.tsx'; import IconButton from 'soapbox/components/ui/icon-button.tsx';
@ -13,8 +14,6 @@ import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
import { useOwnAccount } from 'soapbox/hooks/useOwnAccount.ts'; import { useOwnAccount } from 'soapbox/hooks/useOwnAccount.ts';
import toast from 'soapbox/toast.tsx'; import toast from 'soapbox/toast.tsx';
import type { AxiosError } from 'axios';
const messages = defineMessages({ const messages = defineMessages({
bioPlaceholder: { id: 'onboarding.bio.placeholder', defaultMessage: 'Tell the world a little about yourself…' }, bioPlaceholder: { id: 'onboarding.bio.placeholder', defaultMessage: 'Tell the world a little about yourself…' },
error: { id: 'onboarding.error', defaultMessage: 'An unexpected error occurred. Please try again or skip this step.' }, error: { id: 'onboarding.error', defaultMessage: 'An unexpected error occurred. Please try again or skip this step.' },
@ -45,11 +44,14 @@ const BioStep: React.FC<IBioStep> = ({ onClose, onNext }) => {
.then(() => { .then(() => {
setSubmitting(false); setSubmitting(false);
onNext(); onNext();
}).catch((error: AxiosError) => { }).catch(async (error) => {
setSubmitting(false); setSubmitting(false);
if (error.response?.status === 422) { if (error instanceof HTTPError && error.response.status === 422) {
setErrors([(error.response.data as any).error.replace('Validation failed: ', '')]); const data = await error.response.error();
if (data) {
setErrors([data.error]);
}
} else { } else {
toast.error(messages.error); toast.error(messages.error);
} }

View File

@ -5,6 +5,7 @@ import { useRef, useState } from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { patchMe } from 'soapbox/actions/me.ts'; import { patchMe } from 'soapbox/actions/me.ts';
import { HTTPError } from 'soapbox/api/HTTPError.ts';
import StillImage from 'soapbox/components/still-image.tsx'; import StillImage from 'soapbox/components/still-image.tsx';
import Avatar from 'soapbox/components/ui/avatar.tsx'; import Avatar from 'soapbox/components/ui/avatar.tsx';
import Button from 'soapbox/components/ui/button.tsx'; import Button from 'soapbox/components/ui/button.tsx';
@ -19,8 +20,6 @@ import toast from 'soapbox/toast.tsx';
import { isDefaultHeader } from 'soapbox/utils/accounts.ts'; import { isDefaultHeader } from 'soapbox/utils/accounts.ts';
import resizeImage from 'soapbox/utils/resize-image.ts'; import resizeImage from 'soapbox/utils/resize-image.ts';
import type { AxiosError } from 'axios';
const closeIcon = xIcon; const closeIcon = xIcon;
const messages = defineMessages({ const messages = defineMessages({
@ -68,13 +67,18 @@ const CoverPhotoSelectionModal: React.FC<ICoverPhotoSelectionModal> = ({ onClose
setDisabled(false); setDisabled(false);
setSubmitting(false); setSubmitting(false);
onNext(); onNext();
}).catch((error: AxiosError) => { }).catch(async (error) => {
setSubmitting(false); setSubmitting(false);
setDisabled(false); setDisabled(false);
setSelectedFile(null); setSelectedFile(null);
if (error.response?.status === 422) { if (error instanceof HTTPError && error.response?.status === 422) {
toast.error((error.response.data as any).error.replace('Validation failed: ', '')); const data = await error.response.error();
if (data) {
toast.error(data.error);
} else {
toast.error(messages.error);
}
} else { } else {
toast.error(messages.error); toast.error(messages.error);
} }

View File

@ -3,6 +3,7 @@ import { useMemo, useState } from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { patchMe } from 'soapbox/actions/me.ts'; import { patchMe } from 'soapbox/actions/me.ts';
import { HTTPError } from 'soapbox/api/HTTPError.ts';
import Button from 'soapbox/components/ui/button.tsx'; import Button from 'soapbox/components/ui/button.tsx';
import FormGroup from 'soapbox/components/ui/form-group.tsx'; import FormGroup from 'soapbox/components/ui/form-group.tsx';
import IconButton from 'soapbox/components/ui/icon-button.tsx'; import IconButton from 'soapbox/components/ui/icon-button.tsx';
@ -13,8 +14,6 @@ import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
import { useOwnAccount } from 'soapbox/hooks/useOwnAccount.ts'; import { useOwnAccount } from 'soapbox/hooks/useOwnAccount.ts';
import toast from 'soapbox/toast.tsx'; import toast from 'soapbox/toast.tsx';
import type { AxiosError } from 'axios';
const closeIcon = xIcon; const closeIcon = xIcon;
const messages = defineMessages({ const messages = defineMessages({
@ -56,11 +55,14 @@ const DisplayNameStep: React.FC<IDisplayNameStep> = ({ onClose, onNext }) => {
.then(() => { .then(() => {
setSubmitting(false); setSubmitting(false);
onNext(); onNext();
}).catch((error: AxiosError) => { }).catch(async (error) => {
setSubmitting(false); setSubmitting(false);
if (error.response?.status === 422) { if (error instanceof HTTPError && error.response?.status === 422) {
setErrors([(error.response.data as any).error.replace('Validation failed: ', '')]); const data = await error.response.error();
if (data) {
setErrors([data.error]);
}
} else { } else {
toast.error(messages.error); toast.error(messages.error);
} }

View File

@ -4,7 +4,7 @@ import { useRef, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Link, Redirect } from 'react-router-dom'; import { Link, Redirect } from 'react-router-dom';
import { logIn, verifyCredentials } from 'soapbox/actions/auth.ts'; import { logIn, MfaRequiredError, verifyCredentials } from 'soapbox/actions/auth.ts';
import { fetchInstance } from 'soapbox/actions/instance.ts'; import { fetchInstance } from 'soapbox/actions/instance.ts';
import { openModal } from 'soapbox/actions/modals.ts'; import { openModal } from 'soapbox/actions/modals.ts';
import { openSidebar } from 'soapbox/actions/sidebar.ts'; import { openSidebar } from 'soapbox/actions/sidebar.ts';
@ -28,8 +28,6 @@ import { useSettingsNotifications } from 'soapbox/hooks/useSettingsNotifications
import ProfileDropdown from './profile-dropdown.tsx'; import ProfileDropdown from './profile-dropdown.tsx';
import type { AxiosError } from 'axios';
const messages = defineMessages({ const messages = defineMessages({
login: { id: 'navbar.login.action', defaultMessage: 'Log in' }, login: { id: 'navbar.login.action', defaultMessage: 'Log in' },
username: { id: 'navbar.login.username.placeholder', defaultMessage: 'Email or username' }, username: { id: 'navbar.login.username.placeholder', defaultMessage: 'Email or username' },
@ -52,7 +50,7 @@ const Navbar = () => {
const [isLoading, setLoading] = useState<boolean>(false); const [isLoading, setLoading] = useState<boolean>(false);
const [username, setUsername] = useState<string>(''); const [username, setUsername] = useState<string>('');
const [password, setPassword] = useState<string>(''); const [password, setPassword] = useState<string>('');
const [mfaToken, setMfaToken] = useState<boolean>(false); const [mfaToken, setMfaToken] = useState<string>();
const onOpenSidebar = () => dispatch(openSidebar()); const onOpenSidebar = () => dispatch(openSidebar());
@ -74,17 +72,18 @@ const Navbar = () => {
.then(() => dispatch(fetchInstance())) .then(() => dispatch(fetchInstance()))
); );
}) })
.catch((error: AxiosError) => { .catch((error: unknown) => {
setLoading(false); setLoading(false);
const data: any = error.response?.data; if (error instanceof MfaRequiredError) {
if (data?.error === 'mfa_required') { setMfaToken(error.token);
setMfaToken(data.mfa_token);
} }
}); });
}; };
if (mfaToken) return <Redirect to={`/login?token=${encodeURIComponent(mfaToken)}`} />; if (mfaToken) {
return <Redirect to={`/login?token=${encodeURIComponent(mfaToken)}`} />;
}
return ( return (
<nav <nav

View File

@ -1,74 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { __stub } from 'soapbox/api/index.ts';
import { queryClient, render, screen, waitFor } from 'soapbox/jest/test-helpers.tsx';
import TrendsPanel from './trends-panel.tsx';
describe('<TrendsPanel />', () => {
beforeEach(() => {
queryClient.clear();
});
describe('with hashtags', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v1/trends')
.reply(200, [
{
name: 'hashtag 1',
url: 'https://example.com',
history: [{
day: '1652745600',
uses: '294',
accounts: '180',
}],
},
{ name: 'hashtag 2', url: 'https://example.com' },
]);
});
});
it('renders trending hashtags', async() => {
render(<TrendsPanel limit={1} />);
await waitFor(() => {
expect(screen.getByTestId('hashtag')).toHaveTextContent(/hashtag 1/i);
expect(screen.getByTestId('hashtag')).toHaveTextContent(/180 people talking/i);
expect(screen.getByTestId('sparklines')).toBeInTheDocument();
});
});
it('renders multiple trends', async() => {
render(<TrendsPanel limit={3} />);
await waitFor(() => {
expect(screen.queryAllByTestId('hashtag')).toHaveLength(2);
});
});
it('respects the limit prop', async() => {
render(<TrendsPanel limit={1} />);
await waitFor(() => {
expect(screen.queryAllByTestId('hashtag')).toHaveLength(1);
});
});
});
describe('without hashtags', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v1/trends').reply(200, []);
});
});
it('renders empty', async() => {
render(<TrendsPanel limit={1} />);
await waitFor(() => {
expect(screen.queryAllByTestId('hashtag')).toHaveLength(0);
});
});
});
});

View File

@ -5,14 +5,6 @@ import '@testing-library/jest-dom/vitest';
import 'fake-indexeddb/auto'; import 'fake-indexeddb/auto';
import { afterEach, vi } from 'vitest'; import { afterEach, vi } from 'vitest';
import { __clear as clearApiMocks } from '../api/__mocks__/index.ts';
// API mocking
vi.mock('soapbox/api');
afterEach(() => {
clearApiMocks();
});
// Query mocking // Query mocking
vi.mock('soapbox/queries/client'); vi.mock('soapbox/queries/client');

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