diff --git a/app/soapbox/actions/__tests__/compose.test.ts b/app/soapbox/actions/__tests__/compose.test.ts index 5a743544d5..0dd6fc3091 100644 --- a/app/soapbox/actions/__tests__/compose.test.ts +++ b/app/soapbox/actions/__tests__/compose.test.ts @@ -1,3 +1,5 @@ +import { fromJS } from 'immutable'; + import { mockStore } from 'soapbox/jest/test-helpers'; import { InstanceRecord } from 'soapbox/normalizers'; import rootReducer from 'soapbox/reducers'; @@ -10,14 +12,14 @@ describe('uploadCompose()', () => { beforeEach(() => { const instance = InstanceRecord({ - configuration: { + configuration: fromJS({ statuses: { max_media_attachments: 4, }, media_attachments: { image_size_limit: 10, }, - }, + }), }); const state = rootReducer(undefined, {}) @@ -62,14 +64,14 @@ describe('uploadCompose()', () => { beforeEach(() => { const instance = InstanceRecord({ - configuration: { + configuration: fromJS({ statuses: { max_media_attachments: 4, }, media_attachments: { video_size_limit: 10, }, - }, + }), }); const state = rootReducer(undefined, {}) diff --git a/app/soapbox/actions/accounts.js b/app/soapbox/actions/accounts.js deleted file mode 100644 index 63314a6b1b..0000000000 Binary files a/app/soapbox/actions/accounts.js and /dev/null differ diff --git a/app/soapbox/actions/accounts.ts b/app/soapbox/actions/accounts.ts new file mode 100644 index 0000000000..e5a92fae95 --- /dev/null +++ b/app/soapbox/actions/accounts.ts @@ -0,0 +1,1124 @@ +import { isLoggedIn } from 'soapbox/utils/auth'; +import { getFeatures } from 'soapbox/utils/features'; + +import api, { getLinks } from '../api'; + +import { + importFetchedAccount, + importFetchedAccounts, + importErrorWhileFetchingAccountByUsername, +} from './importer'; + +import type { AxiosError, CancelToken } from 'axios'; +import type { History } from 'history'; +import type { Map as ImmutableMap } from 'immutable'; +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { APIEntity, Status } from 'soapbox/types/entities'; + +const ACCOUNT_CREATE_REQUEST = 'ACCOUNT_CREATE_REQUEST'; +const ACCOUNT_CREATE_SUCCESS = 'ACCOUNT_CREATE_SUCCESS'; +const ACCOUNT_CREATE_FAIL = 'ACCOUNT_CREATE_FAIL'; + +const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST'; +const ACCOUNT_FETCH_SUCCESS = 'ACCOUNT_FETCH_SUCCESS'; +const ACCOUNT_FETCH_FAIL = 'ACCOUNT_FETCH_FAIL'; + +const ACCOUNT_FOLLOW_REQUEST = 'ACCOUNT_FOLLOW_REQUEST'; +const ACCOUNT_FOLLOW_SUCCESS = 'ACCOUNT_FOLLOW_SUCCESS'; +const ACCOUNT_FOLLOW_FAIL = 'ACCOUNT_FOLLOW_FAIL'; + +const ACCOUNT_UNFOLLOW_REQUEST = 'ACCOUNT_UNFOLLOW_REQUEST'; +const ACCOUNT_UNFOLLOW_SUCCESS = 'ACCOUNT_UNFOLLOW_SUCCESS'; +const ACCOUNT_UNFOLLOW_FAIL = 'ACCOUNT_UNFOLLOW_FAIL'; + +const ACCOUNT_BLOCK_REQUEST = 'ACCOUNT_BLOCK_REQUEST'; +const ACCOUNT_BLOCK_SUCCESS = 'ACCOUNT_BLOCK_SUCCESS'; +const ACCOUNT_BLOCK_FAIL = 'ACCOUNT_BLOCK_FAIL'; + +const ACCOUNT_UNBLOCK_REQUEST = 'ACCOUNT_UNBLOCK_REQUEST'; +const ACCOUNT_UNBLOCK_SUCCESS = 'ACCOUNT_UNBLOCK_SUCCESS'; +const ACCOUNT_UNBLOCK_FAIL = 'ACCOUNT_UNBLOCK_FAIL'; + +const ACCOUNT_MUTE_REQUEST = 'ACCOUNT_MUTE_REQUEST'; +const ACCOUNT_MUTE_SUCCESS = 'ACCOUNT_MUTE_SUCCESS'; +const ACCOUNT_MUTE_FAIL = 'ACCOUNT_MUTE_FAIL'; + +const ACCOUNT_UNMUTE_REQUEST = 'ACCOUNT_UNMUTE_REQUEST'; +const ACCOUNT_UNMUTE_SUCCESS = 'ACCOUNT_UNMUTE_SUCCESS'; +const ACCOUNT_UNMUTE_FAIL = 'ACCOUNT_UNMUTE_FAIL'; + +const ACCOUNT_SUBSCRIBE_REQUEST = 'ACCOUNT_SUBSCRIBE_REQUEST'; +const ACCOUNT_SUBSCRIBE_SUCCESS = 'ACCOUNT_SUBSCRIBE_SUCCESS'; +const ACCOUNT_SUBSCRIBE_FAIL = 'ACCOUNT_SUBSCRIBE_FAIL'; + +const ACCOUNT_UNSUBSCRIBE_REQUEST = 'ACCOUNT_UNSUBSCRIBE_REQUEST'; +const ACCOUNT_UNSUBSCRIBE_SUCCESS = 'ACCOUNT_UNSUBSCRIBE_SUCCESS'; +const ACCOUNT_UNSUBSCRIBE_FAIL = 'ACCOUNT_UNSUBSCRIBE_FAIL'; + +const ACCOUNT_PIN_REQUEST = 'ACCOUNT_PIN_REQUEST'; +const ACCOUNT_PIN_SUCCESS = 'ACCOUNT_PIN_SUCCESS'; +const ACCOUNT_PIN_FAIL = 'ACCOUNT_PIN_FAIL'; + +const ACCOUNT_UNPIN_REQUEST = 'ACCOUNT_UNPIN_REQUEST'; +const ACCOUNT_UNPIN_SUCCESS = 'ACCOUNT_UNPIN_SUCCESS'; +const ACCOUNT_UNPIN_FAIL = 'ACCOUNT_UNPIN_FAIL'; + +const ACCOUNT_REMOVE_FROM_FOLLOWERS_REQUEST = 'ACCOUNT_REMOVE_FROM_FOLLOWERS_REQUEST'; +const ACCOUNT_REMOVE_FROM_FOLLOWERS_SUCCESS = 'ACCOUNT_REMOVE_FROM_FOLLOWERS_SUCCESS'; +const ACCOUNT_REMOVE_FROM_FOLLOWERS_FAIL = 'ACCOUNT_REMOVE_FROM_FOLLOWERS_FAIL'; + +const PINNED_ACCOUNTS_FETCH_REQUEST = 'PINNED_ACCOUNTS_FETCH_REQUEST'; +const PINNED_ACCOUNTS_FETCH_SUCCESS = 'PINNED_ACCOUNTS_FETCH_SUCCESS'; +const PINNED_ACCOUNTS_FETCH_FAIL = 'PINNED_ACCOUNTS_FETCH_FAIL'; + +const ACCOUNT_SEARCH_REQUEST = 'ACCOUNT_SEARCH_REQUEST'; +const ACCOUNT_SEARCH_SUCCESS = 'ACCOUNT_SEARCH_SUCCESS'; +const ACCOUNT_SEARCH_FAIL = 'ACCOUNT_SEARCH_FAIL'; + +const ACCOUNT_LOOKUP_REQUEST = 'ACCOUNT_LOOKUP_REQUEST'; +const ACCOUNT_LOOKUP_SUCCESS = 'ACCOUNT_LOOKUP_SUCCESS'; +const ACCOUNT_LOOKUP_FAIL = 'ACCOUNT_LOOKUP_FAIL'; + +const FOLLOWERS_FETCH_REQUEST = 'FOLLOWERS_FETCH_REQUEST'; +const FOLLOWERS_FETCH_SUCCESS = 'FOLLOWERS_FETCH_SUCCESS'; +const FOLLOWERS_FETCH_FAIL = 'FOLLOWERS_FETCH_FAIL'; + +const FOLLOWERS_EXPAND_REQUEST = 'FOLLOWERS_EXPAND_REQUEST'; +const FOLLOWERS_EXPAND_SUCCESS = 'FOLLOWERS_EXPAND_SUCCESS'; +const FOLLOWERS_EXPAND_FAIL = 'FOLLOWERS_EXPAND_FAIL'; + +const FOLLOWING_FETCH_REQUEST = 'FOLLOWING_FETCH_REQUEST'; +const FOLLOWING_FETCH_SUCCESS = 'FOLLOWING_FETCH_SUCCESS'; +const FOLLOWING_FETCH_FAIL = 'FOLLOWING_FETCH_FAIL'; + +const FOLLOWING_EXPAND_REQUEST = 'FOLLOWING_EXPAND_REQUEST'; +const FOLLOWING_EXPAND_SUCCESS = 'FOLLOWING_EXPAND_SUCCESS'; +const FOLLOWING_EXPAND_FAIL = 'FOLLOWING_EXPAND_FAIL'; + +const RELATIONSHIPS_FETCH_REQUEST = 'RELATIONSHIPS_FETCH_REQUEST'; +const RELATIONSHIPS_FETCH_SUCCESS = 'RELATIONSHIPS_FETCH_SUCCESS'; +const RELATIONSHIPS_FETCH_FAIL = 'RELATIONSHIPS_FETCH_FAIL'; + +const FOLLOW_REQUESTS_FETCH_REQUEST = 'FOLLOW_REQUESTS_FETCH_REQUEST'; +const FOLLOW_REQUESTS_FETCH_SUCCESS = 'FOLLOW_REQUESTS_FETCH_SUCCESS'; +const FOLLOW_REQUESTS_FETCH_FAIL = 'FOLLOW_REQUESTS_FETCH_FAIL'; + +const FOLLOW_REQUESTS_EXPAND_REQUEST = 'FOLLOW_REQUESTS_EXPAND_REQUEST'; +const FOLLOW_REQUESTS_EXPAND_SUCCESS = 'FOLLOW_REQUESTS_EXPAND_SUCCESS'; +const FOLLOW_REQUESTS_EXPAND_FAIL = 'FOLLOW_REQUESTS_EXPAND_FAIL'; + +const FOLLOW_REQUEST_AUTHORIZE_REQUEST = 'FOLLOW_REQUEST_AUTHORIZE_REQUEST'; +const FOLLOW_REQUEST_AUTHORIZE_SUCCESS = 'FOLLOW_REQUEST_AUTHORIZE_SUCCESS'; +const FOLLOW_REQUEST_AUTHORIZE_FAIL = 'FOLLOW_REQUEST_AUTHORIZE_FAIL'; + +const FOLLOW_REQUEST_REJECT_REQUEST = 'FOLLOW_REQUEST_REJECT_REQUEST'; +const FOLLOW_REQUEST_REJECT_SUCCESS = 'FOLLOW_REQUEST_REJECT_SUCCESS'; +const FOLLOW_REQUEST_REJECT_FAIL = 'FOLLOW_REQUEST_REJECT_FAIL'; + +const NOTIFICATION_SETTINGS_REQUEST = 'NOTIFICATION_SETTINGS_REQUEST'; +const NOTIFICATION_SETTINGS_SUCCESS = 'NOTIFICATION_SETTINGS_SUCCESS'; +const NOTIFICATION_SETTINGS_FAIL = 'NOTIFICATION_SETTINGS_FAIL'; + +const BIRTHDAY_REMINDERS_FETCH_REQUEST = 'BIRTHDAY_REMINDERS_FETCH_REQUEST'; +const BIRTHDAY_REMINDERS_FETCH_SUCCESS = 'BIRTHDAY_REMINDERS_FETCH_SUCCESS'; +const BIRTHDAY_REMINDERS_FETCH_FAIL = 'BIRTHDAY_REMINDERS_FETCH_FAIL'; + +const maybeRedirectLogin = (error: AxiosError, history?: History) => { + // The client is unauthorized - redirect to login. + if (history && error?.response?.status === 401) { + history.push('/login'); + } +}; + +const createAccount = (params: Record) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: ACCOUNT_CREATE_REQUEST, params }); + return api(getState, 'app').post('/api/v1/accounts', params).then(({ data: token }) => { + return dispatch({ type: ACCOUNT_CREATE_SUCCESS, params, token }); + }).catch(error => { + dispatch({ type: ACCOUNT_CREATE_FAIL, error, params }); + throw error; + }); + }; + +const fetchAccount = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchRelationships([id])); + + const account = getState().accounts.get(id); + + if (account && !account.get('should_refetch')) { + return null; + } + + dispatch(fetchAccountRequest(id)); + + return api(getState) + .get(`/api/v1/accounts/${id}`) + .then(response => { + dispatch(importFetchedAccount(response.data)); + dispatch(fetchAccountSuccess(response.data)); + }) + .catch(error => { + dispatch(fetchAccountFail(id, error)); + }); + }; + +const fetchAccountByUsername = (username: string, history?: History) => + (dispatch: AppDispatch, getState: () => RootState) => { + const { instance, me } = getState(); + const features = getFeatures(instance); + + if (features.accountByUsername && (me || !features.accountLookup)) { + return api(getState).get(`/api/v1/accounts/${username}`).then(response => { + dispatch(fetchRelationships([response.data.id])); + dispatch(importFetchedAccount(response.data)); + dispatch(fetchAccountSuccess(response.data)); + }).catch(error => { + dispatch(fetchAccountFail(null, error)); + dispatch(importErrorWhileFetchingAccountByUsername(username)); + }); + } else if (features.accountLookup) { + return dispatch(accountLookup(username)).then(account => { + dispatch(fetchRelationships([account.id])); + dispatch(fetchAccountSuccess(account)); + }).catch(error => { + dispatch(fetchAccountFail(null, error)); + dispatch(importErrorWhileFetchingAccountByUsername(username)); + maybeRedirectLogin(error, history); + }); + } else { + return dispatch(accountSearch({ + q: username, + limit: 5, + resolve: true, + })).then(accounts => { + const found = accounts.find((a: APIEntity) => a.acct === username); + + if (found) { + dispatch(fetchRelationships([found.id])); + dispatch(fetchAccountSuccess(found)); + } else { + throw accounts; + } + }).catch(error => { + dispatch(fetchAccountFail(null, error)); + dispatch(importErrorWhileFetchingAccountByUsername(username)); + }); + } + }; + +const fetchAccountRequest = (id: string) => ({ + type: ACCOUNT_FETCH_REQUEST, + id, +}); + +const fetchAccountSuccess = (account: APIEntity) => ({ + type: ACCOUNT_FETCH_SUCCESS, + account, +}); + +const fetchAccountFail = (id: string | null, error: AxiosError) => ({ + type: ACCOUNT_FETCH_FAIL, + id, + error, + skipAlert: true, +}); + +const followAccount = (id: string, options = { reblogs: true }) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return null; + + const alreadyFollowing = getState().relationships.get(id)?.following || undefined; + const locked = getState().accounts.get(id)?.locked || false; + + dispatch(followAccountRequest(id, locked)); + + return api(getState) + .post(`/api/v1/accounts/${id}/follow`, options) + .then(response => dispatch(followAccountSuccess(response.data, alreadyFollowing))) + .catch(error => { + dispatch(followAccountFail(error, locked)); + throw error; + }); + }; + +const unfollowAccount = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return null; + + dispatch(unfollowAccountRequest(id)); + + return api(getState) + .post(`/api/v1/accounts/${id}/unfollow`) + .then(response => dispatch(unfollowAccountSuccess(response.data, getState().statuses))) + .catch(error => dispatch(unfollowAccountFail(error))); + }; + +const followAccountRequest = (id: string, locked: boolean) => ({ + type: ACCOUNT_FOLLOW_REQUEST, + id, + locked, + skipLoading: true, +}); + +const followAccountSuccess = (relationship: APIEntity, alreadyFollowing?: boolean) => ({ + type: ACCOUNT_FOLLOW_SUCCESS, + relationship, + alreadyFollowing, + skipLoading: true, +}); + +const followAccountFail = (error: AxiosError, locked: boolean) => ({ + type: ACCOUNT_FOLLOW_FAIL, + error, + locked, + skipLoading: true, +}); + +const unfollowAccountRequest = (id: string) => ({ + type: ACCOUNT_UNFOLLOW_REQUEST, + id, + skipLoading: true, +}); + +const unfollowAccountSuccess = (relationship: APIEntity, statuses: ImmutableMap) => ({ + type: ACCOUNT_UNFOLLOW_SUCCESS, + relationship, + statuses, + skipLoading: true, +}); + +const unfollowAccountFail = (error: AxiosError) => ({ + type: ACCOUNT_UNFOLLOW_FAIL, + error, + skipLoading: true, +}); + +const blockAccount = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return null; + + dispatch(blockAccountRequest(id)); + + return api(getState) + .post(`/api/v1/accounts/${id}/block`) + .then(response => { + // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers + return dispatch(blockAccountSuccess(response.data, getState().statuses)); + }).catch(error => dispatch(blockAccountFail(error))); + }; + +const unblockAccount = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return null; + + dispatch(unblockAccountRequest(id)); + + return api(getState) + .post(`/api/v1/accounts/${id}/unblock`) + .then(response => dispatch(unblockAccountSuccess(response.data))) + .catch(error => dispatch(unblockAccountFail(error))); + }; + +const blockAccountRequest = (id: string) => ({ + type: ACCOUNT_BLOCK_REQUEST, + id, +}); + +const blockAccountSuccess = (relationship: APIEntity, statuses: ImmutableMap) => ({ + type: ACCOUNT_BLOCK_SUCCESS, + relationship, + statuses, +}); + +const blockAccountFail = (error: AxiosError) => ({ + type: ACCOUNT_BLOCK_FAIL, + error, +}); + +const unblockAccountRequest = (id: string) => ({ + type: ACCOUNT_UNBLOCK_REQUEST, + id, +}); + +const unblockAccountSuccess = (relationship: APIEntity) => ({ + type: ACCOUNT_UNBLOCK_SUCCESS, + relationship, +}); + +const unblockAccountFail = (error: AxiosError) => ({ + type: ACCOUNT_UNBLOCK_FAIL, + error, +}); + +const muteAccount = (id: string, notifications?: boolean) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return null; + + dispatch(muteAccountRequest(id)); + + return api(getState) + .post(`/api/v1/accounts/${id}/mute`, { notifications }) + .then(response => { + // 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)); + }) + .catch(error => dispatch(muteAccountFail(error))); + }; + +const unmuteAccount = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return null; + + dispatch(unmuteAccountRequest(id)); + + return api(getState) + .post(`/api/v1/accounts/${id}/unmute`) + .then(response => dispatch(unmuteAccountSuccess(response.data))) + .catch(error => dispatch(unmuteAccountFail(error))); + }; + +const muteAccountRequest = (id: string) => ({ + type: ACCOUNT_MUTE_REQUEST, + id, +}); + +const muteAccountSuccess = (relationship: APIEntity, statuses: ImmutableMap) => ({ + type: ACCOUNT_MUTE_SUCCESS, + relationship, + statuses, +}); + +const muteAccountFail = (error: AxiosError) => ({ + type: ACCOUNT_MUTE_FAIL, + error, +}); + +const unmuteAccountRequest = (id: string) => ({ + type: ACCOUNT_UNMUTE_REQUEST, + id, +}); + +const unmuteAccountSuccess = (relationship: APIEntity) => ({ + type: ACCOUNT_UNMUTE_SUCCESS, + relationship, +}); + +const unmuteAccountFail = (error: AxiosError) => ({ + type: ACCOUNT_UNMUTE_FAIL, + error, +}); + + +const subscribeAccount = (id: string, notifications?: boolean) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return null; + + dispatch(subscribeAccountRequest(id)); + + return api(getState) + .post(`/api/v1/pleroma/accounts/${id}/subscribe`, { notifications }) + .then(response => dispatch(subscribeAccountSuccess(response.data))) + .catch(error => dispatch(subscribeAccountFail(error))); + }; + +const unsubscribeAccount = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return null; + + dispatch(unsubscribeAccountRequest(id)); + + return api(getState) + .post(`/api/v1/pleroma/accounts/${id}/unsubscribe`) + .then(response => dispatch(unsubscribeAccountSuccess(response.data))) + .catch(error => dispatch(unsubscribeAccountFail(error))); + }; + +const subscribeAccountRequest = (id: string) => ({ + type: ACCOUNT_SUBSCRIBE_REQUEST, + id, +}); + +const subscribeAccountSuccess = (relationship: APIEntity) => ({ + type: ACCOUNT_SUBSCRIBE_SUCCESS, + relationship, +}); + +const subscribeAccountFail = (error: AxiosError) => ({ + type: ACCOUNT_SUBSCRIBE_FAIL, + error, +}); + +const unsubscribeAccountRequest = (id: string) => ({ + type: ACCOUNT_UNSUBSCRIBE_REQUEST, + id, +}); + +const unsubscribeAccountSuccess = (relationship: APIEntity) => ({ + type: ACCOUNT_UNSUBSCRIBE_SUCCESS, + relationship, +}); + +const unsubscribeAccountFail = (error: AxiosError) => ({ + type: ACCOUNT_UNSUBSCRIBE_FAIL, + error, +}); + + +const removeFromFollowers = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(muteAccountRequest(id)); + + api(getState).post(`/api/v1/accounts/${id}/remove_from_followers`).then(response => { + dispatch(removeFromFollowersSuccess(response.data)); + }).catch(error => { + dispatch(removeFromFollowersFail(id, error)); + }); + }; + +const removeFromFollowersRequest = (id: string) => ({ + type: ACCOUNT_REMOVE_FROM_FOLLOWERS_REQUEST, + id, +}); + +const removeFromFollowersSuccess = (relationship: APIEntity) => ({ + type: ACCOUNT_REMOVE_FROM_FOLLOWERS_SUCCESS, + relationship, +}); + +const removeFromFollowersFail = (id: string, error: AxiosError) => ({ + type: ACCOUNT_REMOVE_FROM_FOLLOWERS_FAIL, + id, + error, +}); + +const fetchFollowers = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchFollowersRequest(id)); + + api(getState).get(`/api/v1/accounts/${id}/followers`).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchFollowersSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id))); + }).catch(error => { + dispatch(fetchFollowersFail(id, error)); + }); + }; + +const fetchFollowersRequest = (id: string) => ({ + type: FOLLOWERS_FETCH_REQUEST, + id, +}); + +const fetchFollowersSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ + type: FOLLOWERS_FETCH_SUCCESS, + id, + accounts, + next, +}); + +const fetchFollowersFail = (id: string, error: AxiosError) => ({ + type: FOLLOWERS_FETCH_FAIL, + id, + error, +}); + +const expandFollowers = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + const url = getState().user_lists.getIn(['followers', id, 'next']); + + if (url === null) { + return; + } + + dispatch(expandFollowersRequest(id)); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data)); + dispatch(expandFollowersSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id))); + }).catch(error => { + dispatch(expandFollowersFail(id, error)); + }); + }; + +const expandFollowersRequest = (id: string) => ({ + type: FOLLOWERS_EXPAND_REQUEST, + id, +}); + +const expandFollowersSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ + type: FOLLOWERS_EXPAND_SUCCESS, + id, + accounts, + next, +}); + +const expandFollowersFail = (id: string, error: AxiosError) => ({ + type: FOLLOWERS_EXPAND_FAIL, + id, + error, +}); + +const fetchFollowing = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchFollowingRequest(id)); + + api(getState).get(`/api/v1/accounts/${id}/following`).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchFollowingSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id))); + }).catch(error => { + dispatch(fetchFollowingFail(id, error)); + }); + }; + +const fetchFollowingRequest = (id: string) => ({ + type: FOLLOWING_FETCH_REQUEST, + id, +}); + +const fetchFollowingSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ + type: FOLLOWING_FETCH_SUCCESS, + id, + accounts, + next, +}); + +const fetchFollowingFail = (id: string, error: AxiosError) => ({ + type: FOLLOWING_FETCH_FAIL, + id, + error, +}); + +const expandFollowing = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + const url = getState().user_lists.getIn(['following', id, 'next']); + + if (url === null) { + return; + } + + dispatch(expandFollowingRequest(id)); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data)); + dispatch(expandFollowingSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id))); + }).catch(error => { + dispatch(expandFollowingFail(id, error)); + }); + }; + +const expandFollowingRequest = (id: string) => ({ + type: FOLLOWING_EXPAND_REQUEST, + id, +}); + +const expandFollowingSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ + type: FOLLOWING_EXPAND_SUCCESS, + id, + accounts, + next, +}); + +const expandFollowingFail = (id: string, error: AxiosError) => ({ + type: FOLLOWING_EXPAND_FAIL, + id, + error, +}); + +const fetchRelationships = (accountIds: string[]) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + const loadedRelationships = getState().relationships; + const newAccountIds = accountIds.filter(id => loadedRelationships.get(id, null) === null); + + if (newAccountIds.length === 0) { + return; + } + + dispatch(fetchRelationshipsRequest(newAccountIds)); + + api(getState).get(`/api/v1/accounts/relationships?${newAccountIds.map(id => `id[]=${id}`).join('&')}`).then(response => { + dispatch(fetchRelationshipsSuccess(response.data)); + }).catch(error => { + dispatch(fetchRelationshipsFail(error)); + }); + }; + +const fetchRelationshipsRequest = (ids: string[]) => ({ + type: RELATIONSHIPS_FETCH_REQUEST, + ids, + skipLoading: true, +}); + +const fetchRelationshipsSuccess = (relationships: APIEntity[]) => ({ + type: RELATIONSHIPS_FETCH_SUCCESS, + relationships, + skipLoading: true, +}); + +const fetchRelationshipsFail = (error: AxiosError) => ({ + type: RELATIONSHIPS_FETCH_FAIL, + error, + skipLoading: true, +}); + +const fetchFollowRequests = () => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(fetchFollowRequestsRequest()); + + api(getState).get('/api/v1/follow_requests').then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchFollowRequestsSuccess(response.data, next ? next.uri : null)); + }).catch(error => dispatch(fetchFollowRequestsFail(error))); + }; + +const fetchFollowRequestsRequest = () => ({ + type: FOLLOW_REQUESTS_FETCH_REQUEST, +}); + +const fetchFollowRequestsSuccess = (accounts: APIEntity[], next: string | null) => ({ + type: FOLLOW_REQUESTS_FETCH_SUCCESS, + accounts, + next, +}); + +const fetchFollowRequestsFail = (error: AxiosError) => ({ + type: FOLLOW_REQUESTS_FETCH_FAIL, + error, +}); + +const expandFollowRequests = () => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + const url = getState().user_lists.getIn(['follow_requests', 'next']); + + if (url === null) { + return; + } + + dispatch(expandFollowRequestsRequest()); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data)); + dispatch(expandFollowRequestsSuccess(response.data, next ? next.uri : null)); + }).catch(error => dispatch(expandFollowRequestsFail(error))); + }; + +const expandFollowRequestsRequest = () => ({ + type: FOLLOW_REQUESTS_EXPAND_REQUEST, +}); + +const expandFollowRequestsSuccess = (accounts: APIEntity[], next: string | null) => ({ + type: FOLLOW_REQUESTS_EXPAND_SUCCESS, + accounts, + next, +}); + +const expandFollowRequestsFail = (error: AxiosError) => ({ + type: FOLLOW_REQUESTS_EXPAND_FAIL, + error, +}); + +const authorizeFollowRequest = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(authorizeFollowRequestRequest(id)); + + api(getState) + .post(`/api/v1/follow_requests/${id}/authorize`) + .then(() => dispatch(authorizeFollowRequestSuccess(id))) + .catch(error => dispatch(authorizeFollowRequestFail(id, error))); + }; + +const authorizeFollowRequestRequest = (id: string) => ({ + type: FOLLOW_REQUEST_AUTHORIZE_REQUEST, + id, +}); + +const authorizeFollowRequestSuccess = (id: string) => ({ + type: FOLLOW_REQUEST_AUTHORIZE_SUCCESS, + id, +}); + +const authorizeFollowRequestFail = (id: string, error: AxiosError) => ({ + type: FOLLOW_REQUEST_AUTHORIZE_FAIL, + id, + error, +}); + + +const rejectFollowRequest = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(rejectFollowRequestRequest(id)); + + api(getState) + .post(`/api/v1/follow_requests/${id}/reject`) + .then(() => dispatch(rejectFollowRequestSuccess(id))) + .catch(error => dispatch(rejectFollowRequestFail(id, error))); + }; + +const rejectFollowRequestRequest = (id: string) => ({ + type: FOLLOW_REQUEST_REJECT_REQUEST, + id, +}); + +const rejectFollowRequestSuccess = (id: string) => ({ + type: FOLLOW_REQUEST_REJECT_SUCCESS, + id, +}); + +const rejectFollowRequestFail = (id: string, error: AxiosError) => ({ + type: FOLLOW_REQUEST_REJECT_FAIL, + id, + error, +}); + +const pinAccount = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(pinAccountRequest(id)); + + api(getState).post(`/api/v1/accounts/${id}/pin`).then(response => { + dispatch(pinAccountSuccess(response.data)); + }).catch(error => { + dispatch(pinAccountFail(error)); + }); + }; + +const unpinAccount = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(unpinAccountRequest(id)); + + api(getState).post(`/api/v1/accounts/${id}/unpin`).then(response => { + dispatch(unpinAccountSuccess(response.data)); + }).catch(error => { + dispatch(unpinAccountFail(error)); + }); + }; + +const updateNotificationSettings = (params: Record) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: NOTIFICATION_SETTINGS_REQUEST, params }); + return api(getState).put('/api/pleroma/notification_settings', params).then(({ data }) => { + dispatch({ type: NOTIFICATION_SETTINGS_SUCCESS, params, data }); + }).catch(error => { + dispatch({ type: NOTIFICATION_SETTINGS_FAIL, params, error }); + throw error; + }); + }; + +const pinAccountRequest = (id: string) => ({ + type: ACCOUNT_PIN_REQUEST, + id, +}); + +const pinAccountSuccess = (relationship: APIEntity) => ({ + type: ACCOUNT_PIN_SUCCESS, + relationship, +}); + +const pinAccountFail = (error: AxiosError) => ({ + type: ACCOUNT_PIN_FAIL, + error, +}); + +const unpinAccountRequest = (id: string) => ({ + type: ACCOUNT_UNPIN_REQUEST, + id, +}); + +const unpinAccountSuccess = (relationship: APIEntity) => ({ + type: ACCOUNT_UNPIN_SUCCESS, + relationship, +}); + +const unpinAccountFail = (error: AxiosError) => ({ + type: ACCOUNT_UNPIN_FAIL, + error, +}); + +const fetchPinnedAccounts = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchPinnedAccountsRequest(id)); + + api(getState).get(`/api/v1/pleroma/accounts/${id}/endorsements`).then(response => { + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchPinnedAccountsSuccess(id, response.data, null)); + }).catch(error => { + dispatch(fetchPinnedAccountsFail(id, error)); + }); + }; + +const fetchPinnedAccountsRequest = (id: string) => ({ + type: PINNED_ACCOUNTS_FETCH_REQUEST, + id, +}); + +const fetchPinnedAccountsSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ + type: PINNED_ACCOUNTS_FETCH_SUCCESS, + id, + accounts, + next, +}); + +const fetchPinnedAccountsFail = (id: string, error: AxiosError) => ({ + type: PINNED_ACCOUNTS_FETCH_FAIL, + id, + error, +}); + +const accountSearch = (params: Record, signal?: AbortSignal) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: ACCOUNT_SEARCH_REQUEST, params }); + return api(getState).get('/api/v1/accounts/search', { params, signal }).then(({ data: accounts }) => { + dispatch(importFetchedAccounts(accounts)); + dispatch({ type: ACCOUNT_SEARCH_SUCCESS, accounts }); + return accounts; + }).catch(error => { + dispatch({ type: ACCOUNT_SEARCH_FAIL, skipAlert: true }); + throw error; + }); + }; + +const accountLookup = (acct: string, cancelToken?: CancelToken) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: ACCOUNT_LOOKUP_REQUEST, acct }); + return api(getState).get('/api/v1/accounts/lookup', { params: { acct }, cancelToken }).then(({ data: account }) => { + if (account && account.id) dispatch(importFetchedAccount(account)); + dispatch({ type: ACCOUNT_LOOKUP_SUCCESS, account }); + return account; + }).catch(error => { + dispatch({ type: ACCOUNT_LOOKUP_FAIL }); + throw error; + }); + }; + +const fetchBirthdayReminders = (month: number, day: number) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + const me = getState().me; + + dispatch({ type: BIRTHDAY_REMINDERS_FETCH_REQUEST, day, month, id: me }); + + api(getState).get('/api/v1/pleroma/birthdays', { params: { day, month } }).then(response => { + dispatch(importFetchedAccounts(response.data)); + dispatch({ + type: BIRTHDAY_REMINDERS_FETCH_SUCCESS, + accounts: response.data, + day, + month, + id: me, + }); + }).catch(error => { + dispatch({ type: BIRTHDAY_REMINDERS_FETCH_FAIL, day, month, id: me }); + }); + }; + +export { + ACCOUNT_CREATE_REQUEST, + ACCOUNT_CREATE_SUCCESS, + ACCOUNT_CREATE_FAIL, + ACCOUNT_FETCH_REQUEST, + ACCOUNT_FETCH_SUCCESS, + ACCOUNT_FETCH_FAIL, + ACCOUNT_FOLLOW_REQUEST, + ACCOUNT_FOLLOW_SUCCESS, + ACCOUNT_FOLLOW_FAIL, + ACCOUNT_UNFOLLOW_REQUEST, + ACCOUNT_UNFOLLOW_SUCCESS, + ACCOUNT_UNFOLLOW_FAIL, + ACCOUNT_BLOCK_REQUEST, + ACCOUNT_BLOCK_SUCCESS, + ACCOUNT_BLOCK_FAIL, + ACCOUNT_UNBLOCK_REQUEST, + ACCOUNT_UNBLOCK_SUCCESS, + ACCOUNT_UNBLOCK_FAIL, + ACCOUNT_MUTE_REQUEST, + ACCOUNT_MUTE_SUCCESS, + ACCOUNT_MUTE_FAIL, + ACCOUNT_UNMUTE_REQUEST, + ACCOUNT_UNMUTE_SUCCESS, + ACCOUNT_UNMUTE_FAIL, + ACCOUNT_SUBSCRIBE_REQUEST, + ACCOUNT_SUBSCRIBE_SUCCESS, + ACCOUNT_SUBSCRIBE_FAIL, + ACCOUNT_UNSUBSCRIBE_REQUEST, + ACCOUNT_UNSUBSCRIBE_SUCCESS, + ACCOUNT_UNSUBSCRIBE_FAIL, + ACCOUNT_PIN_REQUEST, + ACCOUNT_PIN_SUCCESS, + ACCOUNT_PIN_FAIL, + ACCOUNT_UNPIN_REQUEST, + ACCOUNT_UNPIN_SUCCESS, + ACCOUNT_UNPIN_FAIL, + ACCOUNT_REMOVE_FROM_FOLLOWERS_REQUEST, + ACCOUNT_REMOVE_FROM_FOLLOWERS_SUCCESS, + ACCOUNT_REMOVE_FROM_FOLLOWERS_FAIL, + PINNED_ACCOUNTS_FETCH_REQUEST, + PINNED_ACCOUNTS_FETCH_SUCCESS, + PINNED_ACCOUNTS_FETCH_FAIL, + ACCOUNT_SEARCH_REQUEST, + ACCOUNT_SEARCH_SUCCESS, + ACCOUNT_SEARCH_FAIL, + ACCOUNT_LOOKUP_REQUEST, + ACCOUNT_LOOKUP_SUCCESS, + ACCOUNT_LOOKUP_FAIL, + FOLLOWERS_FETCH_REQUEST, + FOLLOWERS_FETCH_SUCCESS, + FOLLOWERS_FETCH_FAIL, + FOLLOWERS_EXPAND_REQUEST, + FOLLOWERS_EXPAND_SUCCESS, + FOLLOWERS_EXPAND_FAIL, + FOLLOWING_FETCH_REQUEST, + FOLLOWING_FETCH_SUCCESS, + FOLLOWING_FETCH_FAIL, + FOLLOWING_EXPAND_REQUEST, + FOLLOWING_EXPAND_SUCCESS, + FOLLOWING_EXPAND_FAIL, + RELATIONSHIPS_FETCH_REQUEST, + RELATIONSHIPS_FETCH_SUCCESS, + RELATIONSHIPS_FETCH_FAIL, + FOLLOW_REQUESTS_FETCH_REQUEST, + FOLLOW_REQUESTS_FETCH_SUCCESS, + FOLLOW_REQUESTS_FETCH_FAIL, + FOLLOW_REQUESTS_EXPAND_REQUEST, + FOLLOW_REQUESTS_EXPAND_SUCCESS, + FOLLOW_REQUESTS_EXPAND_FAIL, + FOLLOW_REQUEST_AUTHORIZE_REQUEST, + FOLLOW_REQUEST_AUTHORIZE_SUCCESS, + FOLLOW_REQUEST_AUTHORIZE_FAIL, + FOLLOW_REQUEST_REJECT_REQUEST, + FOLLOW_REQUEST_REJECT_SUCCESS, + FOLLOW_REQUEST_REJECT_FAIL, + NOTIFICATION_SETTINGS_REQUEST, + NOTIFICATION_SETTINGS_SUCCESS, + NOTIFICATION_SETTINGS_FAIL, + BIRTHDAY_REMINDERS_FETCH_REQUEST, + BIRTHDAY_REMINDERS_FETCH_SUCCESS, + BIRTHDAY_REMINDERS_FETCH_FAIL, + createAccount, + fetchAccount, + fetchAccountByUsername, + fetchAccountRequest, + fetchAccountSuccess, + fetchAccountFail, + followAccount, + unfollowAccount, + followAccountRequest, + followAccountSuccess, + followAccountFail, + unfollowAccountRequest, + unfollowAccountSuccess, + unfollowAccountFail, + blockAccount, + unblockAccount, + blockAccountRequest, + blockAccountSuccess, + blockAccountFail, + unblockAccountRequest, + unblockAccountSuccess, + unblockAccountFail, + muteAccount, + unmuteAccount, + muteAccountRequest, + muteAccountSuccess, + muteAccountFail, + unmuteAccountRequest, + unmuteAccountSuccess, + unmuteAccountFail, + subscribeAccount, + unsubscribeAccount, + subscribeAccountRequest, + subscribeAccountSuccess, + subscribeAccountFail, + unsubscribeAccountRequest, + unsubscribeAccountSuccess, + unsubscribeAccountFail, + removeFromFollowers, + removeFromFollowersRequest, + removeFromFollowersSuccess, + removeFromFollowersFail, + fetchFollowers, + fetchFollowersRequest, + fetchFollowersSuccess, + fetchFollowersFail, + expandFollowers, + expandFollowersRequest, + expandFollowersSuccess, + expandFollowersFail, + fetchFollowing, + fetchFollowingRequest, + fetchFollowingSuccess, + fetchFollowingFail, + expandFollowing, + expandFollowingRequest, + expandFollowingSuccess, + expandFollowingFail, + fetchRelationships, + fetchRelationshipsRequest, + fetchRelationshipsSuccess, + fetchRelationshipsFail, + fetchFollowRequests, + fetchFollowRequestsRequest, + fetchFollowRequestsSuccess, + fetchFollowRequestsFail, + expandFollowRequests, + expandFollowRequestsRequest, + expandFollowRequestsSuccess, + expandFollowRequestsFail, + authorizeFollowRequest, + authorizeFollowRequestRequest, + authorizeFollowRequestSuccess, + authorizeFollowRequestFail, + rejectFollowRequest, + rejectFollowRequestRequest, + rejectFollowRequestSuccess, + rejectFollowRequestFail, + pinAccount, + unpinAccount, + updateNotificationSettings, + pinAccountRequest, + pinAccountSuccess, + pinAccountFail, + unpinAccountRequest, + unpinAccountSuccess, + unpinAccountFail, + fetchPinnedAccounts, + fetchPinnedAccountsRequest, + fetchPinnedAccountsSuccess, + fetchPinnedAccountsFail, + accountSearch, + accountLookup, + fetchBirthdayReminders, +}; diff --git a/app/soapbox/actions/admin.js b/app/soapbox/actions/admin.ts similarity index 51% rename from app/soapbox/actions/admin.js rename to app/soapbox/actions/admin.ts index b3e382352a..660b52dcef 100644 Binary files a/app/soapbox/actions/admin.js and b/app/soapbox/actions/admin.ts differ diff --git a/app/soapbox/actions/compose.js b/app/soapbox/actions/compose.js deleted file mode 100644 index c42176fa24..0000000000 Binary files a/app/soapbox/actions/compose.js and /dev/null differ diff --git a/app/soapbox/actions/compose.ts b/app/soapbox/actions/compose.ts new file mode 100644 index 0000000000..5ebb229a99 --- /dev/null +++ b/app/soapbox/actions/compose.ts @@ -0,0 +1,784 @@ +import axios, { AxiosError, Canceler } from 'axios'; +import { List as ImmutableList, Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable'; +import throttle from 'lodash/throttle'; +import { defineMessages, IntlShape } from 'react-intl'; + +import snackbar from 'soapbox/actions/snackbar'; +import api from 'soapbox/api'; +import { search as emojiSearch } from 'soapbox/features/emoji/emoji_mart_search_light'; +import { tagHistory } from 'soapbox/settings'; +import { isLoggedIn } from 'soapbox/utils/auth'; +import { getFeatures, parseVersion } from 'soapbox/utils/features'; +import { formatBytes } from 'soapbox/utils/media'; +import resizeImage from 'soapbox/utils/resize_image'; + +import { showAlert, showAlertForError } from './alerts'; +import { useEmoji } from './emojis'; +import { importFetchedAccounts } from './importer'; +import { uploadMedia, fetchMedia, updateMedia } from './media'; +import { openModal, closeModal } from './modals'; +import { getSettings } from './settings'; +import { createStatus } from './statuses'; + +import type { History } from 'history'; +import type { Emoji } from 'soapbox/components/autosuggest_emoji'; +import type { AutoSuggestion } from 'soapbox/components/autosuggest_input'; +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { Account, APIEntity, Status } from 'soapbox/types/entities'; + +const { CancelToken, isCancel } = axios; + +let cancelFetchComposeSuggestionsAccounts: Canceler; + +const COMPOSE_CHANGE = 'COMPOSE_CHANGE'; +const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST'; +const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS'; +const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL'; +const COMPOSE_REPLY = 'COMPOSE_REPLY'; +const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL'; +const COMPOSE_QUOTE = 'COMPOSE_QUOTE'; +const COMPOSE_QUOTE_CANCEL = 'COMPOSE_QUOTE_CANCEL'; +const COMPOSE_DIRECT = 'COMPOSE_DIRECT'; +const COMPOSE_MENTION = 'COMPOSE_MENTION'; +const COMPOSE_RESET = 'COMPOSE_RESET'; +const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST'; +const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS'; +const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL'; +const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS'; +const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO'; + +const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR'; +const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY'; +const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT'; +const COMPOSE_SUGGESTION_TAGS_UPDATE = 'COMPOSE_SUGGESTION_TAGS_UPDATE'; + +const COMPOSE_TAG_HISTORY_UPDATE = 'COMPOSE_TAG_HISTORY_UPDATE'; + +const COMPOSE_MOUNT = 'COMPOSE_MOUNT'; +const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT'; + +const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE'; +const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE'; +const COMPOSE_TYPE_CHANGE = 'COMPOSE_TYPE_CHANGE'; +const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE'; +const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE'; +const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE'; +const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE'; + +const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT'; + +const COMPOSE_UPLOAD_CHANGE_REQUEST = 'COMPOSE_UPLOAD_UPDATE_REQUEST'; +const COMPOSE_UPLOAD_CHANGE_SUCCESS = 'COMPOSE_UPLOAD_UPDATE_SUCCESS'; +const COMPOSE_UPLOAD_CHANGE_FAIL = 'COMPOSE_UPLOAD_UPDATE_FAIL'; + +const COMPOSE_POLL_ADD = 'COMPOSE_POLL_ADD'; +const COMPOSE_POLL_REMOVE = 'COMPOSE_POLL_REMOVE'; +const COMPOSE_POLL_OPTION_ADD = 'COMPOSE_POLL_OPTION_ADD'; +const COMPOSE_POLL_OPTION_CHANGE = 'COMPOSE_POLL_OPTION_CHANGE'; +const COMPOSE_POLL_OPTION_REMOVE = 'COMPOSE_POLL_OPTION_REMOVE'; +const COMPOSE_POLL_SETTINGS_CHANGE = 'COMPOSE_POLL_SETTINGS_CHANGE'; + +const COMPOSE_SCHEDULE_ADD = 'COMPOSE_SCHEDULE_ADD'; +const COMPOSE_SCHEDULE_SET = 'COMPOSE_SCHEDULE_SET'; +const COMPOSE_SCHEDULE_REMOVE = 'COMPOSE_SCHEDULE_REMOVE'; + +const COMPOSE_ADD_TO_MENTIONS = 'COMPOSE_ADD_TO_MENTIONS'; +const COMPOSE_REMOVE_FROM_MENTIONS = 'COMPOSE_REMOVE_FROM_MENTIONS'; + +const COMPOSE_SET_STATUS = 'COMPOSE_SET_STATUS'; + +const messages = defineMessages({ + exceededImageSizeLimit: { id: 'upload_error.image_size_limit', defaultMessage: 'Image exceeds the current file size limit ({limit})' }, + exceededVideoSizeLimit: { id: 'upload_error.video_size_limit', defaultMessage: 'Video exceeds the current file size limit ({limit})' }, + scheduleError: { id: 'compose.invalid_schedule', defaultMessage: 'You must schedule a post at least 5 minutes out.' }, + success: { id: 'compose.submit_success', defaultMessage: 'Your post was sent' }, + uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' }, + uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' }, + view: { id: 'snackbar.view', defaultMessage: 'View' }, +}); + +const COMPOSE_PANEL_BREAKPOINT = 600 + (285 * 1) + (10 * 1); + +const ensureComposeIsVisible = (getState: () => RootState, routerHistory: History) => { + if (!getState().compose.get('mounted') && window.innerWidth < COMPOSE_PANEL_BREAKPOINT) { + routerHistory.push('/posts/new'); + } +}; + +const setComposeToStatus = (status: Status, rawText: string, spoilerText?: string, contentType?: string | false, withRedraft?: boolean) => + (dispatch: AppDispatch, getState: () => RootState) => { + const { instance } = getState(); + const { explicitAddressing } = getFeatures(instance); + + dispatch({ + type: COMPOSE_SET_STATUS, + status, + rawText, + explicitAddressing, + spoilerText, + contentType, + v: parseVersion(instance.version), + withRedraft, + }); + }; + +const changeCompose = (text: string) => ({ + type: COMPOSE_CHANGE, + text: text, +}); + +const replyCompose = (status: Status) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const instance = state.instance; + const { explicitAddressing } = getFeatures(instance); + + dispatch({ + type: COMPOSE_REPLY, + status: status, + account: state.accounts.get(state.me), + explicitAddressing, + }); + + dispatch(openModal('COMPOSE')); + }; + +const cancelReplyCompose = () => ({ + type: COMPOSE_REPLY_CANCEL, +}); + +const quoteCompose = (status: Status) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const instance = state.instance; + const { explicitAddressing } = getFeatures(instance); + + dispatch({ + type: COMPOSE_QUOTE, + status: status, + account: state.accounts.get(state.me), + explicitAddressing, + }); + + dispatch(openModal('COMPOSE')); + }; + +const cancelQuoteCompose = () => ({ + type: COMPOSE_QUOTE_CANCEL, +}); + +const resetCompose = () => ({ + type: COMPOSE_RESET, +}); + +const mentionCompose = (account: Account) => + (dispatch: AppDispatch) => { + dispatch({ + type: COMPOSE_MENTION, + account: account, + }); + + dispatch(openModal('COMPOSE')); + }; + +const directCompose = (account: Account) => + (dispatch: AppDispatch) => { + dispatch({ + type: COMPOSE_DIRECT, + account: account, + }); + + dispatch(openModal('COMPOSE')); + }; + +const directComposeById = (accountId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const account = getState().accounts.get(accountId); + + dispatch({ + type: COMPOSE_DIRECT, + account: account, + }); + + dispatch(openModal('COMPOSE')); + }; + +const handleComposeSubmit = (dispatch: AppDispatch, getState: () => RootState, data: APIEntity, status: string) => { + if (!dispatch || !getState) return; + + dispatch(insertIntoTagHistory(data.tags || [], status)); + dispatch(submitComposeSuccess({ ...data })); + dispatch(snackbar.success(messages.success, messages.view, `/@${data.account.acct}/posts/${data.id}`)); +}; + +const needsDescriptions = (state: RootState) => { + const media = state.compose.get('media_attachments') as ImmutableList>; + const missingDescriptionModal = getSettings(state).get('missingDescriptionModal'); + + const hasMissing = media.filter(item => !item.get('description')).size > 0; + + return missingDescriptionModal && hasMissing; +}; + +const validateSchedule = (state: RootState) => { + const schedule = state.compose.get('schedule'); + if (!schedule) return true; + + const fiveMinutesFromNow = new Date(new Date().getTime() + 300000); + + return schedule.getTime() > fiveMinutesFromNow.getTime(); +}; + +const submitCompose = (routerHistory: History, force = false) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + const state = getState(); + + const status = state.compose.get('text') || ''; + const media = state.compose.get('media_attachments') as ImmutableList>; + const statusId = state.compose.get('id') || null; + let to = state.compose.get('to') || ImmutableOrderedSet(); + + if (!validateSchedule(state)) { + dispatch(snackbar.error(messages.scheduleError)); + return; + } + + if ((!status || !status.length) && media.size === 0) { + return; + } + + if (!force && needsDescriptions(state)) { + dispatch(openModal('MISSING_DESCRIPTION', { + onContinue: () => { + dispatch(closeModal('MISSING_DESCRIPTION')); + dispatch(submitCompose(routerHistory, true)); + }, + })); + return; + } + + if (to && status) { + const mentions: string[] = status.match(/(?:^|\s|\.)@([a-z0-9_]+(?:@[a-z0-9\.\-]+)?)/gi); // not a perfect regex + + if (mentions) + to = to.union(mentions.map(mention => mention.trim().slice(1))); + } + + dispatch(submitComposeRequest()); + dispatch(closeModal()); + + const idempotencyKey = state.compose.get('idempotencyKey'); + + const params = { + status, + in_reply_to_id: state.compose.get('in_reply_to') || null, + quote_id: state.compose.get('quote') || null, + media_ids: media.map(item => item.get('id')), + sensitive: state.compose.get('sensitive'), + spoiler_text: state.compose.get('spoiler_text') || '', + visibility: state.compose.get('privacy'), + content_type: state.compose.get('content_type'), + poll: state.compose.get('poll') || null, + scheduled_at: state.compose.get('schedule') || null, + to, + }; + + dispatch(createStatus(params, idempotencyKey, statusId)).then(function(data) { + if (!statusId && data.visibility === 'direct' && getState().conversations.get('mounted') <= 0 && routerHistory) { + routerHistory.push('/messages'); + } + handleComposeSubmit(dispatch, getState, data, status); + }).catch(function(error) { + dispatch(submitComposeFail(error)); + }); + }; + +const submitComposeRequest = () => ({ + type: COMPOSE_SUBMIT_REQUEST, +}); + +const submitComposeSuccess = (status: APIEntity) => ({ + type: COMPOSE_SUBMIT_SUCCESS, + status: status, +}); + +const submitComposeFail = (error: AxiosError) => ({ + type: COMPOSE_SUBMIT_FAIL, + error: error, +}); + +const uploadCompose = (files: FileList, intl: IntlShape) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + const attachmentLimit = getState().instance.configuration.getIn(['statuses', 'max_media_attachments']) as number; + const maxImageSize = getState().instance.configuration.getIn(['media_attachments', 'image_size_limit']) as number | undefined; + const maxVideoSize = getState().instance.configuration.getIn(['media_attachments', 'video_size_limit']) as number | undefined; + + const media = getState().compose.get('media_attachments'); + const progress = new Array(files.length).fill(0); + let total = Array.from(files).reduce((a, v) => a + v.size, 0); + + if (files.length + media.size > attachmentLimit) { + dispatch(showAlert(undefined, messages.uploadErrorLimit, 'error')); + return; + } + + dispatch(uploadComposeRequest()); + + Array.from(files).forEach((f, i) => { + if (media.size + i > attachmentLimit - 1) return; + + const isImage = f.type.match(/image.*/); + const isVideo = f.type.match(/video.*/); + if (isImage && maxImageSize && (f.size > maxImageSize)) { + const limit = formatBytes(maxImageSize); + const message = intl.formatMessage(messages.exceededImageSizeLimit, { limit }); + dispatch(snackbar.error(message)); + dispatch(uploadComposeFail(true)); + return; + } else if (isVideo && maxVideoSize && (f.size > maxVideoSize)) { + const limit = formatBytes(maxVideoSize); + const message = intl.formatMessage(messages.exceededVideoSizeLimit, { limit }); + dispatch(snackbar.error(message)); + dispatch(uploadComposeFail(true)); + return; + } + + // FIXME: Don't define const in loop + /* eslint-disable no-loop-func */ + resizeImage(f).then(file => { + const data = new FormData(); + data.append('file', file); + // Account for disparity in size of original image and resized data + total += file.size - f.size; + + const onUploadProgress = ({ loaded }: any) => { + progress[i] = loaded; + dispatch(uploadComposeProgress(progress.reduce((a, v) => a + v, 0), total)); + }; + + return dispatch(uploadMedia(data, onUploadProgress)) + .then(({ status, data }) => { + // 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 + if (status === 200) { + dispatch(uploadComposeSuccess(data, f)); + } else if (status === 202) { + const poll = () => { + dispatch(fetchMedia(data.id)).then(({ status, data }) => { + if (status === 200) { + dispatch(uploadComposeSuccess(data, f)); + } else if (status === 206) { + setTimeout(() => poll(), 1000); + } + }).catch(error => dispatch(uploadComposeFail(error))); + }; + + poll(); + } + }); + }).catch(error => dispatch(uploadComposeFail(error))); + /* eslint-enable no-loop-func */ + }); + }; + +const changeUploadCompose = (id: string, params: Record) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(changeUploadComposeRequest()); + + dispatch(updateMedia(id, params)).then(response => { + dispatch(changeUploadComposeSuccess(response.data)); + }).catch(error => { + dispatch(changeUploadComposeFail(id, error)); + }); + }; + +const changeUploadComposeRequest = () => ({ + type: COMPOSE_UPLOAD_CHANGE_REQUEST, + skipLoading: true, +}); + +const changeUploadComposeSuccess = (media: APIEntity) => ({ + type: COMPOSE_UPLOAD_CHANGE_SUCCESS, + media: media, + skipLoading: true, +}); + +const changeUploadComposeFail = (id: string, error: AxiosError) => ({ + type: COMPOSE_UPLOAD_CHANGE_FAIL, + id, + error: error, + skipLoading: true, +}); + +const uploadComposeRequest = () => ({ + type: COMPOSE_UPLOAD_REQUEST, + skipLoading: true, +}); + +const uploadComposeProgress = (loaded: number, total: number) => ({ + type: COMPOSE_UPLOAD_PROGRESS, + loaded: loaded, + total: total, +}); + +const uploadComposeSuccess = (media: APIEntity, file: File) => ({ + type: COMPOSE_UPLOAD_SUCCESS, + media: media, + file, + skipLoading: true, +}); + +const uploadComposeFail = (error: AxiosError | true) => ({ + type: COMPOSE_UPLOAD_FAIL, + error: error, + skipLoading: true, +}); + +const undoUploadCompose = (media_id: string) => ({ + type: COMPOSE_UPLOAD_UNDO, + media_id: media_id, +}); + +const clearComposeSuggestions = () => { + if (cancelFetchComposeSuggestionsAccounts) { + cancelFetchComposeSuggestionsAccounts(); + } + return { + type: COMPOSE_SUGGESTIONS_CLEAR, + }; +}; + +const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => { + if (cancelFetchComposeSuggestionsAccounts) { + cancelFetchComposeSuggestionsAccounts(); + } + api(getState).get('/api/v1/accounts/search', { + cancelToken: new CancelToken(cancel => { + cancelFetchComposeSuggestionsAccounts = cancel; + }), + params: { + q: token.slice(1), + resolve: false, + limit: 4, + }, + }).then(response => { + dispatch(importFetchedAccounts(response.data)); + dispatch(readyComposeSuggestionsAccounts(token, response.data)); + }).catch(error => { + if (!isCancel(error)) { + dispatch(showAlertForError(error)); + } + }); +}, 200, { leading: true, trailing: true }); + +const fetchComposeSuggestionsEmojis = (dispatch: AppDispatch, getState: () => RootState, token: string) => { + const results = emojiSearch(token.replace(':', ''), { maxResults: 5 } as any); + dispatch(readyComposeSuggestionsEmojis(token, results)); +}; + +const fetchComposeSuggestionsTags = (dispatch: AppDispatch, getState: () => RootState, token: string) => { + dispatch(updateSuggestionTags(token)); +}; + +const fetchComposeSuggestions = (token: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + switch (token[0]) { + case ':': + fetchComposeSuggestionsEmojis(dispatch, getState, token); + break; + case '#': + fetchComposeSuggestionsTags(dispatch, getState, token); + break; + default: + fetchComposeSuggestionsAccounts(dispatch, getState, token); + break; + } + }; + +const readyComposeSuggestionsEmojis = (token: string, emojis: Emoji[]) => ({ + type: COMPOSE_SUGGESTIONS_READY, + token, + emojis, +}); + +const readyComposeSuggestionsAccounts = (token: string, accounts: APIEntity[]) => ({ + type: COMPOSE_SUGGESTIONS_READY, + token, + accounts, +}); + +const selectComposeSuggestion = (position: number, token: string | null, suggestion: AutoSuggestion, path: Array) => + (dispatch: AppDispatch, getState: () => RootState) => { + let completion, startPosition; + + if (typeof suggestion === 'object' && suggestion.id) { + completion = suggestion.native || suggestion.colons; + startPosition = position - 1; + + dispatch(useEmoji(suggestion)); + } else if (typeof suggestion === 'string' && suggestion[0] === '#') { + completion = suggestion; + startPosition = position - 1; + } else { + completion = getState().accounts.get(suggestion)!.acct; + startPosition = position; + } + + dispatch({ + type: COMPOSE_SUGGESTION_SELECT, + position: startPosition, + token, + completion, + path, + }); + }; + +const updateSuggestionTags = (token: string) => ({ + type: COMPOSE_SUGGESTION_TAGS_UPDATE, + token, +}); + +const updateTagHistory = (tags: string[]) => ({ + type: COMPOSE_TAG_HISTORY_UPDATE, + tags, +}); + +const insertIntoTagHistory = (recognizedTags: APIEntity[], text: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const oldHistory = state.compose.get('tagHistory') as ImmutableList; + const me = state.me; + const names = recognizedTags + .filter(tag => text.match(new RegExp(`#${tag.name}`, 'i'))) + .map(tag => tag.name); + const intersectedOldHistory = oldHistory.filter(name => names.findIndex(newName => newName.toLowerCase() === name.toLowerCase()) === -1); + + names.push(...intersectedOldHistory.toJS()); + + const newHistory = names.slice(0, 1000); + + tagHistory.set(me as string, newHistory); + dispatch(updateTagHistory(newHistory)); + }; + +const mountCompose = () => ({ + type: COMPOSE_MOUNT, +}); + +const unmountCompose = () => ({ + type: COMPOSE_UNMOUNT, +}); + +const changeComposeSensitivity = () => ({ + type: COMPOSE_SENSITIVITY_CHANGE, +}); + +const changeComposeSpoilerness = () => ({ + type: COMPOSE_SPOILERNESS_CHANGE, +}); + +const changeComposeContentType = (value: string) => ({ + type: COMPOSE_TYPE_CHANGE, + value, +}); + +const changeComposeSpoilerText = (text: string) => ({ + type: COMPOSE_SPOILER_TEXT_CHANGE, + text, +}); + +const changeComposeVisibility = (value: string) => ({ + type: COMPOSE_VISIBILITY_CHANGE, + value, +}); + +const insertEmojiCompose = (position: number, emoji: string, needsSpace: boolean) => ({ + type: COMPOSE_EMOJI_INSERT, + position, + emoji, + needsSpace, +}); + +const changeComposing = (value: string) => ({ + type: COMPOSE_COMPOSING_CHANGE, + value, +}); + +const addPoll = () => ({ + type: COMPOSE_POLL_ADD, +}); + +const removePoll = () => ({ + type: COMPOSE_POLL_REMOVE, +}); + +const addSchedule = () => ({ + type: COMPOSE_SCHEDULE_ADD, +}); + +const setSchedule = (date: Date) => ({ + type: COMPOSE_SCHEDULE_SET, + date: date, +}); + +const removeSchedule = () => ({ + type: COMPOSE_SCHEDULE_REMOVE, +}); + +const addPollOption = (title: string) => ({ + type: COMPOSE_POLL_OPTION_ADD, + title, +}); + +const changePollOption = (index: number, title: string) => ({ + type: COMPOSE_POLL_OPTION_CHANGE, + index, + title, +}); + +const removePollOption = (index: number) => ({ + type: COMPOSE_POLL_OPTION_REMOVE, + index, +}); + +const changePollSettings = (expiresIn?: string | number, isMultiple?: boolean) => ({ + type: COMPOSE_POLL_SETTINGS_CHANGE, + expiresIn, + isMultiple, +}); + +const openComposeWithText = (text = '') => + (dispatch: AppDispatch) => { + dispatch(resetCompose()); + dispatch(openModal('COMPOSE')); + dispatch(changeCompose(text)); + }; + +const addToMentions = (accountId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const acct = state.accounts.get(accountId)!.acct; + + return dispatch({ + type: COMPOSE_ADD_TO_MENTIONS, + account: acct, + }); + }; + +const removeFromMentions = (accountId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const acct = state.accounts.get(accountId)!.acct; + + return dispatch({ + type: COMPOSE_REMOVE_FROM_MENTIONS, + account: acct, + }); + }; + +export { + COMPOSE_CHANGE, + COMPOSE_SUBMIT_REQUEST, + COMPOSE_SUBMIT_SUCCESS, + COMPOSE_SUBMIT_FAIL, + COMPOSE_REPLY, + COMPOSE_REPLY_CANCEL, + COMPOSE_QUOTE, + COMPOSE_QUOTE_CANCEL, + COMPOSE_DIRECT, + COMPOSE_MENTION, + COMPOSE_RESET, + COMPOSE_UPLOAD_REQUEST, + COMPOSE_UPLOAD_SUCCESS, + COMPOSE_UPLOAD_FAIL, + COMPOSE_UPLOAD_PROGRESS, + COMPOSE_UPLOAD_UNDO, + COMPOSE_SUGGESTIONS_CLEAR, + COMPOSE_SUGGESTIONS_READY, + COMPOSE_SUGGESTION_SELECT, + COMPOSE_SUGGESTION_TAGS_UPDATE, + COMPOSE_TAG_HISTORY_UPDATE, + COMPOSE_MOUNT, + COMPOSE_UNMOUNT, + COMPOSE_SENSITIVITY_CHANGE, + COMPOSE_SPOILERNESS_CHANGE, + COMPOSE_TYPE_CHANGE, + COMPOSE_SPOILER_TEXT_CHANGE, + COMPOSE_VISIBILITY_CHANGE, + COMPOSE_LISTABILITY_CHANGE, + COMPOSE_COMPOSING_CHANGE, + COMPOSE_EMOJI_INSERT, + COMPOSE_UPLOAD_CHANGE_REQUEST, + COMPOSE_UPLOAD_CHANGE_SUCCESS, + COMPOSE_UPLOAD_CHANGE_FAIL, + COMPOSE_POLL_ADD, + COMPOSE_POLL_REMOVE, + COMPOSE_POLL_OPTION_ADD, + COMPOSE_POLL_OPTION_CHANGE, + COMPOSE_POLL_OPTION_REMOVE, + COMPOSE_POLL_SETTINGS_CHANGE, + COMPOSE_SCHEDULE_ADD, + COMPOSE_SCHEDULE_SET, + COMPOSE_SCHEDULE_REMOVE, + COMPOSE_ADD_TO_MENTIONS, + COMPOSE_REMOVE_FROM_MENTIONS, + COMPOSE_SET_STATUS, + ensureComposeIsVisible, + setComposeToStatus, + changeCompose, + replyCompose, + cancelReplyCompose, + quoteCompose, + cancelQuoteCompose, + resetCompose, + mentionCompose, + directCompose, + directComposeById, + handleComposeSubmit, + submitCompose, + submitComposeRequest, + submitComposeSuccess, + submitComposeFail, + uploadCompose, + changeUploadCompose, + changeUploadComposeRequest, + changeUploadComposeSuccess, + changeUploadComposeFail, + uploadComposeRequest, + uploadComposeProgress, + uploadComposeSuccess, + uploadComposeFail, + undoUploadCompose, + clearComposeSuggestions, + fetchComposeSuggestions, + readyComposeSuggestionsEmojis, + readyComposeSuggestionsAccounts, + selectComposeSuggestion, + updateSuggestionTags, + updateTagHistory, + mountCompose, + unmountCompose, + changeComposeSensitivity, + changeComposeSpoilerness, + changeComposeContentType, + changeComposeSpoilerText, + changeComposeVisibility, + insertEmojiCompose, + changeComposing, + addPoll, + removePoll, + addSchedule, + setSchedule, + removeSchedule, + addPollOption, + changePollOption, + removePollOption, + changePollSettings, + openComposeWithText, + addToMentions, + removeFromMentions, +}; diff --git a/app/soapbox/actions/emojis.ts b/app/soapbox/actions/emojis.ts index 69d3c1691f..04bda6c899 100644 --- a/app/soapbox/actions/emojis.ts +++ b/app/soapbox/actions/emojis.ts @@ -1,10 +1,11 @@ import { saveSettings } from './settings'; +import type { Emoji } from 'soapbox/components/autosuggest_emoji'; import type { AppDispatch } from 'soapbox/store'; const EMOJI_USE = 'EMOJI_USE'; -const useEmoji = (emoji: string) => +const useEmoji = (emoji: Emoji) => (dispatch: AppDispatch) => { dispatch({ type: EMOJI_USE, diff --git a/app/soapbox/actions/group_editor.js b/app/soapbox/actions/group_editor.js deleted file mode 100644 index c5e85df3b9..0000000000 Binary files a/app/soapbox/actions/group_editor.js and /dev/null differ diff --git a/app/soapbox/actions/group_editor.ts b/app/soapbox/actions/group_editor.ts new file mode 100644 index 0000000000..23f3491ad6 --- /dev/null +++ b/app/soapbox/actions/group_editor.ts @@ -0,0 +1,143 @@ +import { isLoggedIn } from 'soapbox/utils/auth'; + +import api from '../api'; + +import type { AxiosError } from 'axios'; +import type { History } from 'history'; +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { APIEntity } from 'soapbox/types/entities'; + +const GROUP_CREATE_REQUEST = 'GROUP_CREATE_REQUEST'; +const GROUP_CREATE_SUCCESS = 'GROUP_CREATE_SUCCESS'; +const GROUP_CREATE_FAIL = 'GROUP_CREATE_FAIL'; + +const GROUP_UPDATE_REQUEST = 'GROUP_UPDATE_REQUEST'; +const GROUP_UPDATE_SUCCESS = 'GROUP_UPDATE_SUCCESS'; +const GROUP_UPDATE_FAIL = 'GROUP_UPDATE_FAIL'; + +const GROUP_EDITOR_VALUE_CHANGE = 'GROUP_EDITOR_VALUE_CHANGE'; +const GROUP_EDITOR_RESET = 'GROUP_EDITOR_RESET'; +const GROUP_EDITOR_SETUP = 'GROUP_EDITOR_SETUP'; + +const submit = (routerHistory: History) => + (dispatch: AppDispatch, getState: () => RootState) => { + const groupId = getState().group_editor.get('groupId') as string; + const title = getState().group_editor.get('title') as string; + const description = getState().group_editor.get('description') as string; + const coverImage = getState().group_editor.get('coverImage') as any; + + if (groupId === null) { + dispatch(create(title, description, coverImage, routerHistory)); + } else { + dispatch(update(groupId, title, description, coverImage, routerHistory)); + } + }; + +const create = (title: string, description: string, coverImage: File, routerHistory: History) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(createRequest()); + + const formData = new FormData(); + formData.append('title', title); + formData.append('description', description); + + if (coverImage !== null) { + formData.append('cover_image', coverImage); + } + + api(getState).post('/api/v1/groups', formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(({ data }) => { + dispatch(createSuccess(data)); + routerHistory.push(`/groups/${data.id}`); + }).catch(err => dispatch(createFail(err))); + }; + +const createRequest = (id?: string) => ({ + type: GROUP_CREATE_REQUEST, + id, +}); + +const createSuccess = (group: APIEntity) => ({ + type: GROUP_CREATE_SUCCESS, + group, +}); + +const createFail = (error: AxiosError) => ({ + type: GROUP_CREATE_FAIL, + error, +}); + +const update = (groupId: string, title: string, description: string, coverImage: File, routerHistory: History) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(updateRequest(groupId)); + + const formData = new FormData(); + formData.append('title', title); + formData.append('description', description); + + if (coverImage !== null) { + formData.append('cover_image', coverImage); + } + + api(getState).put(`/api/v1/groups/${groupId}`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(({ data }) => { + dispatch(updateSuccess(data)); + routerHistory.push(`/groups/${data.id}`); + }).catch(err => dispatch(updateFail(err))); + }; + +const updateRequest = (id: string) => ({ + type: GROUP_UPDATE_REQUEST, + id, +}); + +const updateSuccess = (group: APIEntity) => ({ + type: GROUP_UPDATE_SUCCESS, + group, +}); + +const updateFail = (error: AxiosError) => ({ + type: GROUP_UPDATE_FAIL, + error, +}); + +const changeValue = (field: string, value: string | File) => ({ + type: GROUP_EDITOR_VALUE_CHANGE, + field, + value, +}); + +const reset = () => ({ + type: GROUP_EDITOR_RESET, +}); + +const setUp = (group: string) => ({ + type: GROUP_EDITOR_SETUP, + group, +}); + +export { + GROUP_CREATE_REQUEST, + GROUP_CREATE_SUCCESS, + GROUP_CREATE_FAIL, + GROUP_UPDATE_REQUEST, + GROUP_UPDATE_SUCCESS, + GROUP_UPDATE_FAIL, + GROUP_EDITOR_VALUE_CHANGE, + GROUP_EDITOR_RESET, + GROUP_EDITOR_SETUP, + submit, + create, + createRequest, + createSuccess, + createFail, + update, + updateRequest, + updateSuccess, + updateFail, + changeValue, + reset, + setUp, +}; diff --git a/app/soapbox/actions/groups.js b/app/soapbox/actions/groups.js deleted file mode 100644 index 3c6255216a..0000000000 Binary files a/app/soapbox/actions/groups.js and /dev/null differ diff --git a/app/soapbox/actions/groups.ts b/app/soapbox/actions/groups.ts new file mode 100644 index 0000000000..a6443758a0 --- /dev/null +++ b/app/soapbox/actions/groups.ts @@ -0,0 +1,550 @@ +import { AxiosError } from 'axios'; + +import { isLoggedIn } from 'soapbox/utils/auth'; + +import api, { getLinks } from '../api'; + +import { fetchRelationships } from './accounts'; +import { importFetchedAccounts } from './importer'; + +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { APIEntity } from 'soapbox/types/entities'; + +const GROUP_FETCH_REQUEST = 'GROUP_FETCH_REQUEST'; +const GROUP_FETCH_SUCCESS = 'GROUP_FETCH_SUCCESS'; +const GROUP_FETCH_FAIL = 'GROUP_FETCH_FAIL'; + +const GROUP_RELATIONSHIPS_FETCH_REQUEST = 'GROUP_RELATIONSHIPS_FETCH_REQUEST'; +const GROUP_RELATIONSHIPS_FETCH_SUCCESS = 'GROUP_RELATIONSHIPS_FETCH_SUCCESS'; +const GROUP_RELATIONSHIPS_FETCH_FAIL = 'GROUP_RELATIONSHIPS_FETCH_FAIL'; + +const GROUPS_FETCH_REQUEST = 'GROUPS_FETCH_REQUEST'; +const GROUPS_FETCH_SUCCESS = 'GROUPS_FETCH_SUCCESS'; +const GROUPS_FETCH_FAIL = 'GROUPS_FETCH_FAIL'; + +const GROUP_JOIN_REQUEST = 'GROUP_JOIN_REQUEST'; +const GROUP_JOIN_SUCCESS = 'GROUP_JOIN_SUCCESS'; +const GROUP_JOIN_FAIL = 'GROUP_JOIN_FAIL'; + +const GROUP_LEAVE_REQUEST = 'GROUP_LEAVE_REQUEST'; +const GROUP_LEAVE_SUCCESS = 'GROUP_LEAVE_SUCCESS'; +const GROUP_LEAVE_FAIL = 'GROUP_LEAVE_FAIL'; + +const GROUP_MEMBERS_FETCH_REQUEST = 'GROUP_MEMBERS_FETCH_REQUEST'; +const GROUP_MEMBERS_FETCH_SUCCESS = 'GROUP_MEMBERS_FETCH_SUCCESS'; +const GROUP_MEMBERS_FETCH_FAIL = 'GROUP_MEMBERS_FETCH_FAIL'; + +const GROUP_MEMBERS_EXPAND_REQUEST = 'GROUP_MEMBERS_EXPAND_REQUEST'; +const GROUP_MEMBERS_EXPAND_SUCCESS = 'GROUP_MEMBERS_EXPAND_SUCCESS'; +const GROUP_MEMBERS_EXPAND_FAIL = 'GROUP_MEMBERS_EXPAND_FAIL'; + +const GROUP_REMOVED_ACCOUNTS_FETCH_REQUEST = 'GROUP_REMOVED_ACCOUNTS_FETCH_REQUEST'; +const GROUP_REMOVED_ACCOUNTS_FETCH_SUCCESS = 'GROUP_REMOVED_ACCOUNTS_FETCH_SUCCESS'; +const GROUP_REMOVED_ACCOUNTS_FETCH_FAIL = 'GROUP_REMOVED_ACCOUNTS_FETCH_FAIL'; + +const GROUP_REMOVED_ACCOUNTS_EXPAND_REQUEST = 'GROUP_REMOVED_ACCOUNTS_EXPAND_REQUEST'; +const GROUP_REMOVED_ACCOUNTS_EXPAND_SUCCESS = 'GROUP_REMOVED_ACCOUNTS_EXPAND_SUCCESS'; +const GROUP_REMOVED_ACCOUNTS_EXPAND_FAIL = 'GROUP_REMOVED_ACCOUNTS_EXPAND_FAIL'; + +const GROUP_REMOVED_ACCOUNTS_REMOVE_REQUEST = 'GROUP_REMOVED_ACCOUNTS_REMOVE_REQUEST'; +const GROUP_REMOVED_ACCOUNTS_REMOVE_SUCCESS = 'GROUP_REMOVED_ACCOUNTS_REMOVE_SUCCESS'; +const GROUP_REMOVED_ACCOUNTS_REMOVE_FAIL = 'GROUP_REMOVED_ACCOUNTS_REMOVE_FAIL'; + +const GROUP_REMOVED_ACCOUNTS_CREATE_REQUEST = 'GROUP_REMOVED_ACCOUNTS_CREATE_REQUEST'; +const GROUP_REMOVED_ACCOUNTS_CREATE_SUCCESS = 'GROUP_REMOVED_ACCOUNTS_CREATE_SUCCESS'; +const GROUP_REMOVED_ACCOUNTS_CREATE_FAIL = 'GROUP_REMOVED_ACCOUNTS_CREATE_FAIL'; + +const GROUP_REMOVE_STATUS_REQUEST = 'GROUP_REMOVE_STATUS_REQUEST'; +const GROUP_REMOVE_STATUS_SUCCESS = 'GROUP_REMOVE_STATUS_SUCCESS'; +const GROUP_REMOVE_STATUS_FAIL = 'GROUP_REMOVE_STATUS_FAIL'; + +const fetchGroup = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(fetchGroupRelationships([id])); + + if (getState().groups.get(id)) { + return; + } + + dispatch(fetchGroupRequest(id)); + + api(getState).get(`/api/v1/groups/${id}`) + .then(({ data }) => dispatch(fetchGroupSuccess(data))) + .catch(err => dispatch(fetchGroupFail(id, err))); +}; + +const fetchGroupRequest = (id: string) => ({ + type: GROUP_FETCH_REQUEST, + id, +}); + +const fetchGroupSuccess = (group: APIEntity) => ({ + type: GROUP_FETCH_SUCCESS, + group, +}); + +const fetchGroupFail = (id: string, error: AxiosError) => ({ + type: GROUP_FETCH_FAIL, + id, + error, +}); + +const fetchGroupRelationships = (groupIds: string[]) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + const loadedRelationships = getState().group_relationships; + const newGroupIds = groupIds.filter(id => loadedRelationships.get(id, null) === null); + + if (newGroupIds.length === 0) { + return; + } + + dispatch(fetchGroupRelationshipsRequest(newGroupIds)); + + api(getState).get(`/api/v1/groups/${newGroupIds[0]}/relationships?${newGroupIds.map(id => `id[]=${id}`).join('&')}`).then(response => { + dispatch(fetchGroupRelationshipsSuccess(response.data)); + }).catch(error => { + dispatch(fetchGroupRelationshipsFail(error)); + }); + }; + +const fetchGroupRelationshipsRequest = (ids: string[]) => ({ + type: GROUP_RELATIONSHIPS_FETCH_REQUEST, + ids, + skipLoading: true, +}); + +const fetchGroupRelationshipsSuccess = (relationships: APIEntity[]) => ({ + type: GROUP_RELATIONSHIPS_FETCH_SUCCESS, + relationships, + skipLoading: true, +}); + +const fetchGroupRelationshipsFail = (error: AxiosError) => ({ + type: GROUP_RELATIONSHIPS_FETCH_FAIL, + error, + skipLoading: true, +}); + +const fetchGroups = (tab: string) => (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(fetchGroupsRequest()); + + api(getState).get('/api/v1/groups?tab=' + tab) + .then(({ data }) => { + dispatch(fetchGroupsSuccess(data, tab)); + dispatch(fetchGroupRelationships(data.map((item: APIEntity) => item.id))); + }) + .catch(err => dispatch(fetchGroupsFail(err))); +}; + +const fetchGroupsRequest = () => ({ + type: GROUPS_FETCH_REQUEST, +}); + +const fetchGroupsSuccess = (groups: APIEntity[], tab: string) => ({ + type: GROUPS_FETCH_SUCCESS, + groups, + tab, +}); + +const fetchGroupsFail = (error: AxiosError) => ({ + type: GROUPS_FETCH_FAIL, + error, +}); + +const joinGroup = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(joinGroupRequest(id)); + + api(getState).post(`/api/v1/groups/${id}/accounts`).then(response => { + dispatch(joinGroupSuccess(response.data)); + }).catch(error => { + dispatch(joinGroupFail(id, error)); + }); + }; + +const leaveGroup = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(leaveGroupRequest(id)); + + api(getState).delete(`/api/v1/groups/${id}/accounts`).then(response => { + dispatch(leaveGroupSuccess(response.data)); + }).catch(error => { + dispatch(leaveGroupFail(id, error)); + }); + }; + +const joinGroupRequest = (id: string) => ({ + type: GROUP_JOIN_REQUEST, + id, +}); + +const joinGroupSuccess = (relationship: APIEntity) => ({ + type: GROUP_JOIN_SUCCESS, + relationship, +}); + +const joinGroupFail = (id: string, error: AxiosError) => ({ + type: GROUP_JOIN_FAIL, + id, + error, +}); + +const leaveGroupRequest = (id: string) => ({ + type: GROUP_LEAVE_REQUEST, + id, +}); + +const leaveGroupSuccess = (relationship: APIEntity) => ({ + type: GROUP_LEAVE_SUCCESS, + relationship, +}); + +const leaveGroupFail = (id: string, error: AxiosError) => ({ + type: GROUP_LEAVE_FAIL, + id, + error, +}); + +const fetchMembers = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(fetchMembersRequest(id)); + + api(getState).get(`/api/v1/groups/${id}/accounts`).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchMembersSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id))); + }).catch(error => { + dispatch(fetchMembersFail(id, error)); + }); + }; + +const fetchMembersRequest = (id: string) => ({ + type: GROUP_MEMBERS_FETCH_REQUEST, + id, +}); + +const fetchMembersSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ + type: GROUP_MEMBERS_FETCH_SUCCESS, + id, + accounts, + next, +}); + +const fetchMembersFail = (id: string, error: AxiosError) => ({ + type: GROUP_MEMBERS_FETCH_FAIL, + id, + error, +}); + +const expandMembers = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + const url = getState().user_lists.getIn(['groups', id, 'next']); + + if (url === null) { + return; + } + + dispatch(expandMembersRequest(id)); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data)); + dispatch(expandMembersSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id))); + }).catch(error => { + dispatch(expandMembersFail(id, error)); + }); + }; + +const expandMembersRequest = (id: string) => ({ + type: GROUP_MEMBERS_EXPAND_REQUEST, + id, +}); + +const expandMembersSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ + type: GROUP_MEMBERS_EXPAND_SUCCESS, + id, + accounts, + next, +}); + +const expandMembersFail = (id: string, error: AxiosError) => ({ + type: GROUP_MEMBERS_EXPAND_FAIL, + id, + error, +}); + +const fetchRemovedAccounts = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(fetchRemovedAccountsRequest(id)); + + api(getState).get(`/api/v1/groups/${id}/removed_accounts`).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchRemovedAccountsSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id))); + }).catch(error => { + dispatch(fetchRemovedAccountsFail(id, error)); + }); + }; + +const fetchRemovedAccountsRequest = (id: string) => ({ + type: GROUP_REMOVED_ACCOUNTS_FETCH_REQUEST, + id, +}); + +const fetchRemovedAccountsSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ + type: GROUP_REMOVED_ACCOUNTS_FETCH_SUCCESS, + id, + accounts, + next, +}); + +const fetchRemovedAccountsFail = (id: string, error: AxiosError) => ({ + type: GROUP_REMOVED_ACCOUNTS_FETCH_FAIL, + id, + error, +}); + +const expandRemovedAccounts = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + const url = getState().user_lists.getIn(['groups_removed_accounts', id, 'next']); + + if (url === null) { + return; + } + + dispatch(expandRemovedAccountsRequest(id)); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data)); + dispatch(expandRemovedAccountsSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id))); + }).catch(error => { + dispatch(expandRemovedAccountsFail(id, error)); + }); + }; + +const expandRemovedAccountsRequest = (id: string) => ({ + type: GROUP_REMOVED_ACCOUNTS_EXPAND_REQUEST, + id, +}); + +const expandRemovedAccountsSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({ + type: GROUP_REMOVED_ACCOUNTS_EXPAND_SUCCESS, + id, + accounts, + next, +}); + +const expandRemovedAccountsFail = (id: string, error: AxiosError) => ({ + type: GROUP_REMOVED_ACCOUNTS_EXPAND_FAIL, + id, + error, +}); + +const removeRemovedAccount = (groupId: string, id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(removeRemovedAccountRequest(groupId, id)); + + api(getState).delete(`/api/v1/groups/${groupId}/removed_accounts?account_id=${id}`).then(response => { + dispatch(removeRemovedAccountSuccess(groupId, id)); + }).catch(error => { + dispatch(removeRemovedAccountFail(groupId, id, error)); + }); + }; + +const removeRemovedAccountRequest = (groupId: string, id: string) => ({ + type: GROUP_REMOVED_ACCOUNTS_REMOVE_REQUEST, + groupId, + id, +}); + +const removeRemovedAccountSuccess = (groupId: string, id: string) => ({ + type: GROUP_REMOVED_ACCOUNTS_REMOVE_SUCCESS, + groupId, + id, +}); + +const removeRemovedAccountFail = (groupId: string, id: string, error: AxiosError) => ({ + type: GROUP_REMOVED_ACCOUNTS_REMOVE_FAIL, + groupId, + id, + error, +}); + +const createRemovedAccount = (groupId: string, id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(createRemovedAccountRequest(groupId, id)); + + api(getState).post(`/api/v1/groups/${groupId}/removed_accounts?account_id=${id}`).then(response => { + dispatch(createRemovedAccountSuccess(groupId, id)); + }).catch(error => { + dispatch(createRemovedAccountFail(groupId, id, error)); + }); + }; + +const createRemovedAccountRequest = (groupId: string, id: string) => ({ + type: GROUP_REMOVED_ACCOUNTS_CREATE_REQUEST, + groupId, + id, +}); + +const createRemovedAccountSuccess = (groupId: string, id: string) => ({ + type: GROUP_REMOVED_ACCOUNTS_CREATE_SUCCESS, + groupId, + id, +}); + +const createRemovedAccountFail = (groupId: string, id: string, error: AxiosError) => ({ + type: GROUP_REMOVED_ACCOUNTS_CREATE_FAIL, + groupId, + id, + error, +}); + +const groupRemoveStatus = (groupId: string, id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + dispatch(groupRemoveStatusRequest(groupId, id)); + + api(getState).delete(`/api/v1/groups/${groupId}/statuses/${id}`).then(response => { + dispatch(groupRemoveStatusSuccess(groupId, id)); + }).catch(error => { + dispatch(groupRemoveStatusFail(groupId, id, error)); + }); + }; + +const groupRemoveStatusRequest = (groupId: string, id: string) => ({ + type: GROUP_REMOVE_STATUS_REQUEST, + groupId, + id, +}); + +const groupRemoveStatusSuccess = (groupId: string, id: string) => ({ + type: GROUP_REMOVE_STATUS_SUCCESS, + groupId, + id, +}); + +const groupRemoveStatusFail = (groupId: string, id: string, error: AxiosError) => ({ + type: GROUP_REMOVE_STATUS_FAIL, + groupId, + id, + error, +}); + +export { + GROUP_FETCH_REQUEST, + GROUP_FETCH_SUCCESS, + GROUP_FETCH_FAIL, + GROUP_RELATIONSHIPS_FETCH_REQUEST, + GROUP_RELATIONSHIPS_FETCH_SUCCESS, + GROUP_RELATIONSHIPS_FETCH_FAIL, + GROUPS_FETCH_REQUEST, + GROUPS_FETCH_SUCCESS, + GROUPS_FETCH_FAIL, + GROUP_JOIN_REQUEST, + GROUP_JOIN_SUCCESS, + GROUP_JOIN_FAIL, + GROUP_LEAVE_REQUEST, + GROUP_LEAVE_SUCCESS, + GROUP_LEAVE_FAIL, + GROUP_MEMBERS_FETCH_REQUEST, + GROUP_MEMBERS_FETCH_SUCCESS, + GROUP_MEMBERS_FETCH_FAIL, + GROUP_MEMBERS_EXPAND_REQUEST, + GROUP_MEMBERS_EXPAND_SUCCESS, + GROUP_MEMBERS_EXPAND_FAIL, + GROUP_REMOVED_ACCOUNTS_FETCH_REQUEST, + GROUP_REMOVED_ACCOUNTS_FETCH_SUCCESS, + GROUP_REMOVED_ACCOUNTS_FETCH_FAIL, + GROUP_REMOVED_ACCOUNTS_EXPAND_REQUEST, + GROUP_REMOVED_ACCOUNTS_EXPAND_SUCCESS, + GROUP_REMOVED_ACCOUNTS_EXPAND_FAIL, + GROUP_REMOVED_ACCOUNTS_REMOVE_REQUEST, + GROUP_REMOVED_ACCOUNTS_REMOVE_SUCCESS, + GROUP_REMOVED_ACCOUNTS_REMOVE_FAIL, + GROUP_REMOVED_ACCOUNTS_CREATE_REQUEST, + GROUP_REMOVED_ACCOUNTS_CREATE_SUCCESS, + GROUP_REMOVED_ACCOUNTS_CREATE_FAIL, + GROUP_REMOVE_STATUS_REQUEST, + GROUP_REMOVE_STATUS_SUCCESS, + GROUP_REMOVE_STATUS_FAIL, + fetchGroup, + fetchGroupRequest, + fetchGroupSuccess, + fetchGroupFail, + fetchGroupRelationships, + fetchGroupRelationshipsRequest, + fetchGroupRelationshipsSuccess, + fetchGroupRelationshipsFail, + fetchGroups, + fetchGroupsRequest, + fetchGroupsSuccess, + fetchGroupsFail, + joinGroup, + leaveGroup, + joinGroupRequest, + joinGroupSuccess, + joinGroupFail, + leaveGroupRequest, + leaveGroupSuccess, + leaveGroupFail, + fetchMembers, + fetchMembersRequest, + fetchMembersSuccess, + fetchMembersFail, + expandMembers, + expandMembersRequest, + expandMembersSuccess, + expandMembersFail, + fetchRemovedAccounts, + fetchRemovedAccountsRequest, + fetchRemovedAccountsSuccess, + fetchRemovedAccountsFail, + expandRemovedAccounts, + expandRemovedAccountsRequest, + expandRemovedAccountsSuccess, + expandRemovedAccountsFail, + removeRemovedAccount, + removeRemovedAccountRequest, + removeRemovedAccountSuccess, + removeRemovedAccountFail, + createRemovedAccount, + createRemovedAccountRequest, + createRemovedAccountSuccess, + createRemovedAccountFail, + groupRemoveStatus, + groupRemoveStatusRequest, + groupRemoveStatusSuccess, + groupRemoveStatusFail, +}; diff --git a/app/soapbox/actions/mrf.ts b/app/soapbox/actions/mrf.ts index a2a3157334..e2cef5938f 100644 --- a/app/soapbox/actions/mrf.ts +++ b/app/soapbox/actions/mrf.ts @@ -27,7 +27,7 @@ const updateMrf = (host: string, restrictions: ImmutableMap) => const simplePolicy = ConfigDB.toSimplePolicy(configs); const merged = simplePolicyMerge(simplePolicy, host, restrictions); const config = ConfigDB.fromSimplePolicy(merged); - return dispatch(updateConfig(config)); + return dispatch(updateConfig(config.toJS() as Array>)); }); export { updateMrf }; diff --git a/app/soapbox/actions/verification.js b/app/soapbox/actions/verification.ts similarity index 72% rename from app/soapbox/actions/verification.js rename to app/soapbox/actions/verification.ts index 8bd34b75cc..bc8db0189d 100644 Binary files a/app/soapbox/actions/verification.js and b/app/soapbox/actions/verification.ts differ diff --git a/app/soapbox/components/status.tsx b/app/soapbox/components/status.tsx index 4389b1fc19..693d408f6f 100644 --- a/app/soapbox/components/status.tsx +++ b/app/soapbox/components/status.tsx @@ -59,7 +59,7 @@ interface IStatus extends RouteComponentProps { account: AccountEntity, otherAccounts: ImmutableList, onClick: () => void, - onReply: (status: StatusEntity, history: History) => void, + onReply: (status: StatusEntity) => void, onFavourite: (status: StatusEntity) => void, onReblog: (status: StatusEntity, e?: KeyboardEvent) => void, onQuote: (status: StatusEntity) => void, @@ -67,7 +67,7 @@ interface IStatus extends RouteComponentProps { onEdit: (status: StatusEntity) => void, onDirect: (status: StatusEntity) => void, onChat: (status: StatusEntity) => void, - onMention: (account: StatusEntity['account'], history: History) => void, + onMention: (account: StatusEntity['account']) => void, onPin: (status: StatusEntity) => void, onOpenMedia: (media: ImmutableList, index: number) => void, onOpenVideo: (media: ImmutableMap | AttachmentEntity, startTime: number) => void, @@ -229,7 +229,7 @@ class Status extends ImmutablePureComponent { handleHotkeyReply = (e?: KeyboardEvent): void => { e?.preventDefault(); - this.props.onReply(this._properStatus(), this.props.history); + this.props.onReply(this._properStatus()); } handleHotkeyFavourite = (): void => { @@ -242,7 +242,7 @@ class Status extends ImmutablePureComponent { handleHotkeyMention = (e?: KeyboardEvent): void => { e?.preventDefault(); - this.props.onMention(this._properStatus().account, this.props.history); + this.props.onMention(this._properStatus().account); } handleHotkeyOpen = (): void => { diff --git a/app/soapbox/components/status_action_bar.tsx b/app/soapbox/components/status_action_bar.tsx index 302194d7a5..fc3fe6087b 100644 --- a/app/soapbox/components/status_action_bar.tsx +++ b/app/soapbox/components/status_action_bar.tsx @@ -71,16 +71,16 @@ interface IStatusActionBar extends RouteComponentProps { status: Status, onOpenUnauthorizedModal: (modalType?: string) => void, onOpenReblogsModal: (acct: string, statusId: string) => void, - onReply: (status: Status, history: History) => void, + onReply: (status: Status) => void, onFavourite: (status: Status) => void, onBookmark: (status: Status) => void, onReblog: (status: Status, e: React.MouseEvent) => void, - onQuote: (status: Status, history: History) => void, + onQuote: (status: Status) => void, onDelete: (status: Status, redraft?: boolean) => void, onEdit: (status: Status) => void, - onDirect: (account: any, history: History) => void, + onDirect: (account: any) => void, onChat: (account: any, history: History) => void, - onMention: (account: any, history: History) => void, + onMention: (account: any) => void, onMute: (account: any) => void, onBlock: (status: Status) => void, onReport: (status: Status) => void, @@ -134,7 +134,7 @@ class StatusActionBar extends ImmutablePureComponent = (e) => { e.stopPropagation(); - this.props.onMention(this.props.status.account, this.props.history); + this.props.onMention(this.props.status.account); } handleDirectClick: React.EventHandler = (e) => { e.stopPropagation(); - this.props.onDirect(this.props.status.account, this.props.history); + this.props.onDirect(this.props.status.account); } handleChatClick: React.EventHandler = (e) => { diff --git a/app/soapbox/containers/status_container.js b/app/soapbox/containers/status_container.js index 43cb728736..cf8b0b0893 100644 Binary files a/app/soapbox/containers/status_container.js and b/app/soapbox/containers/status_container.js differ diff --git a/app/soapbox/features/account_gallery/index.tsx b/app/soapbox/features/account_gallery/index.tsx index 386a8b7bb7..2b8f9c9c6c 100644 --- a/app/soapbox/features/account_gallery/index.tsx +++ b/app/soapbox/features/account_gallery/index.tsx @@ -44,7 +44,7 @@ const AccountGallery = () => { const accountFetchError = (state.accounts.get(-1)?.username || '').toLowerCase() === username.toLowerCase(); const features = getFeatures(state.instance); - let accountId: string | number | null = -1; + let accountId: string | -1 | null = -1; let accountUsername = username; if (accountFetchError) { accountId = null; diff --git a/app/soapbox/features/account_timeline/components/header.js b/app/soapbox/features/account_timeline/components/header.js index bba6bbcd57..af65f16889 100644 Binary files a/app/soapbox/features/account_timeline/components/header.js and b/app/soapbox/features/account_timeline/components/header.js differ diff --git a/app/soapbox/features/account_timeline/containers/header_container.js b/app/soapbox/features/account_timeline/containers/header_container.js index 201887e4e3..c1eccba319 100644 Binary files a/app/soapbox/features/account_timeline/containers/header_container.js and b/app/soapbox/features/account_timeline/containers/header_container.js differ diff --git a/app/soapbox/features/admin/components/latest_accounts_panel.tsx b/app/soapbox/features/admin/components/latest_accounts_panel.tsx index b9eabcde6c..07b3c3f9d4 100644 --- a/app/soapbox/features/admin/components/latest_accounts_panel.tsx +++ b/app/soapbox/features/admin/components/latest_accounts_panel.tsx @@ -30,8 +30,8 @@ const LatestAccountsPanel: React.FC = ({ limit = 5 }) => { useEffect(() => { dispatch(fetchUsers(['local', 'active'], 1, null, limit)) - .then((value: { count: number }) => { - setTotal(value.count); + .then((value) => { + setTotal((value as { count: number }).count); }) .catch(() => {}); }, []); diff --git a/app/soapbox/features/auth_layout/index.tsx b/app/soapbox/features/auth_layout/index.tsx index 1522363815..c8d10bbe68 100644 --- a/app/soapbox/features/auth_layout/index.tsx +++ b/app/soapbox/features/auth_layout/index.tsx @@ -32,7 +32,7 @@ const AuthLayout = () => { const features = useFeatures(); const instance = useAppSelector((state) => state.instance); const isOpen = features.accountCreation && instance.registrations; - const pepeOpen = useAppSelector(state => state.verification.getIn(['instance', 'registrations'], false) === true); + const pepeOpen = useAppSelector(state => state.verification.instance.get('registrations') === true); const isLoginPage = history.location.pathname === '/login'; const shouldShowRegisterLink = (isLoginPage && (isOpen || (pepeEnabled && pepeOpen))); diff --git a/app/soapbox/features/landing_page/index.tsx b/app/soapbox/features/landing_page/index.tsx index 8b29561693..dcb9afd361 100644 --- a/app/soapbox/features/landing_page/index.tsx +++ b/app/soapbox/features/landing_page/index.tsx @@ -12,7 +12,7 @@ const LandingPage = () => { const pepeEnabled = soapboxConfig.getIn(['extensions', 'pepe', 'enabled']) === true; const instance = useAppSelector((state) => state.instance); - const pepeOpen = useAppSelector(state => state.verification.getIn(['instance', 'registrations'], false) === true); + const pepeOpen = useAppSelector(state => state.verification.instance.get('registrations') === true); /** Registrations are closed */ const renderClosed = () => { diff --git a/app/soapbox/features/notifications/components/notification.tsx b/app/soapbox/features/notifications/components/notification.tsx index 26feb4b474..dda8879196 100644 --- a/app/soapbox/features/notifications/components/notification.tsx +++ b/app/soapbox/features/notifications/components/notification.tsx @@ -10,7 +10,6 @@ import AccountContainer from 'soapbox/containers/account_container'; import StatusContainer from 'soapbox/containers/status_container'; import { useAppSelector } from 'soapbox/hooks'; -import type { History } from 'history'; import type { ScrollPosition } from 'soapbox/components/status'; import type { NotificationType } from 'soapbox/normalizers/notification'; import type { Account, Status, Notification as NotificationEntity } from 'soapbox/types/entities'; @@ -131,7 +130,7 @@ interface INotificaton { notification: NotificationEntity, onMoveUp: (notificationId: string) => void, onMoveDown: (notificationId: string) => void, - onMention: (account: Account, history: History) => void, + onMention: (account: Account) => void, onFavourite: (status: Status) => void, onReblog: (status: Status, e?: KeyboardEvent) => void, onToggleHidden: (status: Status) => void, @@ -182,7 +181,7 @@ const Notification: React.FC = (props) => { e?.preventDefault(); if (account && typeof account === 'object') { - props.onMention(account, history); + props.onMention(account); } }; diff --git a/app/soapbox/features/notifications/containers/notification_container.js b/app/soapbox/features/notifications/containers/notification_container.js index d8713d204d..e9530fb837 100644 Binary files a/app/soapbox/features/notifications/containers/notification_container.js and b/app/soapbox/features/notifications/containers/notification_container.js differ diff --git a/app/soapbox/features/public_layout/components/header.tsx b/app/soapbox/features/public_layout/components/header.tsx index d44dfe4508..bb952e4060 100644 --- a/app/soapbox/features/public_layout/components/header.tsx +++ b/app/soapbox/features/public_layout/components/header.tsx @@ -34,7 +34,7 @@ const Header = () => { const features = useFeatures(); const instance = useAppSelector((state) => state.instance); const isOpen = features.accountCreation && instance.registrations; - const pepeOpen = useAppSelector(state => state.verification.getIn(['instance', 'registrations'], false) === true); + const pepeOpen = useAppSelector(state => state.verification.instance.get('registrations') === true); const [isLoading, setLoading] = React.useState(false); const [username, setUsername] = React.useState(''); diff --git a/app/soapbox/features/status/components/action-bar.tsx b/app/soapbox/features/status/components/action-bar.tsx index 885713c45c..b70cae0b87 100644 --- a/app/soapbox/features/status/components/action-bar.tsx +++ b/app/soapbox/features/status/components/action-bar.tsx @@ -91,15 +91,15 @@ interface OwnProps { status: StatusEntity, onReply: (status: StatusEntity) => void, onReblog: (status: StatusEntity, e: React.MouseEvent) => void, - onQuote: (status: StatusEntity, history: History) => void, + onQuote: (status: StatusEntity) => void, onFavourite: (status: StatusEntity) => void, onEmojiReact: (status: StatusEntity, emoji: string) => void, onDelete: (status: StatusEntity, redraft?: boolean) => void, onEdit: (status: StatusEntity) => void, onBookmark: (status: StatusEntity) => void, - onDirect: (account: AccountEntity, history: History) => void, + onDirect: (account: AccountEntity) => void, onChat: (account: AccountEntity, history: History) => void, - onMention: (account: AccountEntity, history: History) => void, + onMention: (account: AccountEntity) => void, onMute: (account: AccountEntity) => void, onMuteConversation: (status: StatusEntity) => void, onBlock: (status: StatusEntity) => void, @@ -164,7 +164,7 @@ class ActionBar extends React.PureComponent { handleQuoteClick: React.EventHandler = () => { const { me, onQuote, onOpenUnauthorizedModal, status } = this.props; if (me) { - onQuote(status, this.props.history); + onQuote(status); } else { onOpenUnauthorizedModal('REBLOG'); } @@ -250,7 +250,7 @@ class ActionBar extends React.PureComponent { handleDirectClick: React.EventHandler = () => { const { account } = this.props.status; if (!account || typeof account !== 'object') return; - this.props.onDirect(account, this.props.history); + this.props.onDirect(account); } handleChatClick: React.EventHandler = () => { @@ -262,7 +262,7 @@ class ActionBar extends React.PureComponent { handleMentionClick: React.EventHandler = () => { const { account } = this.props.status; if (!account || typeof account !== 'object') return; - this.props.onMention(account, this.props.history); + this.props.onMention(account); } handleMuteClick: React.EventHandler = () => { diff --git a/app/soapbox/features/status/containers/detailed_status_container.js b/app/soapbox/features/status/containers/detailed_status_container.js index ae9dcc97a5..a88d9e7cda 100644 Binary files a/app/soapbox/features/status/containers/detailed_status_container.js and b/app/soapbox/features/status/containers/detailed_status_container.js differ diff --git a/app/soapbox/features/status/index.tsx b/app/soapbox/features/status/index.tsx index 3a0803dc57..bc571aa6c6 100644 --- a/app/soapbox/features/status/index.tsx +++ b/app/soapbox/features/status/index.tsx @@ -273,10 +273,10 @@ class Status extends ImmutablePureComponent { dispatch(openModal('CONFIRM', { message: intl.formatMessage(messages.replyMessage), confirm: intl.formatMessage(messages.replyConfirm), - onConfirm: () => dispatch(replyCompose(status, this.props.history)), + onConfirm: () => dispatch(replyCompose(status)), })); } else { - dispatch(replyCompose(status, this.props.history)); + dispatch(replyCompose(status)); } } @@ -305,10 +305,10 @@ class Status extends ImmutablePureComponent { dispatch(openModal('CONFIRM', { message: intl.formatMessage(messages.replyMessage), confirm: intl.formatMessage(messages.replyConfirm), - onConfirm: () => dispatch(quoteCompose(status, this.props.history)), + onConfirm: () => dispatch(quoteCompose(status)), })); } else { - dispatch(quoteCompose(status, this.props.history)); + dispatch(quoteCompose(status)); } } @@ -337,16 +337,16 @@ class Status extends ImmutablePureComponent { dispatch(editStatus(status.id)); } - handleDirectClick = (account: AccountEntity, router: History) => { - this.props.dispatch(directCompose(account, router)); + handleDirectClick = (account: AccountEntity) => { + this.props.dispatch(directCompose(account)); } handleChatClick = (account: AccountEntity, router: History) => { this.props.dispatch(launchChat(account.id, router)); } - handleMentionClick = (account: AccountEntity, router: History) => { - this.props.dispatch(mentionCompose(account, router)); + handleMentionClick = (account: AccountEntity) => { + this.props.dispatch(mentionCompose(account)); } handleOpenMedia = (media: ImmutableList, index: number) => { @@ -475,7 +475,7 @@ class Status extends ImmutablePureComponent { e?.preventDefault(); const { account } = this.props.status; if (!account || typeof account !== 'object') return; - this.handleMentionClick(account, this.props.history); + this.handleMentionClick(account); } handleHotkeyOpenProfile = () => { diff --git a/app/soapbox/features/ui/components/modals/landing-page-modal.tsx b/app/soapbox/features/ui/components/modals/landing-page-modal.tsx index 48e445e96e..d3e9b8be5e 100644 --- a/app/soapbox/features/ui/components/modals/landing-page-modal.tsx +++ b/app/soapbox/features/ui/components/modals/landing-page-modal.tsx @@ -29,7 +29,7 @@ const LandingPageModal: React.FC = ({ onClose }) => { const features = useFeatures(); const isOpen = features.accountCreation && instance.registrations; - const pepeOpen = useAppSelector(state => state.verification.getIn(['instance', 'registrations'], false) === true); + const pepeOpen = useAppSelector(state => state.verification.instance.get('registrations') === true); return ( = ({ onClose }) => { const intl = useIntl(); const accessToken = useAppSelector((state) => getAccessToken(state)); const title = useAppSelector((state) => state.instance.title); - const isLoading = useAppSelector((state) => state.verification.get('isLoading') as boolean); + const isLoading = useAppSelector((state) => state.verification.isLoading); const [status, setStatus] = useState(Statuses.IDLE); const [phone, setPhone] = useState(''); diff --git a/app/soapbox/features/verification/__tests__/index.test.tsx b/app/soapbox/features/verification/__tests__/index.test.tsx index 2eda528524..32182f5153 100644 --- a/app/soapbox/features/verification/__tests__/index.test.tsx +++ b/app/soapbox/features/verification/__tests__/index.test.tsx @@ -1,4 +1,4 @@ -import { Map as ImmutableMap } from 'immutable'; +import { Map as ImmutableMap, Record as ImmutableRecord } from 'immutable'; import React from 'react'; import { Route, Switch } from 'react-router-dom'; @@ -26,13 +26,17 @@ describe('', () => { beforeEach(() => { store = { - verification: ImmutableMap({ - instance: { + verification: ImmutableRecord({ + instance: ImmutableMap({ isReady: true, registrations: true, - }, + }), + ageMinimum: null, + currentChallenge: null, + isLoading: false, isComplete: false, - }), + token: null, + })(), }; __stub(mock => { diff --git a/app/soapbox/features/verification/email_passthru.tsx b/app/soapbox/features/verification/email_passthru.tsx index 97020f3a49..ebe36e5f05 100644 --- a/app/soapbox/features/verification/email_passthru.tsx +++ b/app/soapbox/features/verification/email_passthru.tsx @@ -1,11 +1,11 @@ import * as React from 'react'; import { defineMessages, useIntl } from 'react-intl'; -import { useDispatch } from 'react-redux'; import { useParams } from 'react-router-dom'; import snackbar from 'soapbox/actions/snackbar'; import { confirmEmailVerification } from 'soapbox/actions/verification'; import { Icon, Spinner, Stack, Text } from 'soapbox/components/ui'; +import { useAppDispatch } from 'soapbox/hooks'; import type { AxiosError } from 'axios'; @@ -96,7 +96,7 @@ const TokenExpired = () => { const EmailPassThru = () => { const { token } = useParams<{ token: string }>(); - const dispatch = useDispatch(); + const dispatch = useAppDispatch(); const intl = useIntl(); const [status, setStatus] = React.useState(Statuses.IDLE); diff --git a/app/soapbox/features/verification/index.tsx b/app/soapbox/features/verification/index.tsx index 5dacb5ab37..6e1716c32e 100644 --- a/app/soapbox/features/verification/index.tsx +++ b/app/soapbox/features/verification/index.tsx @@ -25,10 +25,10 @@ const verificationSteps = { const Verification = () => { const dispatch = useDispatch(); - const isInstanceReady = useAppSelector((state) => state.verification.getIn(['instance', 'isReady'], false) === true); - const isRegistrationOpen = useAppSelector(state => state.verification.getIn(['instance', 'registrations'], false) === true); - const currentChallenge = useAppSelector((state) => state.verification.getIn(['currentChallenge']) as ChallengeTypes); - const isVerificationComplete = useAppSelector((state) => state.verification.get('isComplete')); + const isInstanceReady = useAppSelector((state) => state.verification.instance.get('isReady') === true); + const isRegistrationOpen = useAppSelector(state => state.verification.instance.get('registrations') === true); + const currentChallenge = useAppSelector((state) => state.verification.currentChallenge as ChallengeTypes); + const isVerificationComplete = useAppSelector((state) => state.verification.isComplete); const StepToRender = verificationSteps[currentChallenge]; React.useEffect(() => { diff --git a/app/soapbox/features/verification/registration.tsx b/app/soapbox/features/verification/registration.tsx index 2fdf9980ba..02aea214c1 100644 --- a/app/soapbox/features/verification/registration.tsx +++ b/app/soapbox/features/verification/registration.tsx @@ -1,6 +1,5 @@ import * as React from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; -import { useDispatch } from 'react-redux'; import { Redirect } from 'react-router-dom'; import { logIn, verifyCredentials } from 'soapbox/actions/auth'; @@ -9,7 +8,7 @@ import { startOnboarding } from 'soapbox/actions/onboarding'; import snackbar from 'soapbox/actions/snackbar'; import { createAccount, removeStoredVerification } from 'soapbox/actions/verification'; import { Button, Form, FormGroup, Input } from 'soapbox/components/ui'; -import { useAppSelector } from 'soapbox/hooks'; +import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; import { getRedirectUrl } from 'soapbox/utils/redirect'; import PasswordIndicator from './components/password-indicator'; @@ -37,10 +36,10 @@ const initialState = { }; const Registration = () => { - const dispatch = useDispatch(); + const dispatch = useAppDispatch(); const intl = useIntl(); - const isLoading = useAppSelector((state) => state.verification.get('isLoading') as boolean); + const isLoading = useAppSelector((state) => state.verification.isLoading as boolean); const siteTitle = useAppSelector((state) => state.instance.title); const [state, setState] = React.useState(initialState); diff --git a/app/soapbox/features/verification/steps/age-verification.tsx b/app/soapbox/features/verification/steps/age-verification.tsx index 67559a6bb1..634fd92c44 100644 --- a/app/soapbox/features/verification/steps/age-verification.tsx +++ b/app/soapbox/features/verification/steps/age-verification.tsx @@ -25,8 +25,8 @@ const AgeVerification = () => { const intl = useIntl(); const dispatch = useAppDispatch(); - const isLoading = useAppSelector((state) => state.verification.get('isLoading')) as boolean; - const ageMinimum = useAppSelector((state) => state.verification.get('ageMinimum')) as any; + const isLoading = useAppSelector((state) => state.verification.isLoading) as boolean; + const ageMinimum = useAppSelector((state) => state.verification.ageMinimum) as any; const siteTitle = useAppSelector((state) => state.instance.title); const [date, setDate] = React.useState(''); diff --git a/app/soapbox/features/verification/steps/email-verification.tsx b/app/soapbox/features/verification/steps/email-verification.tsx index c68d782083..b8c1810b16 100644 --- a/app/soapbox/features/verification/steps/email-verification.tsx +++ b/app/soapbox/features/verification/steps/email-verification.tsx @@ -53,7 +53,7 @@ const EmailVerification = () => { const intl = useIntl(); const dispatch = useAppDispatch(); - const isLoading = useAppSelector((state) => state.verification.get('isLoading')) as boolean; + const isLoading = useAppSelector((state) => state.verification.isLoading) as boolean; const [email, setEmail] = React.useState(''); const [status, setStatus] = React.useState(Statuses.IDLE); diff --git a/app/soapbox/features/verification/steps/sms-verification.tsx b/app/soapbox/features/verification/steps/sms-verification.tsx index a4017f0613..4e24c8b24c 100644 --- a/app/soapbox/features/verification/steps/sms-verification.tsx +++ b/app/soapbox/features/verification/steps/sms-verification.tsx @@ -21,7 +21,7 @@ const SmsVerification = () => { const intl = useIntl(); const dispatch = useAppDispatch(); - const isLoading = useAppSelector((state) => state.verification.get('isLoading')) as boolean; + const isLoading = useAppSelector((state) => state.verification.isLoading) as boolean; const [phone, setPhone] = React.useState(''); const [status, setStatus] = React.useState(Statuses.IDLE); diff --git a/app/soapbox/reducers/__tests__/verification.test.ts b/app/soapbox/reducers/__tests__/verification.test.ts index 4789def7ec..f503de4430 100644 --- a/app/soapbox/reducers/__tests__/verification.test.ts +++ b/app/soapbox/reducers/__tests__/verification.test.ts @@ -1,117 +1,177 @@ -import { Map as ImmutableMap } from 'immutable'; +import { Map as ImmutableMap, Record as ImmutableRecord } from 'immutable'; -import { SET_LOADING } from 'soapbox/actions/verification'; +import { + Challenge, + FETCH_CHALLENGES_SUCCESS, + FETCH_TOKEN_SUCCESS, + SET_CHALLENGES_COMPLETE, + SET_LOADING, + SET_NEXT_CHALLENGE, +} from 'soapbox/actions/verification'; -import { FETCH_CHALLENGES_SUCCESS, FETCH_TOKEN_SUCCESS, SET_CHALLENGES_COMPLETE, SET_NEXT_CHALLENGE } from '../../actions/verification'; import reducer from '../verification'; describe('verfication reducer', () => { it('returns the initial state', () => { - expect(reducer(undefined, {})).toEqual(ImmutableMap({ + expect(reducer(undefined, {} as any)).toMatchObject({ ageMinimum: null, currentChallenge: null, isLoading: false, isComplete: false, token: null, instance: ImmutableMap(), - })); + }); }); describe('FETCH_CHALLENGES_SUCCESS', () => { it('sets the state', () => { - const state = ImmutableMap({ - untouched: 'hello', + const state = ImmutableRecord({ ageMinimum: null, currentChallenge: null, isLoading: true, isComplete: null, - }); + token: null, + instance: ImmutableMap(), + })(); const action = { type: FETCH_CHALLENGES_SUCCESS, ageMinimum: 13, currentChallenge: 'email', isComplete: false, }; - const expected = ImmutableMap({ - untouched: 'hello', + const expected = { ageMinimum: 13, currentChallenge: 'email', isLoading: false, isComplete: false, - }); + token: null, + instance: ImmutableMap(), + }; - expect(reducer(state, action)).toEqual(expected); + expect(reducer(state, action)).toMatchObject(expected); }); }); describe('FETCH_TOKEN_SUCCESS', () => { it('sets the state', () => { - const state = ImmutableMap({ + const state = ImmutableRecord({ + ageMinimum: null, + currentChallenge: 'email' as Challenge, isLoading: true, + isComplete: false, token: null, - }); + instance: ImmutableMap(), + })(); const action = { type: FETCH_TOKEN_SUCCESS, value: '123' }; - const expected = ImmutableMap({ + const expected = { + ageMinimum: null, + currentChallenge: 'email', isLoading: false, + isComplete: false, token: '123', - }); + instance: ImmutableMap(), + }; - expect(reducer(state, action)).toEqual(expected); + expect(reducer(state, action)).toMatchObject(expected); }); }); describe('SET_CHALLENGES_COMPLETE', () => { it('sets the state', () => { - const state = ImmutableMap({ + const state = ImmutableRecord({ + ageMinimum: null, + currentChallenge: null, isLoading: true, isComplete: false, - }); + token: null, + instance: ImmutableMap(), + })(); const action = { type: SET_CHALLENGES_COMPLETE }; - const expected = ImmutableMap({ + const expected = { + ageMinimum: null, + currentChallenge: null, isLoading: false, isComplete: true, - }); + token: null, + instance: ImmutableMap(), + }; - expect(reducer(state, action)).toEqual(expected); + expect(reducer(state, action)).toMatchObject(expected); }); }); describe('SET_NEXT_CHALLENGE', () => { it('sets the state', () => { - const state = ImmutableMap({ + const state = ImmutableRecord({ + ageMinimum: null, currentChallenge: null, isLoading: true, - }); + isComplete: false, + token: null, + instance: ImmutableMap(), + })(); const action = { type: SET_NEXT_CHALLENGE, challenge: 'sms', }; - const expected = ImmutableMap({ + const expected = { + ageMinimum: null, currentChallenge: 'sms', isLoading: false, - }); + isComplete: false, + token: null, + instance: ImmutableMap(), + }; - expect(reducer(state, action)).toEqual(expected); + expect(reducer(state, action)).toMatchObject(expected); }); }); describe('SET_LOADING with no value', () => { it('sets the state', () => { - const state = ImmutableMap({ isLoading: false }); + const state = ImmutableRecord({ + ageMinimum: null, + currentChallenge: null, + isLoading: false, + isComplete: false, + token: null, + instance: ImmutableMap(), + })(); const action = { type: SET_LOADING }; - const expected = ImmutableMap({ isLoading: true }); + const expected = { + ageMinimum: null, + currentChallenge: null, + isLoading: true, + isComplete: false, + token: null, + instance: ImmutableMap(), + }; - expect(reducer(state, action)).toEqual(expected); + expect(reducer(state, action)).toMatchObject(expected); }); }); describe('SET_LOADING with a value', () => { it('sets the state', () => { - const state = ImmutableMap({ isLoading: true }); + const state = ImmutableRecord({ + ageMinimum: null, + currentChallenge: null, + isLoading: true, + isComplete: false, + token: null, + instance: ImmutableMap(), + })(); const action = { type: SET_LOADING, value: false }; - const expected = ImmutableMap({ isLoading: false }); + const expected = { + ageMinimum: null, + currentChallenge: null, + isLoading: false, + isComplete: false, + token: null, + instance: ImmutableMap(), + }; - expect(reducer(state, action)).toEqual(expected); + expect(reducer(state, action)).toMatchObject(expected); }); }); }); diff --git a/app/soapbox/reducers/verification.js b/app/soapbox/reducers/verification.ts similarity index 66% rename from app/soapbox/reducers/verification.js rename to app/soapbox/reducers/verification.ts index d45e459ebe..94abdeeb1b 100644 Binary files a/app/soapbox/reducers/verification.js and b/app/soapbox/reducers/verification.ts differ