diff --git a/app/soapbox/features/aliases/components/search.js b/app/soapbox/features/aliases/components/search.js deleted file mode 100644 index 4311685e7..000000000 --- a/app/soapbox/features/aliases/components/search.js +++ /dev/null @@ -1,84 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; -import { defineMessages, injectIntl } from 'react-intl'; -import { connect } from 'react-redux'; - -import Icon from 'soapbox/components/icon'; -import { Button } from 'soapbox/components/ui'; - -import { fetchAliasesSuggestions, clearAliasesSuggestions, changeAliasesSuggestions } from '../../../actions/aliases'; - -const messages = defineMessages({ - search: { id: 'aliases.search', defaultMessage: 'Search your old account' }, - searchTitle: { id: 'tabs_bar.search', defaultMessage: 'Search' }, -}); - -const mapStateToProps = state => ({ - value: state.getIn(['aliases', 'suggestions', 'value']), -}); - -const mapDispatchToProps = dispatch => ({ - onSubmit: value => dispatch(fetchAliasesSuggestions(value)), - onClear: () => dispatch(clearAliasesSuggestions()), - onChange: value => dispatch(changeAliasesSuggestions(value)), -}); - -export default @connect(mapStateToProps, mapDispatchToProps) -@injectIntl -class Search extends React.PureComponent { - - static propTypes = { - intl: PropTypes.object.isRequired, - value: PropTypes.string.isRequired, - onChange: PropTypes.func.isRequired, - onSubmit: PropTypes.func.isRequired, - onClear: PropTypes.func.isRequired, - }; - - handleChange = e => { - this.props.onChange(e.target.value); - } - - handleKeyUp = e => { - if (e.keyCode === 13) { - this.props.onSubmit(this.props.value); - } - } - - handleSubmit = () => { - this.props.onSubmit(this.props.value); - } - - handleClear = () => { - this.props.onClear(); - } - - render() { - const { value, intl } = this.props; - const hasValue = value.length > 0; - - return ( -
- - -
- -
- -
- ); - } - -} diff --git a/app/soapbox/features/aliases/components/search.tsx b/app/soapbox/features/aliases/components/search.tsx new file mode 100644 index 000000000..516a38884 --- /dev/null +++ b/app/soapbox/features/aliases/components/search.tsx @@ -0,0 +1,65 @@ +import classNames from 'classnames'; +import React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { useDispatch } from 'react-redux'; + +import { fetchAliasesSuggestions, clearAliasesSuggestions, changeAliasesSuggestions } from 'soapbox/actions/aliases'; +import Icon from 'soapbox/components/icon'; +import { Button } from 'soapbox/components/ui'; +import { useAppSelector } from 'soapbox/hooks'; + +const messages = defineMessages({ + search: { id: 'aliases.search', defaultMessage: 'Search your old account' }, + searchTitle: { id: 'tabs_bar.search', defaultMessage: 'Search' }, +}); + +const Search: React.FC = () => { + const dispatch = useDispatch(); + const intl = useIntl(); + + const value = useAppSelector(state => state.aliases.getIn(['suggestions', 'value'])) as string; + + const handleChange = (e: React.ChangeEvent) => { + dispatch(changeAliasesSuggestions(e.target.value)); + }; + + const handleKeyUp = (e: React.KeyboardEvent) => { + if (e.keyCode === 13) { + dispatch(fetchAliasesSuggestions(value)); + } + }; + + const handleSubmit = () => { + dispatch(fetchAliasesSuggestions(value)); + }; + + const handleClear = () => { + dispatch(clearAliasesSuggestions()); + }; + + const hasValue = value.length > 0; + + return ( +
+ + +
+ +
+ +
+ ); +}; + +export default Search; diff --git a/app/soapbox/features/follow_recommendations/components/account.js b/app/soapbox/features/follow_recommendations/components/account.js deleted file mode 100644 index c15fc6bcc..000000000 --- a/app/soapbox/features/follow_recommendations/components/account.js +++ /dev/null @@ -1,60 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { connect } from 'react-redux'; - -import Avatar from 'soapbox/components/avatar'; -import DisplayName from 'soapbox/components/display_name'; -import Permalink from 'soapbox/components/permalink'; -import ActionButton from 'soapbox/features/ui/components/action_button'; -import { makeGetAccount } from 'soapbox/selectors'; - -const makeMapStateToProps = () => { - const getAccount = makeGetAccount(); - - const mapStateToProps = (state, props) => ({ - account: getAccount(state, props.id), - }); - - return mapStateToProps; -}; - -const getFirstSentence = str => { - const arr = str.split(/(([.?!]+\s)|[.。?!\n•])/); - - return arr[0]; -}; - -export default @connect(makeMapStateToProps) -class Account extends ImmutablePureComponent { - - static propTypes = { - account: ImmutablePropTypes.record.isRequired, - intl: PropTypes.object.isRequired, - dispatch: PropTypes.func.isRequired, - }; - - render() { - const { account } = this.props; - - return ( -
-
- -
- - - -
{getFirstSentence(account.get('note_plain'))}
-
- -
- -
-
-
- ); - } - -} diff --git a/app/soapbox/features/follow_recommendations/components/account.tsx b/app/soapbox/features/follow_recommendations/components/account.tsx new file mode 100644 index 000000000..8ba56b497 --- /dev/null +++ b/app/soapbox/features/follow_recommendations/components/account.tsx @@ -0,0 +1,46 @@ +import React from 'react'; + +import Avatar from 'soapbox/components/avatar'; +import DisplayName from 'soapbox/components/display_name'; +import Permalink from 'soapbox/components/permalink'; +import ActionButton from 'soapbox/features/ui/components/action_button'; +import { useAppSelector } from 'soapbox/hooks'; +import { makeGetAccount } from 'soapbox/selectors'; + +const getAccount = makeGetAccount(); + +const getFirstSentence = (str: string) => { + const arr = str.split(/(([.?!]+\s)|[.。?!\n•])/); + + return arr[0]; +}; + +interface IAccount { + id: string, +} + +const Account: React.FC = ({ id }) => { + const account = useAppSelector((state) => getAccount(state, id)); + + if (!account) return null; + + return ( +
+
+ +
+ + + +
{getFirstSentence(account.get('note_plain'))}
+
+ +
+ +
+
+
+ ); +}; + +export default Account; diff --git a/app/soapbox/features/follow_recommendations/components/follow_recommendations_container.js b/app/soapbox/features/follow_recommendations/components/follow_recommendations_container.js deleted file mode 100644 index 59c4f382b..000000000 --- a/app/soapbox/features/follow_recommendations/components/follow_recommendations_container.js +++ /dev/null @@ -1,39 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { FormattedMessage } from 'react-intl'; - -import { Button } from 'soapbox/components/ui'; - -import FollowRecommendationsList from './follow_recommendations_list'; - -export default class FollowRecommendationsContainer extends React.Component { - - static propTypes = { - onDone: PropTypes.func.isRequired, - } - - handleDone = () => { - this.props.onDone(); - } - - render() { - return ( -
-
-

-

-

-
- - - -
- -
-
- ); - } - -} diff --git a/app/soapbox/features/follow_recommendations/components/follow_recommendations_container.tsx b/app/soapbox/features/follow_recommendations/components/follow_recommendations_container.tsx new file mode 100644 index 000000000..c3f198f94 --- /dev/null +++ b/app/soapbox/features/follow_recommendations/components/follow_recommendations_container.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; + +import { Button } from 'soapbox/components/ui'; + +import FollowRecommendationsList from './follow_recommendations_list'; + +interface IFollowRecommendationsContainer { + onDone: () => void, +} + +const FollowRecommendationsContainer: React.FC = ({ onDone }) => ( +
+
+

+

+

+
+ + + +
+ +
+
+); + +export default FollowRecommendationsContainer; diff --git a/app/soapbox/features/follow_recommendations/components/follow_recommendations_list.js b/app/soapbox/features/follow_recommendations/components/follow_recommendations_list.js deleted file mode 100644 index 7fc06319b..000000000 --- a/app/soapbox/features/follow_recommendations/components/follow_recommendations_list.js +++ /dev/null @@ -1,62 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; - -import { fetchSuggestions } from 'soapbox/actions/suggestions'; -import { Spinner } from 'soapbox/components/ui'; - -import Account from './account'; - -const mapStateToProps = state => ({ - suggestions: state.getIn(['suggestions', 'items']), - isLoading: state.getIn(['suggestions', 'isLoading']), -}); - -export default @connect(mapStateToProps) -class FollowRecommendationsList extends ImmutablePureComponent { - - static propTypes = { - dispatch: PropTypes.func.isRequired, - suggestions: ImmutablePropTypes.list, - isLoading: PropTypes.bool, - }; - - componentDidMount() { - const { dispatch, suggestions } = this.props; - - // Don't re-fetch if we're e.g. navigating backwards to this page, - // since we don't want followed accounts to disappear from the list - if (suggestions.size === 0) { - dispatch(fetchSuggestions(true)); - } - } - - render() { - const { suggestions, isLoading } = this.props; - - if (isLoading) { - return ( -
- -
- ); - } - - return ( -
- {suggestions.size > 0 ? suggestions.map(suggestion => ( - - )) : ( -
- -
- )} -
- ); - - } - -} diff --git a/app/soapbox/features/follow_recommendations/components/follow_recommendations_list.tsx b/app/soapbox/features/follow_recommendations/components/follow_recommendations_list.tsx new file mode 100644 index 000000000..4ca838072 --- /dev/null +++ b/app/soapbox/features/follow_recommendations/components/follow_recommendations_list.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { useEffect } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { useDispatch } from 'react-redux'; + +import { fetchSuggestions } from 'soapbox/actions/suggestions'; +import { Spinner } from 'soapbox/components/ui'; +import { useAppSelector } from 'soapbox/hooks'; + +import Account from './account'; + +const FollowRecommendationsList: React.FC = () => { + const dispatch = useDispatch(); + + const suggestions = useAppSelector((state) => state.suggestions.get('items')); + const isLoading = useAppSelector((state) => state.suggestions.get('isLoading')); + + useEffect(() => { + if (suggestions.size === 0) { + dispatch(fetchSuggestions()); + } + }, []); + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ {suggestions.size > 0 ? suggestions.map((suggestion: { account: string }) => ( + + )) : ( +
+ +
+ )} +
+ ); +}; + +export default FollowRecommendationsList; diff --git a/app/soapbox/features/follow_recommendations/index.js b/app/soapbox/features/follow_recommendations/index.js deleted file mode 100644 index 2e9e3bcc3..000000000 --- a/app/soapbox/features/follow_recommendations/index.js +++ /dev/null @@ -1,28 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { withRouter } from 'react-router-dom'; - -import Column from 'soapbox/features/ui/components/column'; - -import FollowRecommendationsContainer from './components/follow_recommendations_container'; - -export default @withRouter -class FollowRecommendations extends React.Component { - - static propTypes = { - history: PropTypes.object.isRequired, - }; - - onDone = () => { - this.props.history.push('/'); - } - - render() { - return ( - - - - ); - } - -} diff --git a/app/soapbox/features/follow_recommendations/index.tsx b/app/soapbox/features/follow_recommendations/index.tsx new file mode 100644 index 000000000..444504532 --- /dev/null +++ b/app/soapbox/features/follow_recommendations/index.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { useHistory } from 'react-router-dom'; + +import Column from 'soapbox/features/ui/components/column'; + +import FollowRecommendationsContainer from './components/follow_recommendations_container'; + +const FollowRecommendations: React.FC = () => { + const history = useHistory(); + + const onDone = () => { + history.push('/'); + }; + + return ( + + + + ); +}; + +export default FollowRecommendations; diff --git a/app/soapbox/features/intentional_error/index.js b/app/soapbox/features/intentional_error/index.tsx similarity index 50% rename from app/soapbox/features/intentional_error/index.js rename to app/soapbox/features/intentional_error/index.tsx index b3d7fa0f7..01ae3a5c4 100644 --- a/app/soapbox/features/intentional_error/index.js +++ b/app/soapbox/features/intentional_error/index.tsx @@ -4,10 +4,8 @@ import React from 'react'; * IntentionalError: * For testing logging/monitoring & previewing ErrorBoundary design. */ -export default class IntentionalError extends React.Component { +const IntentionalError: React.FC = () => { + throw 'This error is intentional.'; +}; - render() { - throw 'This error is intentional.'; - } - -} +export default IntentionalError; diff --git a/app/soapbox/features/list_timeline/index.js b/app/soapbox/features/list_timeline/index.js deleted file mode 100644 index 7770aa3f2..000000000 --- a/app/soapbox/features/list_timeline/index.js +++ /dev/null @@ -1,158 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; -import { connect } from 'react-redux'; -import { withRouter } from 'react-router-dom'; - -import { Button, Spinner } from 'soapbox/components/ui'; -import Column from 'soapbox/features/ui/components/column'; - -import { fetchList, deleteList } from '../../actions/lists'; -import { openModal } from '../../actions/modals'; -import { connectListStream } from '../../actions/streaming'; -import { expandListTimeline } from '../../actions/timelines'; -import MissingIndicator from '../../components/missing_indicator'; -import StatusListContainer from '../ui/containers/status_list_container'; - -const messages = defineMessages({ - deleteHeading: { id: 'confirmations.delete_list.heading', defaultMessage: 'Delete list' }, - deleteMessage: { id: 'confirmations.delete_list.message', defaultMessage: 'Are you sure you want to permanently delete this list?' }, - deleteConfirm: { id: 'confirmations.delete_list.confirm', defaultMessage: 'Delete' }, -}); - -const mapStateToProps = (state, props) => ({ - list: state.getIn(['lists', props.params.id]), - // hasUnread: state.getIn(['timelines', `list:${props.params.id}`, 'unread']) > 0, -}); - -export default @connect(mapStateToProps) -@injectIntl -@withRouter -class ListTimeline extends React.PureComponent { - - static propTypes = { - params: PropTypes.object.isRequired, - dispatch: PropTypes.func.isRequired, - // hasUnread: PropTypes.bool, - list: PropTypes.oneOfType([ImmutablePropTypes.map, PropTypes.bool]), - intl: PropTypes.object.isRequired, - history: PropTypes.object, - }; - - componentDidMount() { - this.handleConnect(this.props.params.id); - } - - componentWillUnmount() { - this.handleDisconnect(); - } - - componentDidUpdate(prevProps) { - if (this.props.params.id !== prevProps.params.id) { - this.handleDisconnect(); - this.handleConnect(this.props.params.id); - } - } - - handleConnect(id) { - const { dispatch } = this.props; - - dispatch(fetchList(id)); - dispatch(expandListTimeline(id)); - - this.disconnect = dispatch(connectListStream(id)); - } - - handleDisconnect() { - if (this.disconnect) { - this.disconnect(); - this.disconnect = null; - } - } - - handleLoadMore = maxId => { - const { id } = this.props.params; - this.props.dispatch(expandListTimeline(id, { maxId })); - } - - handleEditClick = () => { - this.props.dispatch(openModal('LIST_EDITOR', { listId: this.props.params.id })); - } - - handleDeleteClick = () => { - const { dispatch, intl } = this.props; - const { id } = this.props.params; - - dispatch(openModal('CONFIRM', { - icon: require('@tabler/icons/icons/trash.svg'), - heading: intl.formatMessage(messages.deleteHeading), - message: intl.formatMessage(messages.deleteMessage), - confirm: intl.formatMessage(messages.deleteConfirm), - onConfirm: () => { - dispatch(deleteList(id)); - this.props.history.push('/lists'); - }, - })); - } - - render() { - const { list } = this.props; - const { id } = this.props.params; - const title = list ? list.get('title') : id; - - if (typeof list === 'undefined') { - return ( - -
- -
-
- ); - } else if (list === false) { - return ( - - ); - } - - const emptyMessage = ( -
- -

- -
- ); - - return ( - - {/* -
- - - - -
- - - - - -
-
*/} - - -
- ); - } - -} diff --git a/app/soapbox/features/list_timeline/index.tsx b/app/soapbox/features/list_timeline/index.tsx new file mode 100644 index 000000000..2697c201c --- /dev/null +++ b/app/soapbox/features/list_timeline/index.tsx @@ -0,0 +1,125 @@ +import React from 'react'; +import { useEffect } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { useDispatch } from 'react-redux'; +import { useParams } from 'react-router-dom'; + +import { fetchList } from 'soapbox/actions/lists'; +import { openModal } from 'soapbox/actions/modals'; +import { connectListStream } from 'soapbox/actions/streaming'; +import { expandListTimeline } from 'soapbox/actions/timelines'; +import MissingIndicator from 'soapbox/components/missing_indicator'; +import { Button, Spinner } from 'soapbox/components/ui'; +import Column from 'soapbox/features/ui/components/column'; +import { useAppSelector } from 'soapbox/hooks'; + +import StatusListContainer from '../ui/containers/status_list_container'; + +// const messages = defineMessages({ +// deleteHeading: { id: 'confirmations.delete_list.heading', defaultMessage: 'Delete list' }, +// deleteMessage: { id: 'confirmations.delete_list.message', defaultMessage: 'Are you sure you want to permanently delete this list?' }, +// deleteConfirm: { id: 'confirmations.delete_list.confirm', defaultMessage: 'Delete' }, +// }); + +const ListTimeline: React.FC = () => { + const dispatch = useDispatch(); + const { id } = useParams<{ id: string }>(); + // const intl = useIntl(); + // const history = useHistory(); + + const list = useAppSelector((state) => state.lists.get(id)); + // const hasUnread = useAppSelector((state) => state.timelines.getIn([`list:${props.params.id}`, 'unread']) > 0); + + useEffect(() => { + const disconnect = handleConnect(id); + + return () => { + disconnect(); + }; + }, [id]); + + const handleConnect = (id: string) => { + dispatch(fetchList(id)); + dispatch(expandListTimeline(id)); + + return dispatch(connectListStream(id)); + }; + + const handleLoadMore = (maxId: string) => { + dispatch(expandListTimeline(id, { maxId })); + }; + + const handleEditClick = () => { + dispatch(openModal('LIST_EDITOR', { listId: id })); + }; + + // const handleDeleteClick = () => { + // dispatch(openModal('CONFIRM', { + // icon: require('@tabler/icons/icons/trash.svg'), + // heading: intl.formatMessage(messages.deleteHeading), + // message: intl.formatMessage(messages.deleteMessage), + // confirm: intl.formatMessage(messages.deleteConfirm), + // onConfirm: () => { + // dispatch(deleteList(id)); + // history.push('/lists'); + // }, + // })); + // }; + + const title = list ? list.get('title') : id; + + if (typeof list === 'undefined') { + return ( + +
+ +
+
+ ); + } else if (list === false) { + return ( + + ); + } + + const emptyMessage = ( +
+ +

+ +
+ ); + + return ( + + {/* +
+ + + + +
+ + + + + +
+
*/} + + +
+ ); +}; + +export default ListTimeline; diff --git a/app/soapbox/features/lists/components/new_list_form.js b/app/soapbox/features/lists/components/new_list_form.js deleted file mode 100644 index a065f0b70..000000000 --- a/app/soapbox/features/lists/components/new_list_form.js +++ /dev/null @@ -1,82 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { defineMessages, injectIntl } from 'react-intl'; -import { connect } from 'react-redux'; - -import { changeListEditorTitle, submitListEditor } from '../../../actions/lists'; -import { Button } from '../../../components/ui'; - -const messages = defineMessages({ - label: { id: 'lists.new.title_placeholder', defaultMessage: 'New list title' }, - title: { id: 'lists.new.create', defaultMessage: 'Add list' }, - create: { id: 'lists.new.create_title', defaultMessage: 'Create' }, -}); - -const mapStateToProps = state => ({ - value: state.getIn(['listEditor', 'title']), - disabled: state.getIn(['listEditor', 'isSubmitting']), -}); - -const mapDispatchToProps = dispatch => ({ - onChange: value => dispatch(changeListEditorTitle(value)), - onSubmit: () => dispatch(submitListEditor(true)), -}); - -export default @connect(mapStateToProps, mapDispatchToProps) -@injectIntl -class NewListForm extends React.PureComponent { - - static propTypes = { - value: PropTypes.string.isRequired, - disabled: PropTypes.bool, - intl: PropTypes.object.isRequired, - onChange: PropTypes.func.isRequired, - onSubmit: PropTypes.func.isRequired, - }; - - handleChange = e => { - this.props.onChange(e.target.value); - } - - handleSubmit = e => { - e.preventDefault(); - this.props.onSubmit(); - } - - handleClick = e => { - e.preventDefault(); - this.props.onSubmit(); - } - - render() { - const { value, disabled, intl } = this.props; - - const label = intl.formatMessage(messages.label); - const create = intl.formatMessage(messages.create); - - return ( -
- - - -
- ); - } - -} diff --git a/app/soapbox/features/lists/components/new_list_form.tsx b/app/soapbox/features/lists/components/new_list_form.tsx new file mode 100644 index 000000000..b6385ecf3 --- /dev/null +++ b/app/soapbox/features/lists/components/new_list_form.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { useDispatch } from 'react-redux'; + +import { changeListEditorTitle, submitListEditor } from 'soapbox/actions/lists'; +import { Button } from 'soapbox/components/ui'; +import { useAppSelector } from 'soapbox/hooks'; + +const messages = defineMessages({ + label: { id: 'lists.new.title_placeholder', defaultMessage: 'New list title' }, + title: { id: 'lists.new.create', defaultMessage: 'Add list' }, + create: { id: 'lists.new.create_title', defaultMessage: 'Create' }, +}); + +const NewListForm: React.FC = () => { + const dispatch = useDispatch(); + const intl = useIntl(); + + const value = useAppSelector((state) => state.listEditor.get('title')); + const disabled = useAppSelector((state) => !!state.listEditor.get('isSubmitting')); + + const handleChange = (e: React.ChangeEvent) => { + dispatch(changeListEditorTitle(e.target.value)); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + dispatch(submitListEditor(true)); + }; + + const label = intl.formatMessage(messages.label); + const create = intl.formatMessage(messages.create); + + return ( +
+ + + +
+ ); +}; + +export default NewListForm; diff --git a/app/soapbox/features/lists/index.js b/app/soapbox/features/lists/index.js deleted file mode 100644 index 03521286d..000000000 --- a/app/soapbox/features/lists/index.js +++ /dev/null @@ -1,89 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; - -import { fetchLists } from 'soapbox/actions/lists'; -import ScrollableList from 'soapbox/components/scrollable_list'; -import { Spinner } from 'soapbox/components/ui'; -import { CardHeader, CardTitle } from 'soapbox/components/ui'; - - -import Column from '../ui/components/column'; -import ColumnLink from '../ui/components/column_link'; - -import NewListForm from './components/new_list_form'; - -const messages = defineMessages({ - heading: { id: 'column.lists', defaultMessage: 'Lists' }, - subheading: { id: 'lists.subheading', defaultMessage: 'Your lists' }, - add: { id: 'lists.new.create', defaultMessage: 'Add list' }, -}); - -const getOrderedLists = createSelector([state => state.get('lists')], lists => { - if (!lists) { - return lists; - } - - return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title'))); -}); - -const mapStateToProps = state => ({ - lists: getOrderedLists(state), -}); - -export default @connect(mapStateToProps) -@injectIntl -class Lists extends ImmutablePureComponent { - - static propTypes = { - params: PropTypes.object.isRequired, - dispatch: PropTypes.func.isRequired, - lists: ImmutablePropTypes.list, - intl: PropTypes.object.isRequired, - }; - - componentDidMount() { - this.props.dispatch(fetchLists()); - } - - render() { - const { intl, lists } = this.props; - - if (!lists) { - return ( - - - - ); - } - - const emptyMessage = ; - - return ( - -
- - - - -
- - - - - {lists.map(list => - , - )} - -
- ); - } - -} diff --git a/app/soapbox/features/lists/index.tsx b/app/soapbox/features/lists/index.tsx new file mode 100644 index 000000000..5b9a0e936 --- /dev/null +++ b/app/soapbox/features/lists/index.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { useEffect } from 'react'; +import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; +import { useDispatch } from 'react-redux'; +import { createSelector } from 'reselect'; + +import { fetchLists } from 'soapbox/actions/lists'; +import ScrollableList from 'soapbox/components/scrollable_list'; +import { Spinner } from 'soapbox/components/ui'; +import { CardHeader, CardTitle } from 'soapbox/components/ui'; +import { useAppSelector } from 'soapbox/hooks'; + +import Column from '../ui/components/column'; +import ColumnLink from '../ui/components/column_link'; + +import NewListForm from './components/new_list_form'; + +import type { RootState } from 'soapbox/store'; + +const messages = defineMessages({ + heading: { id: 'column.lists', defaultMessage: 'Lists' }, + subheading: { id: 'lists.subheading', defaultMessage: 'Your lists' }, + add: { id: 'lists.new.create', defaultMessage: 'Add list' }, +}); + +const getOrderedLists = createSelector([(state: RootState) => state.lists], lists => { + if (!lists) { + return lists; + } + + return lists.toList().filter((item) => !!item).sort((a: any, b: any) => a.get('title').localeCompare(b.get('title'))); +}); + +const Lists: React.FC = () => { + const dispatch = useDispatch(); + const intl = useIntl(); + + const lists = useAppSelector((state) => getOrderedLists(state)); + + useEffect(() => { + dispatch(fetchLists()); + }, []); + + if (!lists) { + return ( + + + + ); + } + + const emptyMessage = ; + + return ( + +
+ + + + +
+ + + + + {lists.map((list: any) => + , + )} + +
+ ); +}; + +export default Lists; diff --git a/app/soapbox/normalizers/index.ts b/app/soapbox/normalizers/index.ts index 3251669de..b6d3ba8de 100644 --- a/app/soapbox/normalizers/index.ts +++ b/app/soapbox/normalizers/index.ts @@ -5,6 +5,7 @@ export { ChatRecord, normalizeChat } from './chat'; export { ChatMessageRecord, normalizeChatMessage } from './chat_message'; export { EmojiRecord, normalizeEmoji } from './emoji'; export { InstanceRecord, normalizeInstance } from './instance'; +export { ListRecord, normalizeList } from './list'; export { MentionRecord, normalizeMention } from './mention'; export { NotificationRecord, normalizeNotification } from './notification'; export { PollRecord, PollOptionRecord, normalizePoll } from './poll'; diff --git a/app/soapbox/normalizers/list.ts b/app/soapbox/normalizers/list.ts new file mode 100644 index 000000000..ed3ed1c94 --- /dev/null +++ b/app/soapbox/normalizers/list.ts @@ -0,0 +1,19 @@ +/** + * List normalizer: + * Converts API lists into our internal format. + * @see {@link https://docs.joinmastodon.org/entities/list/} + */ +import { Record as ImmutableRecord, Map as ImmutableMap, fromJS } from 'immutable'; + +// https://docs.joinmastodon.org/entities/list/ +export const ListRecord = ImmutableRecord({ + id: '', + title: '', + replies_policy: null as 'followed' | 'list' | 'none' | null, +}); + +export const normalizeList = (list: Record) => { + return ListRecord( + ImmutableMap(fromJS(list)), + ); +}; diff --git a/app/soapbox/reducers/__tests__/list_adder-test.js b/app/soapbox/reducers/__tests__/list_adder-test.js index bda177fb4..4806d3d13 100644 --- a/app/soapbox/reducers/__tests__/list_adder-test.js +++ b/app/soapbox/reducers/__tests__/list_adder-test.js @@ -1,4 +1,4 @@ -import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import { List as ImmutableList, Record as ImmutableRecord } from 'immutable'; import * as actions from 'soapbox/actions/lists'; @@ -6,87 +6,87 @@ import reducer from '../list_adder'; describe('list_adder reducer', () => { it('should return the initial state', () => { - expect(reducer(undefined, {})).toEqual(ImmutableMap({ + expect(reducer(undefined, {})).toMatchObject({ accountId: null, - lists: ImmutableMap({ + lists: { items: ImmutableList(), loaded: false, isLoading: false, - }), - })); + }, + }); }); it('should handle LIST_ADDER_RESET', () => { - const state = ImmutableMap({ + const state = ImmutableRecord({ accountId: null, - lists: ImmutableMap({ + lists: ImmutableRecord({ items: ImmutableList(), loaded: false, isLoading: false, - }), - }); + })(), + })(); const action = { type: actions.LIST_ADDER_RESET, }; - expect(reducer(state, action)).toEqual(ImmutableMap({ + expect(reducer(state, action)).toMatchObject({ accountId: null, - lists: ImmutableMap({ + lists: { items: ImmutableList(), loaded: false, isLoading: false, - }), - })); + }, + }); }); it('should handle LIST_ADDER_LISTS_FETCH_REQUEST', () => { - const state = ImmutableMap({ + const state = ImmutableRecord({ accountId: null, - lists: ImmutableMap({ + lists: ImmutableRecord({ items: ImmutableList(), loaded: false, isLoading: false, - }), - }); + })(), + })(); const action = { type: actions.LIST_ADDER_LISTS_FETCH_REQUEST, }; - expect(reducer(state, action)).toEqual(ImmutableMap({ + expect(reducer(state, action)).toMatchObject({ accountId: null, - lists: ImmutableMap({ + lists: { items: ImmutableList(), loaded: false, isLoading: true, - }), - })); + }, + }); }); it('should handle LIST_ADDER_LISTS_FETCH_FAIL', () => { - const state = ImmutableMap({ + const state = ImmutableRecord({ accountId: null, - lists: ImmutableMap({ + lists: ImmutableRecord({ items: ImmutableList(), loaded: false, isLoading: false, - }), - }); + })(), + })(); const action = { type: actions.LIST_ADDER_LISTS_FETCH_FAIL, }; - expect(reducer(state, action)).toEqual(ImmutableMap({ + expect(reducer(state, action)).toMatchObject({ accountId: null, - lists: ImmutableMap({ + lists: { items: ImmutableList(), loaded: false, isLoading: false, - }), - })); + }, + }); }); // it('should handle LIST_ADDER_LISTS_FETCH_SUCCESS', () => { diff --git a/app/soapbox/reducers/__tests__/list_editor-test.js b/app/soapbox/reducers/__tests__/list_editor-test.js index 0fe2ec121..1b351bca1 100644 --- a/app/soapbox/reducers/__tests__/list_editor-test.js +++ b/app/soapbox/reducers/__tests__/list_editor-test.js @@ -1,4 +1,4 @@ -import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import { Map as ImmutableMap, List as ImmutableList, Record as ImmutableRecord } from 'immutable'; import * as actions from 'soapbox/actions/lists'; @@ -6,83 +6,83 @@ import reducer from '../list_editor'; describe('list_editor reducer', () => { it('should return the initial state', () => { - expect(reducer(undefined, {})).toEqual(ImmutableMap({ + expect(reducer(undefined, {})).toMatchObject({ listId: null, isSubmitting: false, isChanged: false, title: '', - accounts: ImmutableMap({ + accounts: { items: ImmutableList(), loaded: false, isLoading: false, - }), + }, - suggestions: ImmutableMap({ + suggestions: { value: '', items: ImmutableList(), - }), - })); + }, + }); }); it('should handle LIST_EDITOR_RESET', () => { - const state = ImmutableMap({ + const state = ImmutableRecord({ listId: null, isSubmitting: false, isChanged: false, title: '', - accounts: ImmutableMap({ + accounts: ImmutableRecord({ items: ImmutableList(), loaded: false, isLoading: false, - }), + })(), - suggestions: ImmutableMap({ + suggestions: ImmutableRecord({ value: '', items: ImmutableList(), - }), - }); + })(), + })(); const action = { type: actions.LIST_EDITOR_RESET, }; - expect(reducer(state, action)).toEqual(ImmutableMap({ + expect(reducer(state, action)).toMatchObject({ listId: null, isSubmitting: false, isChanged: false, title: '', - accounts: ImmutableMap({ + accounts: { items: ImmutableList(), loaded: false, isLoading: false, - }), + }, - suggestions: ImmutableMap({ + suggestions: { value: '', items: ImmutableList(), - }), - })); + }, + }); }); it('should handle LIST_EDITOR_SETUP', () => { - const state = ImmutableMap({ + const state = ImmutableRecord({ listId: null, isSubmitting: false, isChanged: false, title: '', - accounts: ImmutableMap({ + accounts: ImmutableRecord({ items: ImmutableList(), loaded: false, isLoading: false, - }), + })(), - suggestions: ImmutableMap({ + suggestions: ImmutableRecord({ value: '', items: ImmutableList(), - }), - }); + })(), + })(); const action = { type: actions.LIST_EDITOR_SETUP, list: ImmutableMap({ @@ -90,23 +90,23 @@ describe('list_editor reducer', () => { title: 'list 1', }), }; - expect(reducer(state, action)).toEqual(ImmutableMap({ + expect(reducer(state, action)).toMatchObject({ listId: '22', isSubmitting: false, isChanged: false, title: 'list 1', - accounts: ImmutableMap({ + accounts: { items: ImmutableList(), loaded: false, isLoading: false, - }), + }, - suggestions: ImmutableMap({ + suggestions: { value: '', items: ImmutableList(), - }), - })); + }, + }); }); it('should handle LIST_EDITOR_TITLE_CHANGE', () => { diff --git a/app/soapbox/reducers/list_adder.js b/app/soapbox/reducers/list_adder.ts similarity index 51% rename from app/soapbox/reducers/list_adder.js rename to app/soapbox/reducers/list_adder.ts index 0f61273aa..c045a4d53 100644 --- a/app/soapbox/reducers/list_adder.js +++ b/app/soapbox/reducers/list_adder.ts @@ -1,4 +1,5 @@ -import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import { List as ImmutableList, Record as ImmutableRecord } from 'immutable'; +import { AnyAction } from 'redux'; import { LIST_ADDER_RESET, @@ -10,20 +11,24 @@ import { LIST_EDITOR_REMOVE_SUCCESS, } from '../actions/lists'; -const initialState = ImmutableMap({ - accountId: null, - - lists: ImmutableMap({ - items: ImmutableList(), - loaded: false, - isLoading: false, - }), +const ListsRecord = ImmutableRecord({ + items: ImmutableList(), + loaded: false, + isLoading: false, }); -export default function listAdderReducer(state = initialState, action) { +const ReducerRecord = ImmutableRecord({ + accountId: null as string | null, + + lists: ListsRecord(), +}); + +type State = ReturnType; + +export default function listAdderReducer(state: State = ReducerRecord(), action: AnyAction) { switch(action.type) { case LIST_ADDER_RESET: - return initialState; + return ReducerRecord(); case LIST_ADDER_SETUP: return state.withMutations(map => { map.set('accountId', action.account.get('id')); @@ -36,12 +41,12 @@ export default function listAdderReducer(state = initialState, action) { return state.update('lists', lists => lists.withMutations(map => { map.set('isLoading', false); map.set('loaded', true); - map.set('items', ImmutableList(action.lists.map(item => item.id))); + map.set('items', ImmutableList(action.lists.map((item: { id: string }) => item.id))); })); case LIST_EDITOR_ADD_SUCCESS: - return state.updateIn(['lists', 'items'], list => list.unshift(action.listId)); + return state.updateIn(['lists', 'items'], list => (list as ImmutableList).unshift(action.listId)); case LIST_EDITOR_REMOVE_SUCCESS: - return state.updateIn(['lists', 'items'], list => list.filterNot(item => item === action.listId)); + return state.updateIn(['lists', 'items'], list => (list as ImmutableList).filterNot(item => item === action.listId)); default: return state; } diff --git a/app/soapbox/reducers/list_editor.js b/app/soapbox/reducers/list_editor.ts similarity index 68% rename from app/soapbox/reducers/list_editor.js rename to app/soapbox/reducers/list_editor.ts index ceceb27c7..3c1611a92 100644 --- a/app/soapbox/reducers/list_editor.js +++ b/app/soapbox/reducers/list_editor.ts @@ -1,4 +1,5 @@ -import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import { List as ImmutableList, Record as ImmutableRecord } from 'immutable'; +import { AnyAction } from 'redux'; import { LIST_CREATE_REQUEST, @@ -20,28 +21,34 @@ import { LIST_EDITOR_REMOVE_SUCCESS, } from '../actions/lists'; -const initialState = ImmutableMap({ - listId: null, +const AccountsRecord = ImmutableRecord({ + items: ImmutableList(), + loaded: false, + isLoading: false, +}); + +const SuggestionsRecord = ImmutableRecord({ + value: '', + items: ImmutableList(), +}); + +const ReducerRecord = ImmutableRecord({ + listId: null as string | null, isSubmitting: false, isChanged: false, title: '', - accounts: ImmutableMap({ - items: ImmutableList(), - loaded: false, - isLoading: false, - }), + accounts: AccountsRecord(), - suggestions: ImmutableMap({ - value: '', - items: ImmutableList(), - }), + suggestions: SuggestionsRecord(), }); -export default function listEditorReducer(state = initialState, action) { +type State = ReturnType; + +export default function listEditorReducer(state: State = ReducerRecord(), action: AnyAction) { switch(action.type) { case LIST_EDITOR_RESET: - return initialState; + return ReducerRecord(); case LIST_EDITOR_SETUP: return state.withMutations(map => { map.set('listId', action.list.get('id')); @@ -76,21 +83,21 @@ export default function listEditorReducer(state = initialState, action) { return state.update('accounts', accounts => accounts.withMutations(map => { map.set('isLoading', false); map.set('loaded', true); - map.set('items', ImmutableList(action.accounts.map(item => item.id))); + map.set('items', ImmutableList(action.accounts.map((item: { id: string }) => item.id))); })); case LIST_EDITOR_SUGGESTIONS_CHANGE: return state.setIn(['suggestions', 'value'], action.value); case LIST_EDITOR_SUGGESTIONS_READY: - return state.setIn(['suggestions', 'items'], ImmutableList(action.accounts.map(item => item.id))); + return state.setIn(['suggestions', 'items'], ImmutableList(action.accounts.map((item: { id: string }) => item.id))); case LIST_EDITOR_SUGGESTIONS_CLEAR: return state.update('suggestions', suggestions => suggestions.withMutations(map => { map.set('items', ImmutableList()); map.set('value', ''); })); case LIST_EDITOR_ADD_SUCCESS: - return state.updateIn(['accounts', 'items'], list => list.unshift(action.accountId)); + return state.updateIn(['accounts', 'items'], list => (list as ImmutableList).unshift(action.accountId)); case LIST_EDITOR_REMOVE_SUCCESS: - return state.updateIn(['accounts', 'items'], list => list.filterNot(item => item === action.accountId)); + return state.updateIn(['accounts', 'items'], list => (list as ImmutableList).filterNot((item) => item === action.accountId)); default: return state; } diff --git a/app/soapbox/reducers/lists.js b/app/soapbox/reducers/lists.js deleted file mode 100644 index 2a797772b..000000000 --- a/app/soapbox/reducers/lists.js +++ /dev/null @@ -1,38 +0,0 @@ -import { Map as ImmutableMap, fromJS } from 'immutable'; - -import { - LIST_FETCH_SUCCESS, - LIST_FETCH_FAIL, - LISTS_FETCH_SUCCESS, - LIST_CREATE_SUCCESS, - LIST_UPDATE_SUCCESS, - LIST_DELETE_SUCCESS, -} from '../actions/lists'; - -const initialState = ImmutableMap(); - -const normalizeList = (state, list) => state.set(list.id, fromJS(list)); - -const normalizeLists = (state, lists) => { - lists.forEach(list => { - state = normalizeList(state, list); - }); - - return state; -}; - -export default function lists(state = initialState, action) { - switch(action.type) { - case LIST_FETCH_SUCCESS: - case LIST_CREATE_SUCCESS: - case LIST_UPDATE_SUCCESS: - return normalizeList(state, action.list); - case LISTS_FETCH_SUCCESS: - return normalizeLists(state, action.lists); - case LIST_DELETE_SUCCESS: - case LIST_FETCH_FAIL: - return state.set(action.id, false); - default: - return state; - } -} diff --git a/app/soapbox/reducers/lists.ts b/app/soapbox/reducers/lists.ts new file mode 100644 index 000000000..e0ab4db97 --- /dev/null +++ b/app/soapbox/reducers/lists.ts @@ -0,0 +1,46 @@ +import { Map as ImmutableMap } from 'immutable'; +import { AnyAction } from 'redux'; + +import { + LIST_FETCH_SUCCESS, + LIST_FETCH_FAIL, + LISTS_FETCH_SUCCESS, + LIST_CREATE_SUCCESS, + LIST_UPDATE_SUCCESS, + LIST_DELETE_SUCCESS, +} from 'soapbox/actions/lists'; +import { normalizeList } from 'soapbox/normalizers'; + +type ListRecord = ReturnType; +type APIEntity = Record; +type APIEntities = Array; + +type State = ImmutableMap; + +const initialState: State = ImmutableMap(); + +const importList = (state: State, list: APIEntity) => state.set(list.id, normalizeList(list)); + +const importLists = (state: State, lists: APIEntities) => { + lists.forEach(list => { + state = importList(state, list); + }); + + return state; +}; + +export default function lists(state: State = initialState, action: AnyAction) { + switch(action.type) { + case LIST_FETCH_SUCCESS: + case LIST_CREATE_SUCCESS: + case LIST_UPDATE_SUCCESS: + return importList(state, action.list); + case LISTS_FETCH_SUCCESS: + return importLists(state, action.lists); + case LIST_DELETE_SUCCESS: + case LIST_FETCH_FAIL: + return state.set(action.id, false); + default: + return state; + } +}