diff --git a/CHANGELOG.md b/CHANGELOG.md index e9b420dd4..1f7e2e580 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - ServiceWorker: don't serve favicon, robots.txt, and others from ServiceWorker. - Datepicker: correctly default to the current year. - Scheduled posts: fix page crashing on deleting a scheduled post. +- Events: don't crash when searching for a location. ## [3.0.0] - 2022-12-25 diff --git a/app/soapbox/actions/__tests__/me.test.ts b/app/soapbox/actions/__tests__/me.test.ts index c75d128f0..d4dc1d31f 100644 --- a/app/soapbox/actions/__tests__/me.test.ts +++ b/app/soapbox/actions/__tests__/me.test.ts @@ -2,7 +2,9 @@ import { Map as ImmutableMap } from 'immutable'; import { __stub } from 'soapbox/api'; import { mockStore, rootState } from 'soapbox/jest/test-helpers'; +import { AccountRecord } from 'soapbox/normalizers'; +import { AuthUserRecord, ReducerRecord } from '../../reducers/auth'; import { fetchMe, patchMe, } from '../me'; @@ -38,18 +40,18 @@ describe('fetchMe()', () => { beforeEach(() => { const state = rootState - .set('auth', ImmutableMap({ + .set('auth', ReducerRecord({ me: accountUrl, users: ImmutableMap({ - [accountUrl]: ImmutableMap({ + [accountUrl]: AuthUserRecord({ 'access_token': token, }), }), })) .set('accounts', ImmutableMap({ - [accountUrl]: { + [accountUrl]: AccountRecord({ url: accountUrl, - }, + }), }) as any); store = mockStore(state); }); @@ -112,4 +114,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..436c87ff2 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', @@ -211,7 +190,7 @@ export const logIn = (username: string, password: string) => (dispatch: AppDispatch) => dispatch(getAuthApp()).then(() => { return dispatch(createUserToken(normalizeUsername(username), password)); }).catch((error: AxiosError) => { - if ((error.response?.data as any).error === 'mfa_required') { + if ((error.response?.data as any)?.error === 'mfa_required') { // If MFA is required, throw the error and handle it in the component. throw error; } else { @@ -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 2a221d0d1..c7fcb6230 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/__tests__/avatar-overlay.test.tsx b/app/soapbox/components/__tests__/avatar-overlay.test.tsx deleted file mode 100644 index 4e83dd071..000000000 --- a/app/soapbox/components/__tests__/avatar-overlay.test.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React from 'react'; - -import { normalizeAccount } from 'soapbox/normalizers'; - -import { render, screen } from '../../jest/test-helpers'; -import AvatarOverlay from '../avatar-overlay'; - -import type { ReducerAccount } from 'soapbox/reducers/accounts'; - -describe(' { - const account = normalizeAccount({ - username: 'alice', - acct: 'alice', - display_name: 'Alice', - avatar: '/animated/alice.gif', - avatar_static: '/static/alice.jpg', - }) as ReducerAccount; - - const friend = normalizeAccount({ - username: 'eve', - acct: 'eve@blackhat.lair', - display_name: 'Evelyn', - avatar: '/animated/eve.gif', - avatar_static: '/static/eve.jpg', - }) as ReducerAccount; - - it('renders a overlay avatar', () => { - render(); - expect(screen.queryAllByRole('img')).toHaveLength(2); - }); -}); diff --git a/app/soapbox/components/__tests__/avatar.test.tsx b/app/soapbox/components/__tests__/avatar.test.tsx deleted file mode 100644 index 56f592925..000000000 --- a/app/soapbox/components/__tests__/avatar.test.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import React from 'react'; - -import { normalizeAccount } from 'soapbox/normalizers'; - -import { render, screen } from '../../jest/test-helpers'; -import Avatar from '../avatar'; - -import type { ReducerAccount } from 'soapbox/reducers/accounts'; - -describe('', () => { - const account = normalizeAccount({ - username: 'alice', - acct: 'alice', - display_name: 'Alice', - avatar: '/animated/alice.gif', - avatar_static: '/static/alice.jpg', - }) as ReducerAccount; - - const size = 100; - - // describe('Autoplay', () => { - // it('renders an animated avatar', () => { - // render(); - - // expect(screen.getByRole('img').getAttribute('src')).toBe(account.get('avatar')); - // }); - // }); - - describe('Still', () => { - it('renders a still avatar', () => { - render(); - - expect(screen.getByRole('img').getAttribute('src')).toBe(account.get('avatar')); - }); - }); - - // TODO add autoplay test if possible -}); diff --git a/app/soapbox/components/account.tsx b/app/soapbox/components/account.tsx index 322a98119..546d5f3f4 100644 --- a/app/soapbox/components/account.tsx +++ b/app/soapbox/components/account.tsx @@ -46,7 +46,7 @@ interface IProfilePopper { const ProfilePopper: React.FC = ({ condition, wrapper, children }): any => condition ? wrapper(children) : children; -interface IAccount { +export interface IAccount { account: AccountEntity, action?: React.ReactElement, actionAlignment?: 'center' | 'top', diff --git a/app/soapbox/components/autosuggest-location.tsx b/app/soapbox/components/autosuggest-location.tsx index 6b6eee434..fab4493c9 100644 --- a/app/soapbox/components/autosuggest-location.tsx +++ b/app/soapbox/components/autosuggest-location.tsx @@ -32,7 +32,7 @@ const AutosuggestLocation: React.FC = ({ id }) => { {location.description} - {[location.street, location.locality, location.country].filter(val => val.trim()).join(' · ')} + {[location.street, location.locality, location.country].filter(val => val?.trim()).join(' · ')} ); diff --git a/app/soapbox/components/avatar-overlay.tsx b/app/soapbox/components/avatar-overlay.tsx deleted file mode 100644 index a463b35ce..000000000 --- a/app/soapbox/components/avatar-overlay.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; - -import StillImage from 'soapbox/components/still-image'; - -import type { Account as AccountEntity } from 'soapbox/types/entities'; - -interface IAvatarOverlay { - account: AccountEntity, - friend: AccountEntity, -} - -const AvatarOverlay: React.FC = ({ account, friend }) => ( -
- - -
-); - -export default AvatarOverlay; diff --git a/app/soapbox/components/avatar.tsx b/app/soapbox/components/avatar.tsx deleted file mode 100644 index 4bc5c0774..000000000 --- a/app/soapbox/components/avatar.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import classNames from 'clsx'; -import React from 'react'; - -import StillImage from 'soapbox/components/still-image'; - -import type { Account } from 'soapbox/types/entities'; - -interface IAvatar { - account?: Account | null, - size?: number, - className?: string, -} - -/** - * Legacy avatar component. - * @see soapbox/components/ui/avatar/avatar.tsx - * @deprecated - */ -const Avatar: React.FC = ({ account, size, className }) => { - if (!account) return null; - - // : TODO : remove inline and change all avatars to be sized using css - const style: React.CSSProperties = !size ? {} : { - width: `${size}px`, - height: `${size}px`, - }; - - return ( - - ); -}; - -export default Avatar; diff --git a/app/soapbox/components/icon-button.js b/app/soapbox/components/icon-button.js deleted file mode 100644 index bdfe157a6..000000000 --- a/app/soapbox/components/icon-button.js +++ /dev/null @@ -1,188 +0,0 @@ -import classNames from 'clsx'; -import PropTypes from 'prop-types'; -import React from 'react'; -import spring from 'react-motion/lib/spring'; - -import Icon from 'soapbox/components/icon'; -import emojify from 'soapbox/features/emoji/emoji'; - -import Motion from '../features/ui/util/optional-motion'; - -export default class IconButton extends React.PureComponent { - - static propTypes = { - className: PropTypes.string, - iconClassName: PropTypes.string, - title: PropTypes.string.isRequired, - icon: PropTypes.string, - src: PropTypes.string, - onClick: PropTypes.func, - onMouseDown: PropTypes.func, - onKeyUp: PropTypes.func, - onKeyDown: PropTypes.func, - onKeyPress: PropTypes.func, - onMouseEnter: PropTypes.func, - onMouseLeave: PropTypes.func, - size: PropTypes.number, - active: PropTypes.bool, - pressed: PropTypes.bool, - expanded: PropTypes.bool, - style: PropTypes.object, - activeStyle: PropTypes.object, - disabled: PropTypes.bool, - inverted: PropTypes.bool, - animate: PropTypes.bool, - overlay: PropTypes.bool, - tabIndex: PropTypes.string, - text: PropTypes.string, - emoji: PropTypes.string, - type: PropTypes.string, - }; - - static defaultProps = { - size: 18, - active: false, - disabled: false, - animate: false, - overlay: false, - tabIndex: '0', - onKeyUp: () => {}, - onKeyDown: () => {}, - onClick: () => {}, - onMouseEnter: () => {}, - onMouseLeave: () => {}, - type: 'button', - }; - - handleClick = (e) => { - e.preventDefault(); - - if (!this.props.disabled) { - this.props.onClick(e); - } - } - - handleMouseDown = (e) => { - if (!this.props.disabled && this.props.onMouseDown) { - this.props.onMouseDown(e); - } - } - - handleKeyDown = (e) => { - if (!this.props.disabled && this.props.onKeyDown) { - this.props.onKeyDown(e); - } - } - - handleKeyUp = (e) => { - if (!this.props.disabled && this.props.onKeyUp) { - this.props.onKeyUp(e); - } - } - - handleKeyPress = (e) => { - if (this.props.onKeyPress && !this.props.disabled) { - this.props.onKeyPress(e); - } - } - - render() { - const style = { - fontSize: `${this.props.size}px`, - width: `${this.props.size * 1.28571429}px`, - height: `${this.props.size * 1.28571429}px`, - lineHeight: `${this.props.size}px`, - ...this.props.style, - ...(this.props.active ? this.props.activeStyle : {}), - }; - - const { - active, - animate, - className, - iconClassName, - disabled, - expanded, - icon, - src, - inverted, - overlay, - pressed, - tabIndex, - title, - text, - emoji, - type, - } = this.props; - - const classes = classNames(className, 'icon-button', { - active, - disabled, - inverted, - overlayed: overlay, - }); - - if (!animate) { - // Perf optimization: avoid unnecessary components unless - // we actually need to animate. - return ( - - ); - } - - return ( - - {({ rotate }) => ( - - )} - - ); - } - -} diff --git a/app/soapbox/components/icon-button.tsx b/app/soapbox/components/icon-button.tsx new file mode 100644 index 000000000..71f110995 --- /dev/null +++ b/app/soapbox/components/icon-button.tsx @@ -0,0 +1,100 @@ +import classNames from 'clsx'; +import React from 'react'; + +import Icon from 'soapbox/components/icon'; + +interface IIconButton extends Pick, 'className' | 'disabled' | 'onClick' | 'onKeyDown' | 'onKeyPress' | 'onKeyUp' | 'onMouseDown' | 'onMouseEnter' | 'onMouseLeave' | 'tabIndex' | 'title'> { + active?: boolean + expanded?: boolean + iconClassName?: string + pressed?: boolean + size?: number + src: string + text?: React.ReactNode +} + +const IconButton: React.FC = ({ + active, + className, + disabled, + expanded, + iconClassName, + onClick, + onKeyDown, + onKeyUp, + onKeyPress, + onMouseDown, + onMouseEnter, + onMouseLeave, + pressed, + size = 18, + src, + tabIndex = 0, + text, + title, +}) => { + + const handleClick: React.MouseEventHandler = (e) => { + e.preventDefault(); + + if (!disabled && onClick) { + onClick(e); + } + }; + + const handleMouseDown: React.MouseEventHandler = (e) => { + if (!disabled && onMouseDown) { + onMouseDown(e); + } + }; + + const handleKeyDown: React.KeyboardEventHandler = (e) => { + if (!disabled && onKeyDown) { + onKeyDown(e); + } + }; + + const handleKeyUp: React.KeyboardEventHandler = (e) => { + if (!disabled && onKeyUp) { + onKeyUp(e); + } + }; + + const handleKeyPress: React.KeyboardEventHandler = (e) => { + if (onKeyPress && !disabled) { + onKeyPress(e); + } + }; + + const classes = classNames(className, 'icon-button', { + active, + disabled, + }); + + return ( + + ); +}; + +export default IconButton; diff --git a/app/soapbox/components/status.tsx b/app/soapbox/components/status.tsx index 66710bf9c..baf22e30e 100644 --- a/app/soapbox/components/status.tsx +++ b/app/soapbox/components/status.tsx @@ -23,7 +23,6 @@ import StatusReplyMentions from './status-reply-mentions'; import SensitiveContentOverlay from './statuses/sensitive-content-overlay'; import { Card, HStack, Stack, Text } from './ui'; -import type { Map as ImmutableMap } from 'immutable'; import type { Account as AccountEntity, Status as StatusEntity, @@ -45,7 +44,6 @@ export interface IStatus { unread?: boolean, onMoveUp?: (statusId: string, featured?: boolean) => void, onMoveDown?: (statusId: string, featured?: boolean) => void, - group?: ImmutableMap, focusable?: boolean, featured?: boolean, hideActionBar?: boolean, diff --git a/app/soapbox/containers/account-container.js b/app/soapbox/containers/account-container.js deleted file mode 100644 index 3ee72a60e..000000000 --- a/app/soapbox/containers/account-container.js +++ /dev/null @@ -1,77 +0,0 @@ -import React from 'react'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; - -import { - followAccount, - unfollowAccount, - blockAccount, - unblockAccount, - muteAccount, - unmuteAccount, -} from '../actions/accounts'; -import { openModal } from '../actions/modals'; -import { initMuteModal } from '../actions/mutes'; -import { getSettings } from '../actions/settings'; -import Account from '../components/account'; -import { makeGetAccount } from '../selectors'; - -const messages = defineMessages({ - unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' }, -}); - -const makeMapStateToProps = () => { - const getAccount = makeGetAccount(); - - const mapStateToProps = (state, props) => ({ - account: getAccount(state, props.id), - }); - - return mapStateToProps; -}; - -const mapDispatchToProps = (dispatch, { intl }) => ({ - - onFollow(account) { - dispatch((_, getState) => { - const unfollowModal = getSettings(getState()).get('unfollowModal'); - if (account.relationship?.following || account.relationship?.requested) { - if (unfollowModal) { - dispatch(openModal('CONFIRM', { - icon: require('@tabler/icons/minus.svg'), - heading: @{account.get('acct')} }} />, - message: @{account.get('acct')} }} />, - confirm: intl.formatMessage(messages.unfollowConfirm), - onConfirm: () => dispatch(unfollowAccount(account.get('id'))), - })); - } else { - dispatch(unfollowAccount(account.get('id'))); - } - } else { - dispatch(followAccount(account.get('id'))); - } - }); - }, - - onBlock(account) { - if (account.relationship?.blocking) { - dispatch(unblockAccount(account.get('id'))); - } else { - dispatch(blockAccount(account.get('id'))); - } - }, - - onMute(account) { - if (account.relationship?.muting) { - dispatch(unmuteAccount(account.get('id'))); - } else { - dispatch(initMuteModal(account)); - } - }, - - onMuteNotifications(account, notifications) { - dispatch(muteAccount(account.get('id'), notifications)); - }, -}); - -export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Account)); diff --git a/app/soapbox/containers/account-container.tsx b/app/soapbox/containers/account-container.tsx new file mode 100644 index 000000000..54c6db64e --- /dev/null +++ b/app/soapbox/containers/account-container.tsx @@ -0,0 +1,21 @@ +import React, { useCallback } from 'react'; + +import { useAppSelector } from 'soapbox/hooks'; + +import Account, { IAccount } from '../components/account'; +import { makeGetAccount } from '../selectors'; + +interface IAccountContainer extends Omit { + id: string +} + +const AccountContainer: React.FC = ({ id, ...props }) => { + const getAccount = useCallback(makeGetAccount(), []); + const account = useAppSelector(state => getAccount(state, id)); + + return ( + + ); +}; + +export default AccountContainer; diff --git a/app/soapbox/features/admin/components/report.tsx b/app/soapbox/features/admin/components/report.tsx index c316e2c45..210288fdc 100644 --- a/app/soapbox/features/admin/components/report.tsx +++ b/app/soapbox/features/admin/components/report.tsx @@ -4,9 +4,8 @@ import { Link } from 'react-router-dom'; import { closeReports } from 'soapbox/actions/admin'; import { deactivateUserModal, deleteUserModal } from 'soapbox/actions/moderation'; -import Avatar from 'soapbox/components/avatar'; import HoverRefWrapper from 'soapbox/components/hover-ref-wrapper'; -import { Accordion, Button, Stack, HStack, Text } from 'soapbox/components/ui'; +import { Accordion, Avatar, Button, Stack, HStack, Text } from 'soapbox/components/ui'; import DropdownMenu from 'soapbox/containers/dropdown-menu-container'; import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; import { makeGetReport } from 'soapbox/selectors'; @@ -86,7 +85,7 @@ const Report: React.FC = ({ id }) => { - + 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..360c05a47 100644 --- a/app/soapbox/features/auth-token-list/index.tsx +++ b/app/soapbox/features/auth-token-list/index.tsx @@ -7,8 +7,6 @@ import { Button, Card, CardBody, CardHeader, CardTitle, Column, Spinner, Stack, import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; import { Token } from 'soapbox/reducers/security'; -import type { Map as ImmutableMap } from 'immutable'; - const messages = defineMessages({ header: { id: 'security.headers.tokens', defaultMessage: 'Sessions' }, revoke: { id: 'security.tokens.revoke', defaultMessage: 'Revoke' }, @@ -75,9 +73,9 @@ 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'); + return currentToken?.id; }); useEffect(() => { diff --git a/app/soapbox/features/birthdays/account.tsx b/app/soapbox/features/birthdays/account.tsx index 805538fd5..79a70fc42 100644 --- a/app/soapbox/features/birthdays/account.tsx +++ b/app/soapbox/features/birthdays/account.tsx @@ -1,10 +1,9 @@ import React, { useCallback } from 'react'; import { defineMessages, useIntl } from 'react-intl'; -import { Link } from 'react-router-dom'; -import Avatar from 'soapbox/components/avatar'; -import DisplayName from 'soapbox/components/display-name'; +import AccountComponent from 'soapbox/components/account'; import Icon from 'soapbox/components/icon'; +import { HStack } from 'soapbox/components/ui'; import { useAppSelector } from 'soapbox/hooks'; import { makeGetAccount } from 'soapbox/selectors'; @@ -22,12 +21,6 @@ const Account: React.FC = ({ accountId }) => { const account = useAppSelector((state) => getAccount(state, accountId)); - // useEffect(() => { - // if (accountId && !account) { - // fetchAccount(accountId); - // } - // }, [accountId]); - if (!account) return null; const birthday = account.birthday; @@ -36,26 +29,20 @@ const Account: React.FC = ({ accountId }) => { const formattedBirthday = intl.formatDate(birthday, { day: 'numeric', month: 'short', year: 'numeric' }); return ( -
-
- -
-
- - -
- -
- - {formattedBirthday} -
+ +
+
-
+
+ + {formattedBirthday} +
+ ); }; diff --git a/app/soapbox/features/chats/components/chat-page/components/chat-page-new.tsx b/app/soapbox/features/chats/components/chat-page/components/chat-page-new.tsx index c362a4159..db2f46a22 100644 --- a/app/soapbox/features/chats/components/chat-page/components/chat-page-new.tsx +++ b/app/soapbox/features/chats/components/chat-page/components/chat-page-new.tsx @@ -1,15 +1,21 @@ import React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; import { useHistory } from 'react-router-dom'; import { CardTitle, HStack, IconButton, Stack } from 'soapbox/components/ui'; import ChatSearch from '../../chat-search/chat-search'; +const messages = defineMessages({ + title: { id: 'chat.new_message.title', defaultMessage: 'New Message' }, +}); + interface IChatPageNew { } /** New message form to create a chat. */ const ChatPageNew: React.FC = () => { + const intl = useIntl(); const history = useHistory(); return ( @@ -22,7 +28,7 @@ const ChatPageNew: React.FC = () => { onClick={() => history.push('/chats')} /> - + @@ -31,4 +37,4 @@ const ChatPageNew: React.FC = () => { ); }; -export default ChatPageNew; \ No newline at end of file +export default ChatPageNew; diff --git a/app/soapbox/features/chats/components/chat-search/chat-search.tsx b/app/soapbox/features/chats/components/chat-search/chat-search.tsx index c6b9536a5..c10dc61c5 100644 --- a/app/soapbox/features/chats/components/chat-search/chat-search.tsx +++ b/app/soapbox/features/chats/components/chat-search/chat-search.tsx @@ -1,6 +1,7 @@ import { useMutation } from '@tanstack/react-query'; import { AxiosError } from 'axios'; import React, { useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; import { useHistory } from 'react-router-dom'; import { Icon, Input, Stack } from 'soapbox/components/ui'; @@ -17,11 +18,16 @@ import Blankslate from './blankslate'; import EmptyResultsBlankslate from './empty-results-blankslate'; import Results from './results'; +const messages = defineMessages({ + placeholder: { id: 'chat_search.placeholder', defaultMessage: 'Type a name' }, +}); + interface IChatSearch { isMainPage?: boolean } const ChatSearch = (props: IChatSearch) => { + const intl = useIntl(); const { isMainPage = false } = props; const debounce = useDebounce; @@ -88,7 +94,7 @@ const ChatSearch = (props: IChatSearch) => { data-testid='search' type='text' autoFocus - placeholder='Type a name' + placeholder={intl.formatMessage(messages.placeholder)} value={value || ''} onChange={(event) => setValue(event.target.value)} outerClassName='mt-0' @@ -112,4 +118,4 @@ const ChatSearch = (props: IChatSearch) => { ); }; -export default ChatSearch; \ No newline at end of file +export default ChatSearch; 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/compose/components/text-icon-button.tsx b/app/soapbox/features/compose/components/text-icon-button.tsx deleted file mode 100644 index fd49d4ed0..000000000 --- a/app/soapbox/features/compose/components/text-icon-button.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import React from 'react'; - -interface ITextIconButton { - label: string, - title: string, - active: boolean, - onClick: () => void, - ariaControls: string, - unavailable: boolean, -} - -const TextIconButton: React.FC = ({ - label, - title, - active, - ariaControls, - unavailable, - onClick, -}) => { - const handleClick: React.MouseEventHandler = (e) => { - e.preventDefault(); - onClick(); - }; - - if (unavailable) { - return null; - } - - return ( - - ); -}; - -export default TextIconButton; 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/follow-requests/components/account-authorize.tsx b/app/soapbox/features/follow-requests/components/account-authorize.tsx index 430a0d49f..b73d0a719 100644 --- a/app/soapbox/features/follow-requests/components/account-authorize.tsx +++ b/app/soapbox/features/follow-requests/components/account-authorize.tsx @@ -1,13 +1,10 @@ import React, { useCallback } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { useDispatch } from 'react-redux'; -import { Link } from 'react-router-dom'; import { authorizeFollowRequest, rejectFollowRequest } from 'soapbox/actions/accounts'; -import Avatar from 'soapbox/components/avatar'; -import DisplayName from 'soapbox/components/display-name'; -import IconButton from 'soapbox/components/icon-button'; -import { Text } from 'soapbox/components/ui'; +import Account from 'soapbox/components/account'; +import { Button, HStack } from 'soapbox/components/ui'; import { useAppSelector } from 'soapbox/hooks'; import { makeGetAccount } from 'soapbox/selectors'; @@ -38,24 +35,28 @@ const AccountAuthorize: React.FC = ({ id }) => { if (!account) return null; - const content = { __html: account.note_emojified }; - return ( -
-
- -
- - - - + +
+
- -
-
-
-
-
+ +