diff --git a/app/soapbox/actions/__tests__/me.test.ts b/app/soapbox/actions/__tests__/me.test.ts index c75d128f0..11ea808be 100644 --- a/app/soapbox/actions/__tests__/me.test.ts +++ b/app/soapbox/actions/__tests__/me.test.ts @@ -3,6 +3,7 @@ import { Map as ImmutableMap } from 'immutable'; import { __stub } from 'soapbox/api'; import { mockStore, rootState } from 'soapbox/jest/test-helpers'; +import { AuthUserRecord, ReducerRecord } from '../../reducers/auth'; import { fetchMe, patchMe, } from '../me'; @@ -38,10 +39,10 @@ describe('fetchMe()', () => { beforeEach(() => { const state = rootState - .set('auth', ImmutableMap({ + .set('auth', ReducerRecord({ me: accountUrl, users: ImmutableMap({ - [accountUrl]: ImmutableMap({ + [accountUrl]: AuthUserRecord({ 'access_token': token, }), }), @@ -112,4 +113,4 @@ describe('patchMe()', () => { expect(actions).toEqual(expectedActions); }); }); -}); \ No newline at end of file +}); diff --git a/app/soapbox/actions/admin.ts b/app/soapbox/actions/admin.ts index 3cd5a25ba..e24999aa1 100644 --- a/app/soapbox/actions/admin.ts +++ b/app/soapbox/actions/admin.ts @@ -77,6 +77,16 @@ const ADMIN_USERS_UNSUGGEST_REQUEST = 'ADMIN_USERS_UNSUGGEST_REQUEST'; const ADMIN_USERS_UNSUGGEST_SUCCESS = 'ADMIN_USERS_UNSUGGEST_SUCCESS'; const ADMIN_USERS_UNSUGGEST_FAIL = 'ADMIN_USERS_UNSUGGEST_FAIL'; +const ADMIN_USER_INDEX_EXPAND_FAIL = 'ADMIN_USER_INDEX_EXPAND_FAIL'; +const ADMIN_USER_INDEX_EXPAND_REQUEST = 'ADMIN_USER_INDEX_EXPAND_REQUEST'; +const ADMIN_USER_INDEX_EXPAND_SUCCESS = 'ADMIN_USER_INDEX_EXPAND_SUCCESS'; + +const ADMIN_USER_INDEX_FETCH_FAIL = 'ADMIN_USER_INDEX_FETCH_FAIL'; +const ADMIN_USER_INDEX_FETCH_REQUEST = 'ADMIN_USER_INDEX_FETCH_REQUEST'; +const ADMIN_USER_INDEX_FETCH_SUCCESS = 'ADMIN_USER_INDEX_FETCH_SUCCESS'; + +const ADMIN_USER_INDEX_QUERY_SET = 'ADMIN_USER_INDEX_QUERY_SET'; + const nicknamesFromIds = (getState: () => RootState, ids: string[]) => ids.map(id => getState().accounts.get(id)!.acct); const fetchConfig = () => @@ -544,6 +554,50 @@ const unsuggestUsers = (accountIds: string[]) => }); }; +const setUserIndexQuery = (query: string) => ({ type: ADMIN_USER_INDEX_QUERY_SET, query }); + +const fetchUserIndex = () => + (dispatch: AppDispatch, getState: () => RootState) => { + const { filters, page, query, pageSize, isLoading } = getState().admin_user_index; + + if (isLoading) return; + + dispatch({ type: ADMIN_USER_INDEX_FETCH_REQUEST }); + + dispatch(fetchUsers(filters.toJS() as string[], page + 1, query, pageSize)) + .then((data: any) => { + if (data.error) { + dispatch({ type: ADMIN_USER_INDEX_FETCH_FAIL }); + } else { + const { users, count, next } = (data); + dispatch({ type: ADMIN_USER_INDEX_FETCH_SUCCESS, users, count, next }); + } + }).catch(() => { + dispatch({ type: ADMIN_USER_INDEX_FETCH_FAIL }); + }); + }; + +const expandUserIndex = () => + (dispatch: AppDispatch, getState: () => RootState) => { + const { filters, page, query, pageSize, isLoading, next, loaded } = getState().admin_user_index; + + if (!loaded || isLoading) return; + + dispatch({ type: ADMIN_USER_INDEX_EXPAND_REQUEST }); + + dispatch(fetchUsers(filters.toJS() as string[], page + 1, query, pageSize, next)) + .then((data: any) => { + if (data.error) { + dispatch({ type: ADMIN_USER_INDEX_EXPAND_FAIL }); + } else { + const { users, count, next } = (data); + dispatch({ type: ADMIN_USER_INDEX_EXPAND_SUCCESS, users, count, next }); + } + }).catch(() => { + dispatch({ type: ADMIN_USER_INDEX_EXPAND_FAIL }); + }); + }; + export { ADMIN_CONFIG_FETCH_REQUEST, ADMIN_CONFIG_FETCH_SUCCESS, @@ -596,6 +650,13 @@ export { ADMIN_USERS_UNSUGGEST_REQUEST, ADMIN_USERS_UNSUGGEST_SUCCESS, ADMIN_USERS_UNSUGGEST_FAIL, + ADMIN_USER_INDEX_EXPAND_FAIL, + ADMIN_USER_INDEX_EXPAND_REQUEST, + ADMIN_USER_INDEX_EXPAND_SUCCESS, + ADMIN_USER_INDEX_FETCH_FAIL, + ADMIN_USER_INDEX_FETCH_REQUEST, + ADMIN_USER_INDEX_FETCH_SUCCESS, + ADMIN_USER_INDEX_QUERY_SET, fetchConfig, updateConfig, updateSoapboxConfig, @@ -622,4 +683,7 @@ export { setRole, suggestUsers, unsuggestUsers, + setUserIndexQuery, + fetchUserIndex, + expandUserIndex, }; diff --git a/app/soapbox/actions/auth.ts b/app/soapbox/actions/auth.ts index 8e7a00d02..d0f083f9b 100644 --- a/app/soapbox/actions/auth.ts +++ b/app/soapbox/actions/auth.ts @@ -29,7 +29,6 @@ import api, { baseClient } from '../api'; import { importFetchedAccount } from './importer'; import type { AxiosError } from 'axios'; -import type { Map as ImmutableMap } from 'immutable'; import type { AppDispatch, RootState } from 'soapbox/store'; export const SWITCH_ACCOUNT = 'SWITCH_ACCOUNT'; @@ -94,11 +93,11 @@ const createAuthApp = () => const createAppToken = () => (dispatch: AppDispatch, getState: () => RootState) => { - const app = getState().auth.get('app'); + const app = getState().auth.app; const params = { - client_id: app.get('client_id'), - client_secret: app.get('client_secret'), + client_id: app.client_id, + client_secret: app.client_secret, redirect_uri: 'urn:ietf:wg:oauth:2.0:oob', grant_type: 'client_credentials', scope: getScopes(getState()), @@ -111,11 +110,11 @@ const createAppToken = () => const createUserToken = (username: string, password: string) => (dispatch: AppDispatch, getState: () => RootState) => { - const app = getState().auth.get('app'); + const app = getState().auth.app; const params = { - client_id: app.get('client_id'), - client_secret: app.get('client_secret'), + client_id: app.client_id, + client_secret: app.client_secret, redirect_uri: 'urn:ietf:wg:oauth:2.0:oob', grant_type: 'password', username: username, @@ -127,32 +126,12 @@ const createUserToken = (username: string, password: string) => .then((token: Record) => dispatch(authLoggedIn(token))); }; -export const refreshUserToken = () => - (dispatch: AppDispatch, getState: () => RootState) => { - const refreshToken = getState().auth.getIn(['user', 'refresh_token']); - const app = getState().auth.get('app'); - - if (!refreshToken) return dispatch(noOp); - - const params = { - client_id: app.get('client_id'), - client_secret: app.get('client_secret'), - refresh_token: refreshToken, - redirect_uri: 'urn:ietf:wg:oauth:2.0:oob', - grant_type: 'refresh_token', - scope: getScopes(getState()), - }; - - return dispatch(obtainOAuthToken(params)) - .then((token: Record) => dispatch(authLoggedIn(token))); - }; - export const otpVerify = (code: string, mfa_token: string) => (dispatch: AppDispatch, getState: () => RootState) => { - const app = getState().auth.get('app'); + const app = getState().auth.app; return api(getState, 'app').post('/oauth/mfa/challenge', { - client_id: app.get('client_id'), - client_secret: app.get('client_secret'), + client_id: app.client_id, + client_secret: app.client_secret, mfa_token: mfa_token, code: code, challenge_type: 'totp', @@ -233,9 +212,9 @@ export const logOut = () => if (!account) return dispatch(noOp); const params = { - client_id: state.auth.getIn(['app', 'client_id']), - client_secret: state.auth.getIn(['app', 'client_secret']), - token: state.auth.getIn(['users', account.url, 'access_token']), + client_id: state.auth.app.client_id, + client_secret: state.auth.app.client_secret, + token: state.auth.users.get(account.url)?.access_token!, }; return dispatch(revokeOAuthToken(params)) @@ -263,10 +242,10 @@ export const switchAccount = (accountId: string, background = false) => export const fetchOwnAccounts = () => (dispatch: AppDispatch, getState: () => RootState) => { const state = getState(); - return state.auth.get('users').forEach((user: ImmutableMap) => { - const account = state.accounts.get(user.get('id')); + return state.auth.users.forEach((user) => { + const account = state.accounts.get(user.id); if (!account) { - dispatch(verifyCredentials(user.get('access_token')!, user.get('url'))); + dispatch(verifyCredentials(user.access_token!, user.url)); } }); }; diff --git a/app/soapbox/actions/instance.ts b/app/soapbox/actions/instance.ts index ca1fc3ef5..9738718b0 100644 --- a/app/soapbox/actions/instance.ts +++ b/app/soapbox/actions/instance.ts @@ -10,12 +10,12 @@ import api from '../api'; const getMeUrl = (state: RootState) => { const me = state.me; - return state.accounts.getIn([me, 'url']); + return state.accounts.get(me)?.url; }; /** Figure out the appropriate instance to fetch depending on the state */ export const getHost = (state: RootState) => { - const accountUrl = getMeUrl(state) || getAuthUserUrl(state); + const accountUrl = getMeUrl(state) || getAuthUserUrl(state) as string; try { return new URL(accountUrl).host; diff --git a/app/soapbox/actions/me.ts b/app/soapbox/actions/me.ts index 17beae21d..7a7cf18d9 100644 --- a/app/soapbox/actions/me.ts +++ b/app/soapbox/actions/me.ts @@ -30,8 +30,8 @@ const getMeUrl = (state: RootState) => { const getMeToken = (state: RootState) => { // Fallback for upgrading IDs to URLs - const accountUrl = getMeUrl(state) || state.auth.get('me'); - return state.auth.getIn(['users', accountUrl, 'access_token']); + const accountUrl = getMeUrl(state) || state.auth.me; + return state.auth.users.get(accountUrl!)?.access_token; }; const fetchMe = () => @@ -46,7 +46,7 @@ const fetchMe = () => } dispatch(fetchMeRequest()); - return dispatch(loadCredentials(token, accountUrl)) + return dispatch(loadCredentials(token, accountUrl!)) .catch(error => dispatch(fetchMeFail(error))); }; diff --git a/app/soapbox/api/index.ts b/app/soapbox/api/index.ts index 97d7d25d7..840c0322a 100644 --- a/app/soapbox/api/index.ts +++ b/app/soapbox/api/index.ts @@ -43,7 +43,7 @@ const maybeParseJSON = (data: string) => { const getAuthBaseURL = createSelector([ (state: RootState, me: string | false | null) => state.accounts.getIn([me, 'url']), - (state: RootState, _me: string | false | null) => state.auth.get('me'), + (state: RootState, _me: string | false | null) => state.auth.me, ], (accountUrl, authUserUrl) => { const baseURL = parseBaseURL(accountUrl) || parseBaseURL(authUserUrl); return baseURL !== window.location.origin ? baseURL : ''; diff --git a/app/soapbox/components/icon-button.tsx b/app/soapbox/components/icon-button.tsx index 6a7dea4f7..71f110995 100644 --- a/app/soapbox/components/icon-button.tsx +++ b/app/soapbox/components/icon-button.tsx @@ -10,7 +10,7 @@ interface IIconButton extends Pick pressed?: boolean size?: number src: string - text: React.ReactNode + text?: React.ReactNode } const IconButton: React.FC = ({ diff --git a/app/soapbox/features/admin/user-index.js b/app/soapbox/features/admin/user-index.js deleted file mode 100644 index 45104a828..000000000 --- a/app/soapbox/features/admin/user-index.js +++ /dev/null @@ -1,132 +0,0 @@ -import { Set as ImmutableSet, OrderedSet as ImmutableOrderedSet, is } from 'immutable'; -import debounce from 'lodash/debounce'; -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { injectIntl, defineMessages } from 'react-intl'; -import { connect } from 'react-redux'; - -import { fetchUsers } from 'soapbox/actions/admin'; -import ScrollableList from 'soapbox/components/scrollable-list'; -import { Column } from 'soapbox/components/ui'; -import AccountContainer from 'soapbox/containers/account-container'; -import { SimpleForm, TextInput } from 'soapbox/features/forms'; - -const messages = defineMessages({ - heading: { id: 'column.admin.users', defaultMessage: 'Users' }, - empty: { id: 'admin.user_index.empty', defaultMessage: 'No users found.' }, - searchPlaceholder: { id: 'admin.user_index.search_input_placeholder', defaultMessage: 'Who are you looking for?' }, -}); - -class UserIndex extends ImmutablePureComponent { - - static propTypes = { - dispatch: PropTypes.func.isRequired, - }; - - state = { - isLoading: true, - filters: ImmutableSet(['local', 'active']), - accountIds: ImmutableOrderedSet(), - total: Infinity, - pageSize: 50, - page: 0, - query: '', - nextLink: undefined, - } - - clearState = callback => { - this.setState({ - isLoading: true, - accountIds: ImmutableOrderedSet(), - page: 0, - }, callback); - } - - fetchNextPage = () => { - const { filters, page, query, pageSize, nextLink } = this.state; - const nextPage = page + 1; - - this.props.dispatch(fetchUsers(filters, nextPage, query, pageSize, nextLink)) - .then(({ users, count, next }) => { - const newIds = users.map(user => user.id); - - this.setState({ - isLoading: false, - accountIds: this.state.accountIds.union(newIds), - total: count, - page: nextPage, - nextLink: next, - }); - }) - .catch(() => { }); - } - - componentDidMount() { - this.fetchNextPage(); - } - - refresh = () => { - this.clearState(() => { - this.fetchNextPage(); - }); - } - - componentDidUpdate(prevProps, prevState) { - const { filters, query } = this.state; - const filtersChanged = !is(filters, prevState.filters); - const queryChanged = query !== prevState.query; - - if (filtersChanged || queryChanged) { - this.refresh(); - } - } - - handleLoadMore = debounce(() => { - this.fetchNextPage(); - }, 2000, { leading: true }); - - updateQuery = debounce(query => { - this.setState({ query }); - }, 900) - - handleQueryChange = e => { - this.updateQuery(e.target.value); - }; - - render() { - const { intl } = this.props; - const { accountIds, isLoading } = this.state; - const hasMore = accountIds.count() < this.state.total && this.state.nextLink !== false; - - const showLoading = isLoading && accountIds.isEmpty(); - - return ( - - - - - - {accountIds.map(id => - , - )} - - - ); - } - -} - -export default injectIntl(connect()(UserIndex)); \ No newline at end of file diff --git a/app/soapbox/features/admin/user-index.tsx b/app/soapbox/features/admin/user-index.tsx new file mode 100644 index 000000000..ff1361204 --- /dev/null +++ b/app/soapbox/features/admin/user-index.tsx @@ -0,0 +1,71 @@ +import debounce from 'lodash/debounce'; +import React, { useCallback, useEffect } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + +import { expandUserIndex, fetchUserIndex, setUserIndexQuery } from 'soapbox/actions/admin'; +import ScrollableList from 'soapbox/components/scrollable-list'; +import { Column } from 'soapbox/components/ui'; +import AccountContainer from 'soapbox/containers/account-container'; +import { SimpleForm, TextInput } from 'soapbox/features/forms'; +import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; + +const messages = defineMessages({ + heading: { id: 'column.admin.users', defaultMessage: 'Users' }, + empty: { id: 'admin.user_index.empty', defaultMessage: 'No users found.' }, + searchPlaceholder: { id: 'admin.user_index.search_input_placeholder', defaultMessage: 'Who are you looking for?' }, +}); + +const UserIndex: React.FC = () => { + const dispatch = useAppDispatch(); + const intl = useIntl(); + + const { isLoading, items, total, query, next } = useAppSelector((state) => state.admin_user_index); + + const handleLoadMore = () => { + dispatch(expandUserIndex()); + }; + + const updateQuery = useCallback(debounce(() => { + dispatch(fetchUserIndex()); + }, 900, { leading: true }), []); + + const handleQueryChange: React.ChangeEventHandler = e => { + dispatch(setUserIndexQuery(e.target.value)); + }; + + useEffect(() => { + updateQuery(); + }, [query]); + + const hasMore = items.count() < total && next !== null; + + const showLoading = isLoading && items.isEmpty(); + + return ( + + + + + + {items.map(id => + , + )} + + + ); +}; + +export default UserIndex; diff --git a/app/soapbox/features/auth-token-list/index.tsx b/app/soapbox/features/auth-token-list/index.tsx index c63f20e13..d59b05425 100644 --- a/app/soapbox/features/auth-token-list/index.tsx +++ b/app/soapbox/features/auth-token-list/index.tsx @@ -75,7 +75,7 @@ const AuthTokenList: React.FC = () => { const intl = useIntl(); const tokens = useAppSelector(state => state.security.get('tokens').reverse()); const currentTokenId = useAppSelector(state => { - const currentToken = state.auth.get('tokens').valueSeq().find((token: ImmutableMap) => token.get('me') === state.auth.get('me')); + const currentToken = state.auth.tokens.valueSeq().find((token) => token.me === state.auth.me); return currentToken?.get('id'); }); diff --git a/app/soapbox/features/compose/components/compose-form.tsx b/app/soapbox/features/compose/components/compose-form.tsx index 4e63a3034..a7af9e77d 100644 --- a/app/soapbox/features/compose/components/compose-form.tsx +++ b/app/soapbox/features/compose/components/compose-form.tsx @@ -75,7 +75,7 @@ const ComposeForm = ({ id, shouldCondense, autoFocus, clickab const showSearch = useAppSelector((state) => state.search.submitted && !state.search.hidden); const isModalOpen = useAppSelector((state) => !!(state.modals.size && state.modals.last()!.modalType === 'COMPOSE')); const maxTootChars = configuration.getIn(['statuses', 'max_characters']) as number; - const scheduledStatusCount = useAppSelector((state) => state.get('scheduled_statuses').size); + const scheduledStatusCount = useAppSelector((state) => state.scheduled_statuses.size); const features = useFeatures(); const { text, suggestions, spoiler, spoiler_text: spoilerText, privacy, focusDate, caretPosition, is_submitting: isSubmitting, is_changing_upload: isChangingUpload, is_uploading: isUploading, schedule: scheduledAt } = compose; diff --git a/app/soapbox/features/developers/settings-store.tsx b/app/soapbox/features/developers/settings-store.tsx index a32f69c96..34caab1b0 100644 --- a/app/soapbox/features/developers/settings-store.tsx +++ b/app/soapbox/features/developers/settings-store.tsx @@ -37,7 +37,7 @@ const SettingsStore: React.FC = () => { const intl = useIntl(); const dispatch = useAppDispatch(); const settings = useSettings(); - const settingsStore = useAppSelector(state => state.get('settings')); + const settingsStore = useAppSelector(state => state.settings); const [rawJSON, setRawJSON] = useState(JSON.stringify(settingsStore, null, 2)); const [jsonValid, setJsonValid] = useState(true); diff --git a/app/soapbox/features/ui/components/profile-dropdown.tsx b/app/soapbox/features/ui/components/profile-dropdown.tsx index 04d535add..e14c139e3 100644 --- a/app/soapbox/features/ui/components/profile-dropdown.tsx +++ b/app/soapbox/features/ui/components/profile-dropdown.tsx @@ -39,8 +39,8 @@ const ProfileDropdown: React.FC = ({ account, children }) => { const features = useFeatures(); const intl = useIntl(); - const authUsers = useAppSelector((state) => state.auth.get('users')); - const otherAccounts = useAppSelector((state) => authUsers.map((authUser: any) => getAccount(state, authUser.get('id')))); + const authUsers = useAppSelector((state) => state.auth.users); + const otherAccounts = useAppSelector((state) => authUsers.map((authUser: any) => getAccount(state, authUser.id)!)); const handleLogOut = () => { dispatch(logOut()); diff --git a/app/soapbox/pages/admin-page.tsx b/app/soapbox/pages/admin-page.tsx index eeccc004c..be960773d 100644 --- a/app/soapbox/pages/admin-page.tsx +++ b/app/soapbox/pages/admin-page.tsx @@ -16,9 +16,9 @@ const AdminPage: React.FC = ({ children }) => { - + {/* {Component => } - + */} diff --git a/app/soapbox/reducers/__tests__/auth.test.ts b/app/soapbox/reducers/__tests__/auth.test.ts index b90a1be65..27525d947 100644 --- a/app/soapbox/reducers/__tests__/auth.test.ts +++ b/app/soapbox/reducers/__tests__/auth.test.ts @@ -1,4 +1,4 @@ -import { Map as ImmutableMap, fromJS } from 'immutable'; +import { Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable'; import { AUTH_APP_CREATED, @@ -10,12 +10,13 @@ import { } from 'soapbox/actions/auth'; import { ME_FETCH_SKIP } from 'soapbox/actions/me'; import { MASTODON_PRELOAD_IMPORT } from 'soapbox/actions/preload'; +import { ReducerRecord } from 'soapbox/reducers/auth'; import reducer from '../auth'; describe('auth reducer', () => { it('should return the initial state', () => { - expect(reducer(undefined, {})).toEqual(ImmutableMap({ + expect(reducer(undefined, {} as any)).toEqual(ImmutableMap({ app: ImmutableMap(), users: ImmutableMap(), tokens: ImmutableMap(), @@ -47,9 +48,9 @@ describe('auth reducer', () => { }); it('should merge the token with existing state', () => { - const state = fromJS({ + const state = ReducerRecord(ImmutableMap(fromJS({ tokens: { 'ABCDEFG': { token_type: 'Bearer', access_token: 'ABCDEFG' } }, - }); + }))); const expected = fromJS({ 'ABCDEFG': { token_type: 'Bearer', access_token: 'ABCDEFG' }, @@ -73,12 +74,12 @@ describe('auth reducer', () => { account: fromJS({ url: 'https://gleasonator.com/users/alex' }), }; - const state = fromJS({ + const state = ReducerRecord(ImmutableMap(fromJS({ users: { 'https://gleasonator.com/users/alex': { id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/alex' }, 'https://gleasonator.com/users/benis': { id: '5678', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' }, }, - }); + }))); const expected = fromJS({ 'https://gleasonator.com/users/benis': { id: '5678', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' }, @@ -89,13 +90,13 @@ describe('auth reducer', () => { }); it('sets `me` to the next available user', () => { - const state = fromJS({ + const state = ReducerRecord(ImmutableMap(fromJS({ me: 'https://gleasonator.com/users/alex', users: { 'https://gleasonator.com/users/alex': { id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/alex' }, 'https://gleasonator.com/users/benis': { id: '5678', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' }, }, - }); + }))); const action = { type: AUTH_LOGGED_OUT, @@ -130,9 +131,9 @@ describe('auth reducer', () => { account: { id: '1234', url: 'https://gleasonator.com/users/alex' }, }; - const state = fromJS({ + const state = ReducerRecord(ImmutableMap(fromJS({ tokens: { 'ABCDEFG': { token_type: 'Bearer', access_token: 'ABCDEFG' } }, - }); + }))); const expected = fromJS({ 'ABCDEFG': { @@ -165,7 +166,7 @@ describe('auth reducer', () => { account: { id: '1234', url: 'https://gleasonator.com/users/alex' }, }; - const state = fromJS({ me: 'https://gleasonator.com/users/benis' }); + const state = ReducerRecord(ImmutableMap(fromJS({ me: 'https://gleasonator.com/users/benis' }))); const result = reducer(state, action); expect(result.get('me')).toEqual('https://gleasonator.com/users/benis'); @@ -178,13 +179,13 @@ describe('auth reducer', () => { account: { id: '1234', url: 'https://gleasonator.com/users/alex' }, }; - const state = fromJS({ + const state = ReducerRecord(ImmutableMap(fromJS({ users: { 'https://gleasonator.com/users/mk': { id: '4567', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/mk' }, 'https://gleasonator.com/users/curtis': { id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/curtis' }, 'https://gleasonator.com/users/benis': { id: '5432', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' }, }, - }); + }))); const expected = fromJS({ 'https://gleasonator.com/users/alex': { id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/alex' }, @@ -202,7 +203,7 @@ describe('auth reducer', () => { account: { id: '1234', url: 'https://gleasonator.com/users/alex' }, }; - const state = fromJS({ + const state = ReducerRecord(ImmutableMap(fromJS({ me: '1234', users: { '1234': { id: '1234', access_token: 'ABCDEFG' }, @@ -211,9 +212,9 @@ describe('auth reducer', () => { tokens: { 'ABCDEFG': { access_token: 'ABCDEFG', account: '1234' }, }, - }); + }))); - const expected = fromJS({ + const expected = ImmutableRecord(fromJS({ me: 'https://gleasonator.com/users/alex', users: { 'https://gleasonator.com/users/alex': { id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/alex' }, @@ -222,7 +223,7 @@ describe('auth reducer', () => { tokens: { 'ABCDEFG': { access_token: 'ABCDEFG', account: '1234', me: 'https://gleasonator.com/users/alex' }, }, - }); + })); const result = reducer(state, action); expect(result).toEqual(expected); @@ -231,12 +232,12 @@ describe('auth reducer', () => { describe('VERIFY_CREDENTIALS_FAIL', () => { it('should delete the failed token if it 403\'d', () => { - const state = fromJS({ + const state = ReducerRecord(ImmutableMap(fromJS({ tokens: { 'ABCDEFG': { token_type: 'Bearer', access_token: 'ABCDEFG' }, 'HIJKLMN': { token_type: 'Bearer', access_token: 'HIJKLMN' }, }, - }); + }))); const expected = fromJS({ 'HIJKLMN': { token_type: 'Bearer', access_token: 'HIJKLMN' }, @@ -253,12 +254,12 @@ describe('auth reducer', () => { }); it('should delete any users associated with the failed token', () => { - const state = fromJS({ + const state = ReducerRecord(ImmutableMap(fromJS({ users: { 'https://gleasonator.com/users/alex': { id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/alex' }, 'https://gleasonator.com/users/benis': { id: '5678', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' }, }, - }); + }))); const expected = fromJS({ 'https://gleasonator.com/users/benis': { id: '5678', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' }, @@ -275,13 +276,13 @@ describe('auth reducer', () => { }); it('should reassign `me` to the next in line', () => { - const state = fromJS({ + const state = ReducerRecord(ImmutableMap(fromJS({ me: 'https://gleasonator.com/users/alex', users: { 'https://gleasonator.com/users/alex': { id: '1234', access_token: 'ABCDEFG', url: 'https://gleasonator.com/users/alex' }, 'https://gleasonator.com/users/benis': { id: '5678', access_token: 'HIJKLMN', url: 'https://gleasonator.com/users/benis' }, }, - }); + }))); const action = { type: VERIFY_CREDENTIALS_FAIL, @@ -308,7 +309,7 @@ describe('auth reducer', () => { describe('ME_FETCH_SKIP', () => { it('sets `me` to null', () => { - const state = fromJS({ me: 'https://gleasonator.com/users/alex' }); + const state = ReducerRecord(ImmutableMap(fromJS({ me: 'https://gleasonator.com/users/alex' }))); const action = { type: ME_FETCH_SKIP }; const result = reducer(state, action); expect(result.get('me')).toEqual(null); diff --git a/app/soapbox/reducers/admin-user-index.ts b/app/soapbox/reducers/admin-user-index.ts new file mode 100644 index 000000000..51bb162ff --- /dev/null +++ b/app/soapbox/reducers/admin-user-index.ts @@ -0,0 +1,68 @@ +import { Set as ImmutableSet, OrderedSet as ImmutableOrderedSet, Record as ImmutableRecord } from 'immutable'; + +import { + ADMIN_USER_INDEX_EXPAND_FAIL, + ADMIN_USER_INDEX_EXPAND_REQUEST, + ADMIN_USER_INDEX_EXPAND_SUCCESS, + ADMIN_USER_INDEX_FETCH_FAIL, + ADMIN_USER_INDEX_FETCH_REQUEST, + ADMIN_USER_INDEX_FETCH_SUCCESS, + ADMIN_USER_INDEX_QUERY_SET, +} from 'soapbox/actions/admin'; + +import type { AnyAction } from 'redux'; +import type { APIEntity } from 'soapbox/types/entities'; + +const ReducerRecord = ImmutableRecord({ + isLoading: false, + loaded: false, + items: ImmutableOrderedSet(), + filters: ImmutableSet(['local', 'active']), + total: Infinity, + pageSize: 50, + page: -1, + query: '', + next: null as string | null, +}); + +type State = ReturnType; + +export default function admin_user_index(state: State = ReducerRecord(), action: AnyAction): State { + switch (action.type) { + case ADMIN_USER_INDEX_QUERY_SET: + return state.set('query', action.query); + case ADMIN_USER_INDEX_FETCH_REQUEST: + return state + .set('isLoading', true) + .set('loaded', true) + .set('items', ImmutableOrderedSet()) + .set('total', action.count) + .set('page', 0) + .set('next', null); + case ADMIN_USER_INDEX_FETCH_SUCCESS: + return state + .set('isLoading', false) + .set('loaded', true) + .set('items', ImmutableOrderedSet(action.users.map((user: APIEntity) => user.id))) + .set('total', action.count) + .set('page', 1) + .set('next', action.next); + case ADMIN_USER_INDEX_FETCH_FAIL: + case ADMIN_USER_INDEX_EXPAND_FAIL: + return state + .set('isLoading', false); + case ADMIN_USER_INDEX_EXPAND_REQUEST: + return state + .set('isLoading', true); + case ADMIN_USER_INDEX_EXPAND_SUCCESS: + return state + .set('isLoading', false) + .set('loaded', true) + .set('items', state.items.union(action.users.map((user: APIEntity) => user.id))) + .set('total', action.count) + .set('page', 1) + .set('next', action.next); + default: + return state; + } +} diff --git a/app/soapbox/reducers/auth.js b/app/soapbox/reducers/auth.ts similarity index 55% rename from app/soapbox/reducers/auth.js rename to app/soapbox/reducers/auth.ts index c2ed9cdf6..a80bb4fa0 100644 --- a/app/soapbox/reducers/auth.js +++ b/app/soapbox/reducers/auth.ts @@ -1,8 +1,8 @@ -import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; +import { List as ImmutableList, Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable'; import trim from 'lodash/trim'; import { MASTODON_PRELOAD_IMPORT } from 'soapbox/actions/preload'; -import { FE_SUBDIRECTORY } from 'soapbox/build-config'; +import BuildConfig from 'soapbox/build-config'; import KVStore from 'soapbox/storage/kv-store'; import { validId, isURL } from 'soapbox/utils/auth'; @@ -17,17 +17,54 @@ import { } from '../actions/auth'; import { ME_FETCH_SKIP } from '../actions/me'; -const defaultState = ImmutableMap({ - app: ImmutableMap(), - users: ImmutableMap(), - tokens: ImmutableMap(), - me: null, +import type { AxiosError } from 'axios'; +import type { AnyAction } from 'redux'; +import type { APIEntity, Account as AccountEntity } from 'soapbox/types/entities'; + +export const AuthAppRecord = ImmutableRecord({ + access_token: '', + client_id: '', + client_secret: '', + id: '', + name: '', + redirect_uri: '', + vapid_key: '', + website: '', }); -const buildKey = parts => parts.join(':'); +export const AuthUserRecord = ImmutableRecord({ + access_token: '', + id: '', + url: '', +}); + +export const AuthTokenRecord = ImmutableRecord({ + access_token: '', + account: '', + created_at: null as number | null, + expires_in: null as number | null, + id: null as number | null, + me: '', + refresh_token: null as string | null, + scope: '', + token_type: '', +}); + +export const ReducerRecord = ImmutableRecord({ + app: AuthAppRecord(), + tokens: ImmutableMap(), + users: ImmutableMap(), + me: null as string | null, +}); + +type AuthToken = ReturnType; +type AuthUser = ReturnType; +type State = ReturnType; + +const buildKey = (parts: string[]) => parts.join(':'); // For subdirectory support -const NAMESPACE = trim(FE_SUBDIRECTORY, '/') ? `soapbox@${FE_SUBDIRECTORY}` : 'soapbox'; +const NAMESPACE = trim(BuildConfig.FE_SUBDIRECTORY, '/') ? `soapbox@${BuildConfig.FE_SUBDIRECTORY}` : 'soapbox'; const STORAGE_KEY = buildKey([NAMESPACE, 'auth']); const SESSION_KEY = buildKey([NAMESPACE, 'auth', 'me']); @@ -38,34 +75,34 @@ const getSessionUser = () => { }; const sessionUser = getSessionUser(); -export const localState = fromJS(JSON.parse(localStorage.getItem(STORAGE_KEY))); +export const localState = fromJS(JSON.parse(localStorage.getItem(STORAGE_KEY)!)); // Checks if the user has an ID and access token -const validUser = user => { +const validUser = (user?: AuthUser) => { try { - return validId(user.get('id')) && validId(user.get('access_token')); + return !!(user && validId(user.id) && validId(user.access_token)); } catch (e) { return false; } }; // Finds the first valid user in the state -const firstValidUser = state => state.get('users', ImmutableMap()).find(validUser); +const firstValidUser = (state: State) => state.users.find(validUser); // For legacy purposes. IDs get upgraded to URLs further down. -const getUrlOrId = user => { +const getUrlOrId = (user?: AuthUser): string | null => { try { - const { id, url } = user.toJS(); - return url || id; + const { id, url } = user!.toJS(); + return (url || id) as string; } catch { return null; } }; // If `me` doesn't match an existing user, attempt to shift it. -const maybeShiftMe = state => { - const me = state.get('me'); - const user = state.getIn(['users', me]); +const maybeShiftMe = (state: State) => { + const me = state.me!; + const user = state.users.get(me); if (!validUser(user)) { const nextUser = firstValidUser(state); @@ -76,29 +113,30 @@ const maybeShiftMe = state => { }; // Set the user from the session or localStorage, whichever is valid first -const setSessionUser = state => state.update('me', null, me => { - const user = ImmutableList([ - state.getIn(['users', sessionUser]), - state.getIn(['users', me]), +const setSessionUser = (state: State) => state.update('me', me => { + const user = ImmutableList([ + state.users.get(sessionUser!)!, + state.users.get(me!)!, ]).find(validUser); return getUrlOrId(user); }); // Upgrade the initial state -const migrateLegacy = state => { +const migrateLegacy = (state: State) => { if (localState) return state; return state.withMutations(state => { - const app = fromJS(JSON.parse(localStorage.getItem('soapbox:auth:app'))); - const user = fromJS(JSON.parse(localStorage.getItem('soapbox:auth:user'))); + console.log(localStorage.getItem('soapbox:auth:app')); + const app = AuthAppRecord(JSON.parse(localStorage.getItem('soapbox:auth:app')!)); + const user = fromJS(JSON.parse(localStorage.getItem('soapbox:auth:user')!)) as ImmutableMap; if (!user) return; state.set('me', '_legacy'); // Placeholder account ID state.set('app', app); state.set('tokens', ImmutableMap({ - [user.get('access_token')]: user.set('account', '_legacy'), + [user.get('access_token')]: AuthTokenRecord(user.set('account', '_legacy')), })); state.set('users', ImmutableMap({ - '_legacy': ImmutableMap({ + '_legacy': AuthUserRecord({ id: '_legacy', access_token: user.get('access_token'), }), @@ -106,26 +144,26 @@ const migrateLegacy = state => { }); }; -const isUpgradingUrlId = state => { - const me = state.get('me'); - const user = state.getIn(['users', me]); +const isUpgradingUrlId = (state: State) => { + const me = state.me; + const user = state.users.get(me!); return validId(me) && user && !isURL(me); }; // Checks the state and makes it valid -const sanitizeState = state => { +const sanitizeState = (state: State) => { // Skip sanitation during ID to URL upgrade if (isUpgradingUrlId(state)) return state; return state.withMutations(state => { // Remove invalid users, ensure ID match - state.update('users', ImmutableMap(), users => ( + state.update('users', users => ( users.filter((user, url) => ( validUser(user) && user.get('url') === url )) )); // Remove mismatched tokens - state.update('tokens', ImmutableMap(), tokens => ( + state.update('tokens', tokens => ( tokens.filter((token, id) => ( validId(id) && token.get('access_token') === id )) @@ -133,21 +171,22 @@ const sanitizeState = state => { }); }; -const persistAuth = state => localStorage.setItem(STORAGE_KEY, JSON.stringify(state.toJS())); +const persistAuth = (state: State) => localStorage.setItem(STORAGE_KEY, JSON.stringify(state.toJS())); -const persistSession = state => { - const me = state.get('me'); +const persistSession = (state: State) => { + const me = state.me; if (me && typeof me === 'string') { sessionStorage.setItem(SESSION_KEY, me); } }; -const persistState = state => { +const persistState = (state: State) => { persistAuth(state); persistSession(state); }; -const initialize = state => { +const initialize = (state: State) => { + console.log(JSON.stringify(state.toJS()), JSON.stringify(localState?.toJS())); return state.withMutations(state => { maybeShiftMe(state); setSessionUser(state); @@ -157,17 +196,17 @@ const initialize = state => { }); }; -const initialState = initialize(defaultState.merge(localState)); +const initialState = initialize(ReducerRecord().merge(localState as any)); -const importToken = (state, token) => { - return state.setIn(['tokens', token.access_token], fromJS(token)); +const importToken = (state: State, token: APIEntity) => { + return state.setIn(['tokens', token.access_token], AuthTokenRecord(ImmutableMap(fromJS(token)))); }; // Upgrade the `_legacy` placeholder ID with a real account -const upgradeLegacyId = (state, account) => { +const upgradeLegacyId = (state: State, account: APIEntity) => { if (localState) return state; return state.withMutations(state => { - state.update('me', null, me => me === '_legacy' ? account.url : me); + state.update('me', me => me === '_legacy' ? account.url : me); state.deleteIn(['users', '_legacy']); }); // TODO: Delete `soapbox:auth:app` and `soapbox:auth:user` localStorage? @@ -176,19 +215,19 @@ const upgradeLegacyId = (state, account) => { // Users are now stored by their ActivityPub ID instead of their // primary key to support auth against multiple hosts. -const upgradeNonUrlId = (state, account) => { - const me = state.get('me'); +const upgradeNonUrlId = (state: State, account: APIEntity) => { + const me = state.me; if (isURL(me)) return state; return state.withMutations(state => { - state.update('me', null, me => me === account.id ? account.url : me); + state.update('me', me => me === account.id ? account.url : me); state.deleteIn(['users', account.id]); }); }; // Returns a predicate function for filtering a mismatched user/token -const userMismatch = (token, account) => { - return (user, url) => { +const userMismatch = (token: string, account: APIEntity) => { + return (user: AuthUser, url: string) => { const sameToken = user.get('access_token') === token; const differentUrl = url !== account.url || user.get('url') !== account.url; const differentId = user.get('id') !== account.id; @@ -197,7 +236,7 @@ const userMismatch = (token, account) => { }; }; -const importCredentials = (state, token, account) => { +const importCredentials = (state: State, token: string, account: APIEntity) => { return state.withMutations(state => { state.setIn(['users', account.url], ImmutableMap({ id: account.id, @@ -206,62 +245,62 @@ const importCredentials = (state, token, account) => { })); state.setIn(['tokens', token, 'account'], account.id); state.setIn(['tokens', token, 'me'], account.url); - state.update('users', ImmutableMap(), users => users.filterNot(userMismatch(token, account))); - state.update('me', null, me => me || account.url); + state.update('users', users => users.filterNot(userMismatch(token, account))); + state.update('me', me => me || account.url); upgradeLegacyId(state, account); upgradeNonUrlId(state, account); }); }; -const deleteToken = (state, token) => { +const deleteToken = (state: State, token: string) => { return state.withMutations(state => { - state.update('tokens', ImmutableMap(), tokens => tokens.delete(token)); - state.update('users', ImmutableMap(), users => users.filterNot(user => user.get('access_token') === token)); + state.update('tokens', tokens => tokens.delete(token)); + state.update('users', users => users.filterNot(user => user.get('access_token') === token)); maybeShiftMe(state); }); }; -const deleteUser = (state, account) => { - const accountUrl = account.get('url'); +const deleteUser = (state: State, account: AccountEntity) => { + const accountUrl = account.url; return state.withMutations(state => { - state.update('users', ImmutableMap(), users => users.delete(accountUrl)); - state.update('tokens', ImmutableMap(), tokens => tokens.filterNot(token => token.get('me') === accountUrl)); + state.update('users', users => users.delete(accountUrl)); + state.update('tokens', tokens => tokens.filterNot(token => token.get('me') === accountUrl)); maybeShiftMe(state); }); }; -const importMastodonPreload = (state, data) => { +const importMastodonPreload = (state: State, data: ImmutableMap) => { return state.withMutations(state => { const accountId = data.getIn(['meta', 'me']); - const accountUrl = data.getIn(['accounts', accountId, 'url']); + const accountUrl = data.getIn(['accounts', accountId, 'url']) as string; const accessToken = data.getIn(['meta', 'access_token']); if (validId(accessToken) && validId(accountId) && isURL(accountUrl)) { - state.setIn(['tokens', accessToken], fromJS({ + state.setIn(['tokens', accessToken], AuthTokenRecord(ImmutableMap(fromJS({ access_token: accessToken, account: accountId, me: accountUrl, scope: 'read write follow push', token_type: 'Bearer', - })); + })))); - state.setIn(['users', accountUrl], fromJS({ + state.setIn(['users', accountUrl], AuthUserRecord(ImmutableMap(fromJS({ id: accountId, access_token: accessToken, url: accountUrl, - })); + })))); } maybeShiftMe(state); }); }; -const persistAuthAccount = account => { +const persistAuthAccount = (account: APIEntity) => { if (account && account.url) { const key = `authAccount:${account.url}`; if (!account.pleroma) account.pleroma = {}; - KVStore.getItem(key).then(oldAccount => { + KVStore.getItem(key).then((oldAccount: any) => { const settings = oldAccount?.pleroma?.settings_store || {}; if (!account.pleroma.settings_store) { account.pleroma.settings_store = settings; @@ -272,20 +311,22 @@ const persistAuthAccount = account => { } }; -const deleteForbiddenToken = (state, error, token) => { - if ([401, 403].includes(error.response?.status)) { +const deleteForbiddenToken = (state: State, error: AxiosError, token: string) => { + if ([401, 403].includes(error.response?.status!)) { return deleteToken(state, token); } else { return state; } }; -const reducer = (state, action) => { +const reducer = (state: State, action: AnyAction) => { switch (action.type) { case AUTH_APP_CREATED: - return state.set('app', fromJS(action.app)); + console.log(action.app, AuthAppRecord(action.app)); + return state.set('app', AuthAppRecord(action.app)); case AUTH_APP_AUTHORIZED: - return state.update('app', ImmutableMap(), app => app.merge(fromJS(action.token))); + console.log(state.app, state.app.merge(action.token)); + return state.update('app', app => app.merge(action.token)); case AUTH_LOGGED_IN: return importToken(state, action.token); case AUTH_LOGGED_OUT: @@ -300,7 +341,7 @@ const reducer = (state, action) => { case ME_FETCH_SKIP: return state.set('me', null); case MASTODON_PRELOAD_IMPORT: - return importMastodonPreload(state, fromJS(action.data)); + return importMastodonPreload(state, fromJS(action.data) as ImmutableMap); default: return state; } @@ -309,33 +350,33 @@ const reducer = (state, action) => { const reload = () => location.replace('/'); // `me` is a user ID string -const validMe = state => { - const me = state.get('me'); +const validMe = (state: State) => { + const me = state.me; return typeof me === 'string' && me !== '_legacy'; }; // `me` has changed from one valid ID to another -const userSwitched = (oldState, state) => { - const me = state.get('me'); - const oldMe = oldState.get('me'); +const userSwitched = (oldState: State, state: State) => { + const me = state.me; + const oldMe = oldState.me; const stillValid = validMe(oldState) && validMe(state); const didChange = oldMe !== me; - const userUpgradedUrl = state.getIn(['users', me, 'id']) === oldMe; + const userUpgradedUrl = state.users.get(me!)?.id === oldMe; return stillValid && didChange && !userUpgradedUrl; }; -const maybeReload = (oldState, state, action) => { +const maybeReload = (oldState: State, state: State, action: AnyAction) => { const loggedOutStandalone = action.type === AUTH_LOGGED_OUT && action.standalone; const switched = userSwitched(oldState, state); if (switched || loggedOutStandalone) { - reload(state); + reload(); } }; -export default function auth(oldState = initialState, action) { +export default function auth(oldState: State = initialState, action: AnyAction) { const state = reducer(oldState, action); if (!state.equals(oldState)) { diff --git a/app/soapbox/reducers/index.ts b/app/soapbox/reducers/index.ts index 7e84c0537..5b5cca999 100644 --- a/app/soapbox/reducers/index.ts +++ b/app/soapbox/reducers/index.ts @@ -10,6 +10,7 @@ import accounts_counters from './accounts-counters'; import accounts_meta from './accounts-meta'; import admin from './admin'; import admin_log from './admin-log'; +import admin_user_index from './admin-user-index'; import aliases from './aliases'; import announcements from './announcements'; import auth from './auth'; @@ -118,6 +119,7 @@ const reducers = { history, announcements, compose_event, + admin_user_index, }; // Build a default state from all reducers: it has the key and `undefined` diff --git a/app/soapbox/reducers/soapbox.js b/app/soapbox/reducers/soapbox.ts similarity index 60% rename from app/soapbox/reducers/soapbox.js rename to app/soapbox/reducers/soapbox.ts index b959a5596..d44b562a3 100644 --- a/app/soapbox/reducers/soapbox.js +++ b/app/soapbox/reducers/soapbox.ts @@ -1,4 +1,4 @@ -import { Map as ImmutableMap, fromJS } from 'immutable'; +import { List as ImmutableList, Map as ImmutableMap, fromJS } from 'immutable'; import { PLEROMA_PRELOAD_IMPORT } from 'soapbox/actions/preload'; import KVStore from 'soapbox/storage/kv-store'; @@ -11,24 +11,24 @@ import { SOAPBOX_CONFIG_REQUEST_FAIL, } from '../actions/soapbox'; -const initialState = ImmutableMap(); +const initialState = ImmutableMap(); -const fallbackState = ImmutableMap({ +const fallbackState = ImmutableMap({ brandColor: '#0482d8', // Azure }); -const updateFromAdmin = (state, configs) => { +const updateFromAdmin = (state: ImmutableMap, configs: ImmutableList>) => { try { - return ConfigDB.find(configs, ':pleroma', ':frontend_configurations') + return ConfigDB.find(configs, ':pleroma', ':frontend_configurations')! .get('value') - .find(value => value.getIn(['tuple', 0]) === ':soapbox_fe') + .find((value: ImmutableMap) => value.getIn(['tuple', 0]) === ':soapbox_fe') .getIn(['tuple', 1]); } catch { return state; } }; -const preloadImport = (state, action) => { +const preloadImport = (state: ImmutableMap, action: Record) => { const path = '/api/pleroma/frontend_configurations'; const feData = action.data[path]; @@ -40,29 +40,29 @@ const preloadImport = (state, action) => { } }; -const persistSoapboxConfig = (soapboxConfig, host) => { +const persistSoapboxConfig = (soapboxConfig: ImmutableMap, host: string) => { if (host) { KVStore.setItem(`soapbox_config:${host}`, soapboxConfig.toJS()).catch(console.error); } }; -const importSoapboxConfig = (state, soapboxConfig, host) => { +const importSoapboxConfig = (state: ImmutableMap, soapboxConfig: ImmutableMap, host: string) => { persistSoapboxConfig(soapboxConfig, host); return soapboxConfig; }; -export default function soapbox(state = initialState, action) { +export default function soapbox(state = initialState, action: Record) { switch (action.type) { case PLEROMA_PRELOAD_IMPORT: return preloadImport(state, action); case SOAPBOX_CONFIG_REMEMBER_SUCCESS: return fromJS(action.soapboxConfig); case SOAPBOX_CONFIG_REQUEST_SUCCESS: - return importSoapboxConfig(state, fromJS(action.soapboxConfig), action.host); + return importSoapboxConfig(state, fromJS(action.soapboxConfig) as ImmutableMap, action.host); case SOAPBOX_CONFIG_REQUEST_FAIL: return fallbackState.mergeDeep(state); case ADMIN_CONFIG_UPDATE_SUCCESS: - return updateFromAdmin(state, fromJS(action.configs)); + return updateFromAdmin(state, fromJS(action.configs) as ImmutableList>); default: return state; } diff --git a/app/soapbox/selectors/index.ts b/app/soapbox/selectors/index.ts index 047faefa6..063537330 100644 --- a/app/soapbox/selectors/index.ts +++ b/app/soapbox/selectors/index.ts @@ -269,16 +269,16 @@ export const makeGetReport = () => { }; const getAuthUserIds = createSelector([ - (state: RootState) => state.auth.get('users', ImmutableMap()), + (state: RootState) => state.auth.users, ], authUsers => { - return authUsers.reduce((ids: ImmutableOrderedSet, authUser: ImmutableMap) => { + return authUsers.reduce((ids: ImmutableOrderedSet, authUser) => { try { const id = authUser.get('id'); return validId(id) ? ids.add(id) : ids; } catch { return ids; } - }, ImmutableOrderedSet()); + }, ImmutableOrderedSet()); }); export const makeGetOtherAccounts = () => { diff --git a/app/soapbox/utils/auth.ts b/app/soapbox/utils/auth.ts index dcb84104e..d826c752a 100644 --- a/app/soapbox/utils/auth.ts +++ b/app/soapbox/utils/auth.ts @@ -4,7 +4,8 @@ import type { RootState } from 'soapbox/store'; export const validId = (id: any) => typeof id === 'string' && id !== 'null' && id !== 'undefined'; -export const isURL = (url: string) => { +export const isURL = (url?: string | null) => { + if (typeof url !== 'string') return false; try { new URL(url); return true; @@ -30,11 +31,11 @@ export const isLoggedIn = (getState: () => RootState) => { return validId(getState().me); }; -export const getAppToken = (state: RootState) => state.auth.getIn(['app', 'access_token']) as string; +export const getAppToken = (state: RootState) => state.auth.app.access_token as string; export const getUserToken = (state: RootState, accountId?: string | false | null) => { - const accountUrl = state.accounts.getIn([accountId, 'url']); - return state.auth.getIn(['users', accountUrl, 'access_token']) as string; + const accountUrl = state.accounts.getIn([accountId, 'url']) as string; + return state.auth.users.get(accountUrl)?.access_token as string; }; export const getAccessToken = (state: RootState) => { @@ -43,24 +44,23 @@ export const getAccessToken = (state: RootState) => { }; export const getAuthUserId = (state: RootState) => { - const me = state.auth.get('me'); + const me = state.auth.me; return ImmutableList([ - state.auth.getIn(['users', me, 'id']), + state.auth.users.get(me!)?.id, me, ]).find(validId); }; export const getAuthUserUrl = (state: RootState) => { - const me = state.auth.get('me'); + const me = state.auth.me; return ImmutableList([ - state.auth.getIn(['users', me, 'url']), + state.auth.users.get(me!)?.url, me, - ]).find(isURL); + ].filter(url => url)).find(isURL); }; /** Get the VAPID public key. */ -export const getVapidKey = (state: RootState) => { - return state.auth.getIn(['app', 'vapid_key']) || state.instance.getIn(['pleroma', 'vapid_public_key']); -}; +export const getVapidKey = (state: RootState) => + (state.auth.app.vapid_key || state.instance.pleroma.get('vapid_public_key')) as string;