diff --git a/app/javascript/mastodon/actions/lists.js b/app/javascript/mastodon/actions/lists.js index 9956059387..f9abc2e769 100644 --- a/app/javascript/mastodon/actions/lists.js +++ b/app/javascript/mastodon/actions/lists.js @@ -1,8 +1,5 @@ import api from '../api'; -import { showAlertForError } from './alerts'; -import { importFetchedAccounts } from './importer'; - export const LIST_FETCH_REQUEST = 'LIST_FETCH_REQUEST'; export const LIST_FETCH_SUCCESS = 'LIST_FETCH_SUCCESS'; export const LIST_FETCH_FAIL = 'LIST_FETCH_FAIL'; @@ -11,45 +8,10 @@ export const LISTS_FETCH_REQUEST = 'LISTS_FETCH_REQUEST'; export const LISTS_FETCH_SUCCESS = 'LISTS_FETCH_SUCCESS'; export const LISTS_FETCH_FAIL = 'LISTS_FETCH_FAIL'; -export const LIST_EDITOR_TITLE_CHANGE = 'LIST_EDITOR_TITLE_CHANGE'; -export const LIST_EDITOR_RESET = 'LIST_EDITOR_RESET'; -export const LIST_EDITOR_SETUP = 'LIST_EDITOR_SETUP'; - -export const LIST_CREATE_REQUEST = 'LIST_CREATE_REQUEST'; -export const LIST_CREATE_SUCCESS = 'LIST_CREATE_SUCCESS'; -export const LIST_CREATE_FAIL = 'LIST_CREATE_FAIL'; - -export const LIST_UPDATE_REQUEST = 'LIST_UPDATE_REQUEST'; -export const LIST_UPDATE_SUCCESS = 'LIST_UPDATE_SUCCESS'; -export const LIST_UPDATE_FAIL = 'LIST_UPDATE_FAIL'; - export const LIST_DELETE_REQUEST = 'LIST_DELETE_REQUEST'; export const LIST_DELETE_SUCCESS = 'LIST_DELETE_SUCCESS'; export const LIST_DELETE_FAIL = 'LIST_DELETE_FAIL'; -export const LIST_ACCOUNTS_FETCH_REQUEST = 'LIST_ACCOUNTS_FETCH_REQUEST'; -export const LIST_ACCOUNTS_FETCH_SUCCESS = 'LIST_ACCOUNTS_FETCH_SUCCESS'; -export const LIST_ACCOUNTS_FETCH_FAIL = 'LIST_ACCOUNTS_FETCH_FAIL'; - -export const LIST_EDITOR_SUGGESTIONS_CHANGE = 'LIST_EDITOR_SUGGESTIONS_CHANGE'; -export const LIST_EDITOR_SUGGESTIONS_READY = 'LIST_EDITOR_SUGGESTIONS_READY'; -export const LIST_EDITOR_SUGGESTIONS_CLEAR = 'LIST_EDITOR_SUGGESTIONS_CLEAR'; - -export const LIST_EDITOR_ADD_REQUEST = 'LIST_EDITOR_ADD_REQUEST'; -export const LIST_EDITOR_ADD_SUCCESS = 'LIST_EDITOR_ADD_SUCCESS'; -export const LIST_EDITOR_ADD_FAIL = 'LIST_EDITOR_ADD_FAIL'; - -export const LIST_EDITOR_REMOVE_REQUEST = 'LIST_EDITOR_REMOVE_REQUEST'; -export const LIST_EDITOR_REMOVE_SUCCESS = 'LIST_EDITOR_REMOVE_SUCCESS'; -export const LIST_EDITOR_REMOVE_FAIL = 'LIST_EDITOR_REMOVE_FAIL'; - -export const LIST_ADDER_RESET = 'LIST_ADDER_RESET'; -export const LIST_ADDER_SETUP = 'LIST_ADDER_SETUP'; - -export const LIST_ADDER_LISTS_FETCH_REQUEST = 'LIST_ADDER_LISTS_FETCH_REQUEST'; -export const LIST_ADDER_LISTS_FETCH_SUCCESS = 'LIST_ADDER_LISTS_FETCH_SUCCESS'; -export const LIST_ADDER_LISTS_FETCH_FAIL = 'LIST_ADDER_LISTS_FETCH_FAIL'; - export const fetchList = id => (dispatch, getState) => { if (getState().getIn(['lists', id])) { return; @@ -100,89 +62,6 @@ export const fetchListsFail = error => ({ error, }); -export const submitListEditor = shouldReset => (dispatch, getState) => { - const listId = getState().getIn(['listEditor', 'listId']); - const title = getState().getIn(['listEditor', 'title']); - - if (listId === null) { - dispatch(createList(title, shouldReset)); - } else { - dispatch(updateList(listId, title, shouldReset)); - } -}; - -export const setupListEditor = listId => (dispatch, getState) => { - dispatch({ - type: LIST_EDITOR_SETUP, - list: getState().getIn(['lists', listId]), - }); - - dispatch(fetchListAccounts(listId)); -}; - -export const changeListEditorTitle = value => ({ - type: LIST_EDITOR_TITLE_CHANGE, - value, -}); - -export const createList = (title, shouldReset) => (dispatch) => { - dispatch(createListRequest()); - - api().post('/api/v1/lists', { title }).then(({ data }) => { - dispatch(createListSuccess(data)); - - if (shouldReset) { - dispatch(resetListEditor()); - } - }).catch(err => dispatch(createListFail(err))); -}; - -export const createListRequest = () => ({ - type: LIST_CREATE_REQUEST, -}); - -export const createListSuccess = list => ({ - type: LIST_CREATE_SUCCESS, - list, -}); - -export const createListFail = error => ({ - type: LIST_CREATE_FAIL, - error, -}); - -export const updateList = (id, title, shouldReset, isExclusive, replies_policy) => (dispatch) => { - dispatch(updateListRequest(id)); - - api().put(`/api/v1/lists/${id}`, { title, replies_policy, exclusive: typeof isExclusive === 'undefined' ? undefined : !!isExclusive }).then(({ data }) => { - dispatch(updateListSuccess(data)); - - if (shouldReset) { - dispatch(resetListEditor()); - } - }).catch(err => dispatch(updateListFail(id, err))); -}; - -export const updateListRequest = id => ({ - type: LIST_UPDATE_REQUEST, - id, -}); - -export const updateListSuccess = list => ({ - type: LIST_UPDATE_SUCCESS, - list, -}); - -export const updateListFail = (id, error) => ({ - type: LIST_UPDATE_FAIL, - id, - error, -}); - -export const resetListEditor = () => ({ - type: LIST_EDITOR_RESET, -}); - export const deleteList = id => (dispatch) => { dispatch(deleteListRequest(id)); @@ -206,167 +85,3 @@ export const deleteListFail = (id, error) => ({ id, error, }); - -export const fetchListAccounts = listId => (dispatch) => { - dispatch(fetchListAccountsRequest(listId)); - - api().get(`/api/v1/lists/${listId}/accounts`, { params: { limit: 0 } }).then(({ data }) => { - dispatch(importFetchedAccounts(data)); - dispatch(fetchListAccountsSuccess(listId, data)); - }).catch(err => dispatch(fetchListAccountsFail(listId, err))); -}; - -export const fetchListAccountsRequest = id => ({ - type: LIST_ACCOUNTS_FETCH_REQUEST, - id, -}); - -export const fetchListAccountsSuccess = (id, accounts, next) => ({ - type: LIST_ACCOUNTS_FETCH_SUCCESS, - id, - accounts, - next, -}); - -export const fetchListAccountsFail = (id, error) => ({ - type: LIST_ACCOUNTS_FETCH_FAIL, - id, - error, -}); - -export const fetchListSuggestions = q => (dispatch) => { - const params = { - q, - resolve: false, - limit: 4, - following: true, - }; - - api().get('/api/v1/accounts/search', { params }).then(({ data }) => { - dispatch(importFetchedAccounts(data)); - dispatch(fetchListSuggestionsReady(q, data)); - }).catch(error => dispatch(showAlertForError(error))); -}; - -export const fetchListSuggestionsReady = (query, accounts) => ({ - type: LIST_EDITOR_SUGGESTIONS_READY, - query, - accounts, -}); - -export const clearListSuggestions = () => ({ - type: LIST_EDITOR_SUGGESTIONS_CLEAR, -}); - -export const changeListSuggestions = value => ({ - type: LIST_EDITOR_SUGGESTIONS_CHANGE, - value, -}); - -export const addToListEditor = accountId => (dispatch, getState) => { - dispatch(addToList(getState().getIn(['listEditor', 'listId']), accountId)); -}; - -export const addToList = (listId, accountId) => (dispatch) => { - dispatch(addToListRequest(listId, accountId)); - - api().post(`/api/v1/lists/${listId}/accounts`, { account_ids: [accountId] }) - .then(() => dispatch(addToListSuccess(listId, accountId))) - .catch(err => dispatch(addToListFail(listId, accountId, err))); -}; - -export const addToListRequest = (listId, accountId) => ({ - type: LIST_EDITOR_ADD_REQUEST, - listId, - accountId, -}); - -export const addToListSuccess = (listId, accountId) => ({ - type: LIST_EDITOR_ADD_SUCCESS, - listId, - accountId, -}); - -export const addToListFail = (listId, accountId, error) => ({ - type: LIST_EDITOR_ADD_FAIL, - listId, - accountId, - error, -}); - -export const removeFromListEditor = accountId => (dispatch, getState) => { - dispatch(removeFromList(getState().getIn(['listEditor', 'listId']), accountId)); -}; - -export const removeFromList = (listId, accountId) => (dispatch) => { - dispatch(removeFromListRequest(listId, accountId)); - - api().delete(`/api/v1/lists/${listId}/accounts`, { params: { account_ids: [accountId] } }) - .then(() => dispatch(removeFromListSuccess(listId, accountId))) - .catch(err => dispatch(removeFromListFail(listId, accountId, err))); -}; - -export const removeFromListRequest = (listId, accountId) => ({ - type: LIST_EDITOR_REMOVE_REQUEST, - listId, - accountId, -}); - -export const removeFromListSuccess = (listId, accountId) => ({ - type: LIST_EDITOR_REMOVE_SUCCESS, - listId, - accountId, -}); - -export const removeFromListFail = (listId, accountId, error) => ({ - type: LIST_EDITOR_REMOVE_FAIL, - listId, - accountId, - error, -}); - -export const resetListAdder = () => ({ - type: LIST_ADDER_RESET, -}); - -export const setupListAdder = accountId => (dispatch, getState) => { - dispatch({ - type: LIST_ADDER_SETUP, - account: getState().getIn(['accounts', accountId]), - }); - dispatch(fetchLists()); - dispatch(fetchAccountLists(accountId)); -}; - -export const fetchAccountLists = accountId => (dispatch) => { - dispatch(fetchAccountListsRequest(accountId)); - - api().get(`/api/v1/accounts/${accountId}/lists`) - .then(({ data }) => dispatch(fetchAccountListsSuccess(accountId, data))) - .catch(err => dispatch(fetchAccountListsFail(accountId, err))); -}; - -export const fetchAccountListsRequest = id => ({ - type:LIST_ADDER_LISTS_FETCH_REQUEST, - id, -}); - -export const fetchAccountListsSuccess = (id, lists) => ({ - type: LIST_ADDER_LISTS_FETCH_SUCCESS, - id, - lists, -}); - -export const fetchAccountListsFail = (id, err) => ({ - type: LIST_ADDER_LISTS_FETCH_FAIL, - id, - err, -}); - -export const addToListAdder = listId => (dispatch, getState) => { - dispatch(addToList(listId, getState().getIn(['listAdder', 'accountId']))); -}; - -export const removeFromListAdder = listId => (dispatch, getState) => { - dispatch(removeFromList(listId, getState().getIn(['listAdder', 'accountId']))); -}; diff --git a/app/javascript/mastodon/actions/lists_typed.ts b/app/javascript/mastodon/actions/lists_typed.ts new file mode 100644 index 0000000000..ccc5c11c89 --- /dev/null +++ b/app/javascript/mastodon/actions/lists_typed.ts @@ -0,0 +1,13 @@ +import { apiCreate, apiUpdate } from 'mastodon/api/lists'; +import type { List } from 'mastodon/models/list'; +import { createDataLoadingThunk } from 'mastodon/store/typed_functions'; + +export const createList = createDataLoadingThunk( + 'list/create', + (list: Partial) => apiCreate(list), +); + +export const updateList = createDataLoadingThunk( + 'list/update', + (list: Partial) => apiUpdate(list), +); diff --git a/app/javascript/mastodon/api.ts b/app/javascript/mastodon/api.ts index 51cbe0b695..f0663ded40 100644 --- a/app/javascript/mastodon/api.ts +++ b/app/javascript/mastodon/api.ts @@ -68,6 +68,7 @@ export async function apiRequest( method: Method, url: string, args: { + signal?: AbortSignal; params?: RequestParamsOrData; data?: RequestParamsOrData; timeout?: number; diff --git a/app/javascript/mastodon/api/lists.ts b/app/javascript/mastodon/api/lists.ts new file mode 100644 index 0000000000..a5586eb6d4 --- /dev/null +++ b/app/javascript/mastodon/api/lists.ts @@ -0,0 +1,32 @@ +import { + apiRequestPost, + apiRequestPut, + apiRequestGet, + apiRequestDelete, +} from 'mastodon/api'; +import type { ApiAccountJSON } from 'mastodon/api_types/accounts'; +import type { ApiListJSON } from 'mastodon/api_types/lists'; + +export const apiCreate = (list: Partial) => + apiRequestPost('v1/lists', list); + +export const apiUpdate = (list: Partial) => + apiRequestPut(`v1/lists/${list.id}`, list); + +export const apiGetAccounts = (listId: string) => + apiRequestGet(`v1/lists/${listId}/accounts`, { + limit: 0, + }); + +export const apiGetAccountLists = (accountId: string) => + apiRequestGet(`v1/accounts/${accountId}/lists`); + +export const apiAddAccountToList = (listId: string, accountId: string) => + apiRequestPost(`v1/lists/${listId}/accounts`, { + account_ids: [accountId], + }); + +export const apiRemoveAccountFromList = (listId: string, accountId: string) => + apiRequestDelete(`v1/lists/${listId}/accounts`, { + account_ids: [accountId], + }); diff --git a/app/javascript/mastodon/api_types/lists.ts b/app/javascript/mastodon/api_types/lists.ts new file mode 100644 index 0000000000..6984cf9b19 --- /dev/null +++ b/app/javascript/mastodon/api_types/lists.ts @@ -0,0 +1,10 @@ +// See app/serializers/rest/list_serializer.rb + +export type RepliesPolicyType = 'list' | 'followed' | 'none'; + +export interface ApiListJSON { + id: string; + title: string; + exclusive: boolean; + replies_policy: RepliesPolicyType; +} diff --git a/app/javascript/mastodon/components/check_box.tsx b/app/javascript/mastodon/components/check_box.tsx index 9bd137abf5..73fdb2f97b 100644 --- a/app/javascript/mastodon/components/check_box.tsx +++ b/app/javascript/mastodon/components/check_box.tsx @@ -7,11 +7,11 @@ import { Icon } from './icon'; interface Props { value: string; - checked: boolean; - indeterminate: boolean; - name: string; - onChange: (event: React.ChangeEvent) => void; - label: React.ReactNode; + checked?: boolean; + indeterminate?: boolean; + name?: string; + onChange?: (event: React.ChangeEvent) => void; + label?: React.ReactNode; } export const CheckBox: React.FC = ({ @@ -30,6 +30,7 @@ export const CheckBox: React.FC = ({ value={value} checked={checked} onChange={onChange} + readOnly={!onChange} /> = ({ )} - {label} + {label && {label}} ); }; diff --git a/app/javascript/mastodon/components/scrollable_list.jsx b/app/javascript/mastodon/components/scrollable_list.jsx index 29439fdf55..35cd86ea1a 100644 --- a/app/javascript/mastodon/components/scrollable_list.jsx +++ b/app/javascript/mastodon/components/scrollable_list.jsx @@ -80,6 +80,7 @@ class ScrollableList extends PureComponent { children: PropTypes.node, bindToDocument: PropTypes.bool, preventScroll: PropTypes.bool, + footer: PropTypes.node, }; static defaultProps = { @@ -324,7 +325,7 @@ class ScrollableList extends PureComponent { }; render () { - const { children, scrollKey, trackScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, emptyMessage, onLoadMore } = this.props; + const { children, scrollKey, trackScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, footer, emptyMessage, onLoadMore } = this.props; const { fullscreen } = this.state; const childrenCount = Children.count(children); @@ -342,11 +343,13 @@ class ScrollableList extends PureComponent {
+ + {footer} ); } else if (isLoading || childrenCount > 0 || numPending > 0 || hasMore || !emptyMessage) { scrollableArea = ( -
+
{prepend} @@ -375,6 +378,8 @@ class ScrollableList extends PureComponent { {!hasMore && append}
+ + {footer}
); } else { @@ -385,6 +390,8 @@ class ScrollableList extends PureComponent {
{emptyMessage}
+ + {footer}
); } diff --git a/app/javascript/mastodon/containers/dropdown_menu_container.js b/app/javascript/mastodon/containers/dropdown_menu_container.js index bc9124c041..dc2a9648ff 100644 --- a/app/javascript/mastodon/containers/dropdown_menu_container.js +++ b/app/javascript/mastodon/containers/dropdown_menu_container.js @@ -15,6 +15,13 @@ const mapStateToProps = state => ({ openedViaKeyboard: state.dropdownMenu.keyboard, }); +/** + * @param {any} dispatch + * @param {Object} root0 + * @param {any} [root0.status] + * @param {any} root0.items + * @param {any} [root0.scrollKey] + */ const mapDispatchToProps = (dispatch, { status, items, scrollKey }) => ({ onOpen(id, onItemClick, keyboard) { if (status) { diff --git a/app/javascript/mastodon/features/list_adder/components/account.jsx b/app/javascript/mastodon/features/list_adder/components/account.jsx deleted file mode 100644 index 94a90726e3..0000000000 --- a/app/javascript/mastodon/features/list_adder/components/account.jsx +++ /dev/null @@ -1,43 +0,0 @@ -import { injectIntl } from 'react-intl'; - -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { connect } from 'react-redux'; - -import { Avatar } from '../../../components/avatar'; -import { DisplayName } from '../../../components/display_name'; -import { makeGetAccount } from '../../../selectors'; - -const makeMapStateToProps = () => { - const getAccount = makeGetAccount(); - - const mapStateToProps = (state, { accountId }) => ({ - account: getAccount(state, accountId), - }); - - return mapStateToProps; -}; - -class Account extends ImmutablePureComponent { - - static propTypes = { - account: ImmutablePropTypes.record.isRequired, - }; - - render () { - const { account } = this.props; - return ( -
-
-
-
- -
-
-
- ); - } - -} - -export default connect(makeMapStateToProps)(injectIntl(Account)); diff --git a/app/javascript/mastodon/features/list_adder/components/list.jsx b/app/javascript/mastodon/features/list_adder/components/list.jsx deleted file mode 100644 index a7cfd60bf3..0000000000 --- a/app/javascript/mastodon/features/list_adder/components/list.jsx +++ /dev/null @@ -1,75 +0,0 @@ -import PropTypes from 'prop-types'; - -import { defineMessages, injectIntl } from 'react-intl'; - -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { connect } from 'react-redux'; - -import AddIcon from '@/material-icons/400-24px/add.svg?react'; -import CloseIcon from '@/material-icons/400-24px/close.svg?react'; -import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; -import { Icon } from 'mastodon/components/icon'; - -import { removeFromListAdder, addToListAdder } from '../../../actions/lists'; -import { IconButton } from '../../../components/icon_button'; - -const messages = defineMessages({ - remove: { id: 'lists.account.remove', defaultMessage: 'Remove from list' }, - add: { id: 'lists.account.add', defaultMessage: 'Add to list' }, -}); - -const MapStateToProps = (state, { listId, added }) => ({ - list: state.get('lists').get(listId), - added: typeof added === 'undefined' ? state.getIn(['listAdder', 'lists', 'items']).includes(listId) : added, -}); - -const mapDispatchToProps = (dispatch, { listId }) => ({ - onRemove: () => dispatch(removeFromListAdder(listId)), - onAdd: () => dispatch(addToListAdder(listId)), -}); - -class List extends ImmutablePureComponent { - - static propTypes = { - list: ImmutablePropTypes.map.isRequired, - intl: PropTypes.object.isRequired, - onRemove: PropTypes.func.isRequired, - onAdd: PropTypes.func.isRequired, - added: PropTypes.bool, - }; - - static defaultProps = { - added: false, - }; - - render () { - const { list, intl, onRemove, onAdd, added } = this.props; - - let button; - - if (added) { - button = ; - } else { - button = ; - } - - return ( -
-
-
- - {list.get('title')} -
- -
- {button} -
-
-
- ); - } - -} - -export default connect(MapStateToProps, mapDispatchToProps)(injectIntl(List)); diff --git a/app/javascript/mastodon/features/list_adder/index.jsx b/app/javascript/mastodon/features/list_adder/index.jsx deleted file mode 100644 index 4e7bd46bdf..0000000000 --- a/app/javascript/mastodon/features/list_adder/index.jsx +++ /dev/null @@ -1,76 +0,0 @@ -import PropTypes from 'prop-types'; - -import { injectIntl } from 'react-intl'; - -import { createSelector } from '@reduxjs/toolkit'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { connect } from 'react-redux'; - -import { setupListAdder, resetListAdder } from '../../actions/lists'; -import NewListForm from '../lists/components/new_list_form'; - -import Account from './components/account'; -import List from './components/list'; -// hack - -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 => ({ - listIds: getOrderedLists(state).map(list=>list.get('id')), -}); - -const mapDispatchToProps = dispatch => ({ - onInitialize: accountId => dispatch(setupListAdder(accountId)), - onReset: () => dispatch(resetListAdder()), -}); - -class ListAdder extends ImmutablePureComponent { - - static propTypes = { - accountId: PropTypes.string.isRequired, - onClose: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, - onInitialize: PropTypes.func.isRequired, - onReset: PropTypes.func.isRequired, - listIds: ImmutablePropTypes.list.isRequired, - }; - - componentDidMount () { - const { onInitialize, accountId } = this.props; - onInitialize(accountId); - } - - componentWillUnmount () { - const { onReset } = this.props; - onReset(); - } - - render () { - const { accountId, listIds } = this.props; - - return ( -
-
- -
- - - - -
- {listIds.map(ListId => )} -
-
- ); - } - -} - -export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(ListAdder)); diff --git a/app/javascript/mastodon/features/list_adder/index.tsx b/app/javascript/mastodon/features/list_adder/index.tsx new file mode 100644 index 0000000000..5429c24aed --- /dev/null +++ b/app/javascript/mastodon/features/list_adder/index.tsx @@ -0,0 +1,213 @@ +import { useEffect, useState, useCallback } from 'react'; + +import { FormattedMessage, useIntl, defineMessages } from 'react-intl'; + +import { isFulfilled } from '@reduxjs/toolkit'; + +import CloseIcon from '@/material-icons/400-24px/close.svg?react'; +import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; +import { fetchLists } from 'mastodon/actions/lists'; +import { createList } from 'mastodon/actions/lists_typed'; +import { + apiGetAccountLists, + apiAddAccountToList, + apiRemoveAccountFromList, +} from 'mastodon/api/lists'; +import type { ApiListJSON } from 'mastodon/api_types/lists'; +import { Button } from 'mastodon/components/button'; +import { CheckBox } from 'mastodon/components/check_box'; +import { Icon } from 'mastodon/components/icon'; +import { IconButton } from 'mastodon/components/icon_button'; +import { getOrderedLists } from 'mastodon/selectors/lists'; +import { useAppDispatch, useAppSelector } from 'mastodon/store'; + +const messages = defineMessages({ + newList: { + id: 'lists.new_list_name', + defaultMessage: 'New list name', + }, + createList: { + id: 'lists.create', + defaultMessage: 'Create', + }, + close: { + id: 'lightbox.close', + defaultMessage: 'Close', + }, +}); + +const ListItem: React.FC<{ + id: string; + title: string; + checked: boolean; + onChange: (id: string, checked: boolean) => void; +}> = ({ id, title, checked, onChange }) => { + const handleChange = useCallback( + (e: React.ChangeEvent) => { + onChange(id, e.target.checked); + }, + [id, onChange], + ); + + return ( + // eslint-disable-next-line jsx-a11y/label-has-associated-control + + ); +}; + +const NewListItem: React.FC<{ + onCreate: (list: ApiListJSON) => void; +}> = ({ onCreate }) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + const [title, setTitle] = useState(''); + + const handleChange = useCallback( + ({ target: { value } }: React.ChangeEvent) => { + setTitle(value); + }, + [setTitle], + ); + + const handleSubmit = useCallback(() => { + if (title.trim().length === 0) { + return; + } + + void dispatch(createList({ title })).then((result) => { + if (isFulfilled(result)) { + onCreate(result.payload); + setTitle(''); + } + + return ''; + }); + }, [setTitle, dispatch, onCreate, title]); + + return ( +
+ + +
+ - -
-
- - -
-
- - {replies_policy !== undefined && ( -
-

- -
- { ['none', 'list', 'followed'].map(policy => ( - - ))} -
-
- )} @@ -229,4 +178,4 @@ class ListTimeline extends PureComponent { } -export default withRouter(connect(mapStateToProps)(injectIntl(ListTimeline))); +export default withRouter(connect(mapStateToProps)(ListTimeline)); diff --git a/app/javascript/mastodon/features/lists/components/new_list_form.jsx b/app/javascript/mastodon/features/lists/components/new_list_form.jsx deleted file mode 100644 index 0fed9d70a2..0000000000 --- a/app/javascript/mastodon/features/lists/components/new_list_form.jsx +++ /dev/null @@ -1,80 +0,0 @@ -import PropTypes from 'prop-types'; -import { PureComponent } from 'react'; - -import { defineMessages, injectIntl } from 'react-intl'; - -import { connect } from 'react-redux'; - -import { changeListEditorTitle, submitListEditor } from 'mastodon/actions/lists'; -import { Button } from 'mastodon/components/button'; - -const messages = defineMessages({ - label: { id: 'lists.new.title_placeholder', defaultMessage: 'New list title' }, - title: { id: 'lists.new.create', defaultMessage: 'Add list' }, -}); - -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)), -}); - -class NewListForm extends 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 = () => { - this.props.onSubmit(); - }; - - render () { - const { value, disabled, intl } = this.props; - - const label = intl.formatMessage(messages.label); - const title = intl.formatMessage(messages.title); - - return ( -
- - -
+ + + + + ); +}; + +const AccountItem: React.FC<{ + accountId: string; + listId: string; + partOfList: boolean; + onToggle: (accountId: string) => void; +}> = ({ accountId, listId, partOfList, onToggle }) => { + const intl = useIntl(); + const account = useAppSelector((state) => state.accounts.get(accountId)); + + const handleClick = useCallback(() => { + if (partOfList) { + void apiRemoveAccountFromList(listId, accountId); + } else { + void apiAddAccountToList(listId, accountId); + } + + onToggle(accountId); + }, [accountId, listId, partOfList, onToggle]); + + if (!account) { + return null; + } + + const firstVerifiedField = account.fields.find((item) => !!item.verified_at); + + return ( +
+
+ +
+ +
+ +
+ + +
+ {' '} + {firstVerifiedField && ( + + )} +
+
+ + +
+
+
+
+ ); +}; + +const ListMembers: React.FC<{ + multiColumn?: boolean; +}> = ({ multiColumn }) => { + const dispatch = useAppDispatch(); + const { id } = useParams<{ id: string }>(); + const intl = useIntl(); + + const followingAccountIds = useAppSelector( + (state) => state.user_lists.getIn(['following', me, 'items']) as string[], + ); + const [searching, setSearching] = useState(false); + const [accountIds, setAccountIds] = useState([]); + const [searchAccountIds, setSearchAccountIds] = useState([]); + const [loading, setLoading] = useState(true); + const [mode, setMode] = useState('remove'); + + useEffect(() => { + if (id) { + setLoading(true); + dispatch(fetchList(id)); + + void apiGetAccounts(id) + .then((data) => { + dispatch(importFetchedAccounts(data)); + setAccountIds(data.map((a) => a.id)); + setLoading(false); + return ''; + }) + .catch(() => { + setLoading(false); + }); + + dispatch(fetchFollowing(me)); + } + }, [dispatch, id]); + + const handleSearchClick = useCallback(() => { + setMode('add'); + }, [setMode]); + + const handleDismissSearchClick = useCallback(() => { + setMode('remove'); + setSearching(false); + }, [setMode]); + + const handleAccountToggle = useCallback( + (accountId: string) => { + const partOfList = accountIds.includes(accountId); + + if (partOfList) { + setAccountIds(accountIds.filter((id) => id !== accountId)); + } else { + setAccountIds([accountId, ...accountIds]); + } + }, + [accountIds, setAccountIds], + ); + + const searchRequestRef = useRef(null); + + const handleSearch = useDebouncedCallback( + (value: string) => { + if (searchRequestRef.current) { + searchRequestRef.current.abort(); + } + + if (value.trim().length === 0) { + setSearching(false); + return; + } + + setLoading(true); + + searchRequestRef.current = new AbortController(); + + void apiRequest('GET', 'v1/accounts/search', { + signal: searchRequestRef.current.signal, + params: { + q: value, + resolve: false, + following: true, + }, + }) + .then((data) => { + dispatch(importFetchedAccounts(data)); + setSearchAccountIds(data.map((a) => a.id)); + setLoading(false); + setSearching(true); + return ''; + }) + .catch(() => { + setSearching(true); + setLoading(false); + }); + }, + 500, + { leading: true, trailing: true }, + ); + + let displayedAccountIds: string[]; + + if (mode === 'add') { + displayedAccountIds = searching ? searchAccountIds : followingAccountIds; + } else { + displayedAccountIds = accountIds; + } + + return ( + + {mode === 'remove' ? ( + + + + } + /> + ) : ( + + )} + + +
+ +
+ + + +
+ + ) + } + emptyMessage={ + mode === 'remove' ? ( + <> + + +
+ +
+ + + + ) : ( + + ) + } + > + {displayedAccountIds.map((accountId) => ( + + ))} + + + + {intl.formatMessage(messages.heading)} + + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export default ListMembers; diff --git a/app/javascript/mastodon/features/lists/new.tsx b/app/javascript/mastodon/features/lists/new.tsx new file mode 100644 index 0000000000..cf39331d7c --- /dev/null +++ b/app/javascript/mastodon/features/lists/new.tsx @@ -0,0 +1,296 @@ +import { useCallback, useState, useEffect } from 'react'; + +import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; + +import { Helmet } from 'react-helmet'; +import { useParams, useHistory, Link } from 'react-router-dom'; + +import { isFulfilled } from '@reduxjs/toolkit'; + +import Toggle from 'react-toggle'; + +import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; +import { fetchList } from 'mastodon/actions/lists'; +import { createList, updateList } from 'mastodon/actions/lists_typed'; +import { apiGetAccounts } from 'mastodon/api/lists'; +import type { RepliesPolicyType } from 'mastodon/api_types/lists'; +import Column from 'mastodon/components/column'; +import { ColumnHeader } from 'mastodon/components/column_header'; +import { LoadingIndicator } from 'mastodon/components/loading_indicator'; +import { useAppDispatch, useAppSelector } from 'mastodon/store'; + +const messages = defineMessages({ + edit: { id: 'column.edit_list', defaultMessage: 'Edit list' }, + create: { id: 'column.create_list', defaultMessage: 'Create list' }, +}); + +const MembersLink: React.FC<{ + id: string; +}> = ({ id }) => { + const [count, setCount] = useState(0); + const [avatars, setAvatars] = useState([]); + + useEffect(() => { + void apiGetAccounts(id) + .then((data) => { + setCount(data.length); + setAvatars(data.slice(0, 3).map((a) => a.avatar)); + return ''; + }) + .catch(() => { + // Nothing + }); + }, [id, setCount, setAvatars]); + + return ( + +
+ + + + +
+ +
+ {avatars.map((url) => ( + + ))} +
+ + ); +}; + +const NewList: React.FC<{ + multiColumn?: boolean; +}> = ({ multiColumn }) => { + const dispatch = useAppDispatch(); + const { id } = useParams<{ id?: string }>(); + const intl = useIntl(); + const history = useHistory(); + + const list = useAppSelector((state) => + id ? state.lists.get(id) : undefined, + ); + const [title, setTitle] = useState(''); + const [exclusive, setExclusive] = useState(false); + const [repliesPolicy, setRepliesPolicy] = useState('list'); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + if (id) { + dispatch(fetchList(id)); + } + }, [dispatch, id]); + + useEffect(() => { + if (id && list) { + setTitle(list.title); + setExclusive(list.exclusive); + setRepliesPolicy(list.replies_policy); + } + }, [setTitle, setExclusive, setRepliesPolicy, id, list]); + + const handleTitleChange = useCallback( + ({ target: { value } }: React.ChangeEvent) => { + setTitle(value); + }, + [setTitle], + ); + + const handleExclusiveChange = useCallback( + ({ target: { checked } }: React.ChangeEvent) => { + setExclusive(checked); + }, + [setExclusive], + ); + + const handleRepliesPolicyChange = useCallback( + ({ target: { value } }: React.ChangeEvent) => { + setRepliesPolicy(value as RepliesPolicyType); + }, + [setRepliesPolicy], + ); + + const handleSubmit = useCallback(() => { + setSubmitting(true); + + if (id) { + void dispatch( + updateList({ + id, + title, + exclusive, + replies_policy: repliesPolicy, + }), + ).then(() => { + setSubmitting(false); + return ''; + }); + } else { + void dispatch( + createList({ + title, + exclusive, + replies_policy: repliesPolicy, + }), + ).then((result) => { + setSubmitting(false); + + if (isFulfilled(result)) { + history.replace(`/lists/${result.payload.id}/edit`); + history.push(`/lists/${result.payload.id}/members`); + } + + return ''; + }); + } + }, [history, dispatch, setSubmitting, id, title, exclusive, repliesPolicy]); + + return ( + + + +
+
+
+
+
+ + +
+ +
+
+
+
+ +
+
+
+ + +
+ +
+
+
+
+ + {id && ( +
+ +
+ )} + +
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + +
+ +
+ +
+
+
+ + + + {intl.formatMessage(id ? messages.edit : messages.create)} + + + +
+ ); +}; + +// eslint-disable-next-line import/no-default-export +export default NewList; diff --git a/app/javascript/mastodon/features/ui/components/modal_root.jsx b/app/javascript/mastodon/features/ui/components/modal_root.jsx index 42a00acf81..8a97ec4565 100644 --- a/app/javascript/mastodon/features/ui/components/modal_root.jsx +++ b/app/javascript/mastodon/features/ui/components/modal_root.jsx @@ -10,7 +10,6 @@ import { DomainBlockModal, ReportModal, EmbedModal, - ListEditor, ListAdder, CompareHistoryModal, FilterModal, @@ -64,7 +63,6 @@ export const MODAL_COMPONENTS = { 'REPORT': ReportModal, 'ACTIONS': () => Promise.resolve({ default: ActionsModal }), 'EMBED': EmbedModal, - 'LIST_EDITOR': ListEditor, 'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }), 'LIST_ADDER': ListAdder, 'COMPARE_HISTORY': CompareHistoryModal, diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx index b90ea5585a..daa4585ead 100644 --- a/app/javascript/mastodon/features/ui/index.jsx +++ b/app/javascript/mastodon/features/ui/index.jsx @@ -58,11 +58,13 @@ import { FollowedTags, LinkTimeline, ListTimeline, + Lists, + ListEdit, + ListMembers, Blocks, DomainBlocks, Mutes, PinnedStatuses, - Lists, Directory, Explore, Onboarding, @@ -205,6 +207,9 @@ class SwitchingColumnsArea extends PureComponent { + + + diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index ff5db65347..5a85c856d2 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -150,10 +150,6 @@ export function EmbedModal () { return import(/* webpackChunkName: "modals/embed_modal" */'../components/embed_modal'); } -export function ListEditor () { - return import(/* webpackChunkName: "features/list_editor" */'../../list_editor'); -} - export function ListAdder () { return import(/*webpackChunkName: "features/list_adder" */'../../list_adder'); } @@ -221,3 +217,11 @@ export function LinkTimeline () { export function AnnualReportModal () { return import(/*webpackChunkName: "modals/annual_report_modal" */'../components/annual_report_modal'); } + +export function ListEdit () { + return import(/*webpackChunkName: "features/lists" */'../../lists/new'); +} + +export function ListMembers () { + return import(/* webpackChunkName: "features/lists" */'../../lists/members'); +} diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 9728528f8e..142b7f1770 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -140,13 +140,16 @@ "column.blocks": "Blocked users", "column.bookmarks": "Bookmarks", "column.community": "Local timeline", + "column.create_list": "Create list", "column.direct": "Private mentions", "column.directory": "Browse profiles", "column.domain_blocks": "Blocked domains", + "column.edit_list": "Edit list", "column.favourites": "Favorites", "column.firehose": "Live feeds", "column.follow_requests": "Follow requests", "column.home": "Home", + "column.list_members": "Manage list members", "column.lists": "Lists", "column.mutes": "Muted users", "column.notifications": "Notifications", @@ -292,7 +295,6 @@ "empty_column.hashtag": "There is nothing in this hashtag yet.", "empty_column.home": "Your home timeline is empty! Follow more people to fill it up.", "empty_column.list": "There is nothing in this list yet. When members of this list publish new posts, they will appear here.", - "empty_column.lists": "You don't have any lists yet. When you create one, it will show up here.", "empty_column.mutes": "You haven't muted any users yet.", "empty_column.notification_requests": "All clear! There is nothing here. When you receive new notifications, they will appear here according to your settings.", "empty_column.notifications": "You don't have any notifications yet. When other people interact with you, you will see it here.", @@ -465,20 +467,32 @@ "link_preview.author": "By {name}", "link_preview.more_from_author": "More from {name}", "link_preview.shares": "{count, plural, one {{counter} post} other {{counter} posts}}", - "lists.account.add": "Add to list", - "lists.account.remove": "Remove from list", + "lists.add_member": "Add", + "lists.add_to_list": "Add to list", + "lists.add_to_lists": "Add {name} to lists", + "lists.create": "Create", + "lists.create_a_list_to_organize": "Create a new list to organize your Home feed", + "lists.create_list": "Create list", "lists.delete": "Delete list", + "lists.done": "Done", "lists.edit": "Edit list", - "lists.edit.submit": "Change title", - "lists.exclusive": "Hide these posts from home", - "lists.new.create": "Add list", - "lists.new.title_placeholder": "New list title", + "lists.exclusive": "Hide members in Home", + "lists.exclusive_hint": "If someone is on this list, hide them in your Home feed to avoid seeing their posts twice.", + "lists.find_users_to_add": "Find users to add", + "lists.list_members": "List members", + "lists.list_members_count": "{count, plural, one {# member} other {# members}}", + "lists.list_name": "List name", + "lists.new_list_name": "New list name", + "lists.no_lists_yet": "No lists yet.", + "lists.no_members_yet": "No members yet.", + "lists.no_results_found": "No results found.", + "lists.remove_member": "Remove", "lists.replies_policy.followed": "Any followed user", "lists.replies_policy.list": "Members of the list", "lists.replies_policy.none": "No one", - "lists.replies_policy.title": "Show replies to:", - "lists.search": "Search among people you follow", - "lists.subheading": "Your lists", + "lists.save": "Save", + "lists.search_placeholder": "Search people you follow", + "lists.show_replies_to": "Include replies from list members to", "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "Loading…", "media_gallery.hide": "Hide", diff --git a/app/javascript/mastodon/models/list.ts b/app/javascript/mastodon/models/list.ts new file mode 100644 index 0000000000..50b9896775 --- /dev/null +++ b/app/javascript/mastodon/models/list.ts @@ -0,0 +1,18 @@ +import type { RecordOf } from 'immutable'; +import { Record } from 'immutable'; + +import type { ApiListJSON } from 'mastodon/api_types/lists'; + +type ListShape = Required; // no changes from server shape +export type List = RecordOf; + +const ListFactory = Record({ + id: '', + title: '', + exclusive: false, + replies_policy: 'list', +}); + +export function createList(attributes: Partial) { + return ListFactory(attributes); +} diff --git a/app/javascript/mastodon/reducers/index.ts b/app/javascript/mastodon/reducers/index.ts index b92de0dbcd..aafee19c09 100644 --- a/app/javascript/mastodon/reducers/index.ts +++ b/app/javascript/mastodon/reducers/index.ts @@ -17,9 +17,7 @@ import filters from './filters'; import followed_tags from './followed_tags'; import height_cache from './height_cache'; import history from './history'; -import listAdder from './list_adder'; -import listEditor from './list_editor'; -import lists from './lists'; +import { listsReducer } from './lists'; import { markersReducer } from './markers'; import media_attachments from './media_attachments'; import meta from './meta'; @@ -69,9 +67,7 @@ const reducers = { notificationGroups: notificationGroupsReducer, height_cache, custom_emojis, - lists, - listEditor, - listAdder, + lists: listsReducer, filters, conversations, suggestions, diff --git a/app/javascript/mastodon/reducers/list_adder.js b/app/javascript/mastodon/reducers/list_adder.js deleted file mode 100644 index 0f61273aa6..0000000000 --- a/app/javascript/mastodon/reducers/list_adder.js +++ /dev/null @@ -1,48 +0,0 @@ -import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; - -import { - LIST_ADDER_RESET, - LIST_ADDER_SETUP, - LIST_ADDER_LISTS_FETCH_REQUEST, - LIST_ADDER_LISTS_FETCH_SUCCESS, - LIST_ADDER_LISTS_FETCH_FAIL, - LIST_EDITOR_ADD_SUCCESS, - LIST_EDITOR_REMOVE_SUCCESS, -} from '../actions/lists'; - -const initialState = ImmutableMap({ - accountId: null, - - lists: ImmutableMap({ - items: ImmutableList(), - loaded: false, - isLoading: false, - }), -}); - -export default function listAdderReducer(state = initialState, action) { - switch(action.type) { - case LIST_ADDER_RESET: - return initialState; - case LIST_ADDER_SETUP: - return state.withMutations(map => { - map.set('accountId', action.account.get('id')); - }); - case LIST_ADDER_LISTS_FETCH_REQUEST: - return state.setIn(['lists', 'isLoading'], true); - case LIST_ADDER_LISTS_FETCH_FAIL: - return state.setIn(['lists', 'isLoading'], false); - case LIST_ADDER_LISTS_FETCH_SUCCESS: - 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))); - })); - case LIST_EDITOR_ADD_SUCCESS: - return state.updateIn(['lists', 'items'], list => list.unshift(action.listId)); - case LIST_EDITOR_REMOVE_SUCCESS: - return state.updateIn(['lists', 'items'], list => list.filterNot(item => item === action.listId)); - default: - return state; - } -} diff --git a/app/javascript/mastodon/reducers/list_editor.js b/app/javascript/mastodon/reducers/list_editor.js deleted file mode 100644 index d3fd62adec..0000000000 --- a/app/javascript/mastodon/reducers/list_editor.js +++ /dev/null @@ -1,99 +0,0 @@ -import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; - -import { - LIST_CREATE_REQUEST, - LIST_CREATE_FAIL, - LIST_CREATE_SUCCESS, - LIST_UPDATE_REQUEST, - LIST_UPDATE_FAIL, - LIST_UPDATE_SUCCESS, - LIST_EDITOR_RESET, - LIST_EDITOR_SETUP, - LIST_EDITOR_TITLE_CHANGE, - LIST_ACCOUNTS_FETCH_REQUEST, - LIST_ACCOUNTS_FETCH_SUCCESS, - LIST_ACCOUNTS_FETCH_FAIL, - LIST_EDITOR_SUGGESTIONS_READY, - LIST_EDITOR_SUGGESTIONS_CLEAR, - LIST_EDITOR_SUGGESTIONS_CHANGE, - LIST_EDITOR_ADD_SUCCESS, - LIST_EDITOR_REMOVE_SUCCESS, -} from '../actions/lists'; - -const initialState = ImmutableMap({ - listId: null, - isSubmitting: false, - isChanged: false, - title: '', - isExclusive: false, - - accounts: ImmutableMap({ - items: ImmutableList(), - loaded: false, - isLoading: false, - }), - - suggestions: ImmutableMap({ - value: '', - items: ImmutableList(), - }), -}); - -export default function listEditorReducer(state = initialState, action) { - switch(action.type) { - case LIST_EDITOR_RESET: - return initialState; - case LIST_EDITOR_SETUP: - return state.withMutations(map => { - map.set('listId', action.list.get('id')); - map.set('title', action.list.get('title')); - map.set('isExclusive', action.list.get('is_exclusive')); - map.set('isSubmitting', false); - }); - case LIST_EDITOR_TITLE_CHANGE: - return state.withMutations(map => { - map.set('title', action.value); - map.set('isChanged', true); - }); - case LIST_CREATE_REQUEST: - case LIST_UPDATE_REQUEST: - return state.withMutations(map => { - map.set('isSubmitting', true); - map.set('isChanged', false); - }); - case LIST_CREATE_FAIL: - case LIST_UPDATE_FAIL: - return state.set('isSubmitting', false); - case LIST_CREATE_SUCCESS: - case LIST_UPDATE_SUCCESS: - return state.withMutations(map => { - map.set('isSubmitting', false); - map.set('listId', action.list.id); - }); - case LIST_ACCOUNTS_FETCH_REQUEST: - return state.setIn(['accounts', 'isLoading'], true); - case LIST_ACCOUNTS_FETCH_FAIL: - return state.setIn(['accounts', 'isLoading'], false); - case LIST_ACCOUNTS_FETCH_SUCCESS: - 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))); - })); - 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))); - 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)); - case LIST_EDITOR_REMOVE_SUCCESS: - return state.updateIn(['accounts', 'items'], list => list.filterNot(item => item === action.accountId)); - default: - return state; - } -} diff --git a/app/javascript/mastodon/reducers/lists.js b/app/javascript/mastodon/reducers/lists.js deleted file mode 100644 index 2a797772b3..0000000000 --- a/app/javascript/mastodon/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/javascript/mastodon/reducers/lists.ts b/app/javascript/mastodon/reducers/lists.ts new file mode 100644 index 0000000000..593e717949 --- /dev/null +++ b/app/javascript/mastodon/reducers/lists.ts @@ -0,0 +1,49 @@ +import type { Reducer } from '@reduxjs/toolkit'; +import { Map as ImmutableMap } from 'immutable'; + +import { createList, updateList } from 'mastodon/actions/lists_typed'; +import type { ApiListJSON } from 'mastodon/api_types/lists'; +import { createList as createListFromJSON } from 'mastodon/models/list'; +import type { List } from 'mastodon/models/list'; + +import { + LIST_FETCH_SUCCESS, + LIST_FETCH_FAIL, + LISTS_FETCH_SUCCESS, + LIST_DELETE_SUCCESS, +} from '../actions/lists'; + +const initialState = ImmutableMap(); +type State = typeof initialState; + +const normalizeList = (state: State, list: ApiListJSON) => + state.set(list.id, createListFromJSON(list)); + +const normalizeLists = (state: State, lists: ApiListJSON[]) => { + lists.forEach((list) => { + state = normalizeList(state, list); + }); + + return state; +}; + +export const listsReducer: Reducer = (state = initialState, action) => { + if ( + createList.fulfilled.match(action) || + updateList.fulfilled.match(action) + ) { + return normalizeList(state, action.payload); + } else { + switch (action.type) { + case LIST_FETCH_SUCCESS: + return normalizeList(state, action.list as ApiListJSON); + case LISTS_FETCH_SUCCESS: + return normalizeLists(state, action.lists as ApiListJSON[]); + case LIST_DELETE_SUCCESS: + case LIST_FETCH_FAIL: + return state.set(action.id as string, null); + default: + return state; + } + } +}; diff --git a/app/javascript/mastodon/selectors/lists.ts b/app/javascript/mastodon/selectors/lists.ts new file mode 100644 index 0000000000..f93e90ce68 --- /dev/null +++ b/app/javascript/mastodon/selectors/lists.ts @@ -0,0 +1,15 @@ +import { createSelector } from '@reduxjs/toolkit'; +import type { Map as ImmutableMap } from 'immutable'; + +import type { List } from 'mastodon/models/list'; +import type { RootState } from 'mastodon/store'; + +export const getOrderedLists = createSelector( + [(state: RootState) => state.lists], + (lists: ImmutableMap) => + lists + .toList() + .filter((item: List | null) => !!item) + .sort((a: List, b: List) => a.title.localeCompare(b.title)) + .toArray(), +); diff --git a/app/javascript/styles/mastodon-light/variables.scss b/app/javascript/styles/mastodon-light/variables.scss index 777c622ace..2d5e1b1094 100644 --- a/app/javascript/styles/mastodon-light/variables.scss +++ b/app/javascript/styles/mastodon-light/variables.scss @@ -79,4 +79,5 @@ body { --rich-text-container-color: rgba(255, 216, 231, 100%); --rich-text-text-color: rgba(114, 47, 83, 100%); --rich-text-decorations-color: rgba(255, 175, 212, 100%); + --input-placeholder-color: #{transparentize($dark-text-color, 0.5)}; } diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss index 579b2a4f69..b5f8570ae2 100644 --- a/app/javascript/styles/mastodon/admin.scss +++ b/app/javascript/styles/mastodon/admin.scss @@ -611,16 +611,6 @@ body, max-width: 100%; } -.simple_form { - .actions { - margin-top: 15px; - } - - .button { - font-size: 15px; - } -} - .batch-form-box { display: flex; flex-wrap: wrap; diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 1bfe37cee6..8b95c3776e 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -4650,6 +4650,7 @@ a.status-card { border: 0; background: transparent; cursor: pointer; + text-decoration: none; .icon { width: 13px; @@ -5063,7 +5064,8 @@ a.status-card { color: $dark-text-color; text-align: center; padding: 20px; - font-size: 15px; + font-size: 14px; + line-height: 20px; font-weight: 400; cursor: default; display: flex; @@ -5085,6 +5087,17 @@ a.status-card { } } +.empty-column-indicator { + &__arrow { + position: absolute; + top: 50%; + inset-inline-start: 50%; + pointer-events: none; + transform: translate(100%, -100%) rotate(12deg); + transform-origin: center; + } +} + .follow_requests-unlocked_explanation { background: var(--surface-background-color); border-bottom: 1px solid var(--background-border-color); @@ -5776,7 +5789,7 @@ a.status-card { .modal-root { position: relative; - z-index: 9999; + z-index: 9998; } .modal-root__overlay { @@ -6381,12 +6394,14 @@ a.status-card { border-radius: 16px; &__header { + box-sizing: border-box; border-bottom: 1px solid var(--modal-border-color); display: flex; align-items: center; justify-content: space-between; flex-direction: row-reverse; padding: 12px 24px; + min-height: 61px; &__title { font-size: 16px; @@ -7993,92 +8008,6 @@ noscript { background: rgba($base-overlay-background, 0.5); } -.list-adder, -.list-editor { - backdrop-filter: var(--background-filter); - background: var(--modal-background-color); - border: 1px solid var(--modal-border-color); - flex-direction: column; - border-radius: 8px; - width: 380px; - overflow: hidden; - - @media screen and (width <= 420px) { - width: 90%; - } -} - -.list-adder { - &__lists { - height: 50vh; - border-radius: 0 0 8px 8px; - overflow-y: auto; - } - - .list { - padding: 10px; - border-bottom: 1px solid var(--background-border-color); - } - - .list__wrapper { - display: flex; - } - - .list__display-name { - flex: 1 1 auto; - overflow: hidden; - text-decoration: none; - font-size: 16px; - padding: 10px; - display: flex; - align-items: center; - gap: 4px; - } -} - -.list-editor { - h4 { - padding: 15px 0; - background: lighten($ui-base-color, 13%); - font-weight: 500; - font-size: 16px; - text-align: center; - border-radius: 8px 8px 0 0; - } - - .drawer__pager { - height: 50vh; - border: 0; - } - - .drawer__inner { - &.backdrop { - width: calc(100% - 60px); - box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4); - border-radius: 0 0 0 8px; - } - } - - &__accounts { - background: unset; - overflow-y: auto; - } - - .account__display-name { - &:hover strong { - text-decoration: none; - } - } - - .account__avatar { - cursor: default; - } - - .search { - margin-bottom: 0; - } -} - .focal-point { position: relative; cursor: move; @@ -10142,7 +10071,7 @@ noscript { position: fixed; bottom: 2rem; inset-inline-start: 0; - z-index: 999; + z-index: 9999; display: flex; flex-direction: column; gap: 4px; @@ -11150,3 +11079,87 @@ noscript { } } } + +.lists__item { + display: flex; + align-items: center; + gap: 16px; + padding-inline-end: 13px; + border-bottom: 1px solid var(--background-border-color); + + &__title { + display: flex; + align-items: center; + gap: 5px; + padding: 16px 13px; + flex: 1 1 auto; + font-size: 16px; + line-height: 24px; + color: $secondary-text-color; + text-decoration: none; + + &:is(a):hover, + &:is(a):focus, + &:is(a):active { + color: $primary-text-color; + } + + input { + display: block; + width: 100%; + background: transparent; + border: 0; + padding: 0; + font-family: inherit; + font-size: inherit; + line-height: inherit; + color: inherit; + + &::placeholder { + color: var(--input-placeholder-color); + opacity: 1; + } + + &:focus { + outline: 0; + } + } + } +} + +.column-search-header { + display: flex; + border-radius: 4px 4px 0 0; + border: 1px solid var(--background-border-color); + + .column-header__back-button.compact { + flex: 0 0 auto; + color: $primary-text-color; + } + + input { + background: transparent; + border: 0; + color: $primary-text-color; + font-size: 16px; + display: block; + flex: 1 1 auto; + + &::placeholder { + color: var(--input-placeholder-color); + opacity: 1; + } + + &:focus { + outline: 0; + } + } +} + +.column-footer { + padding: 16px; +} + +.lists-scrollable { + min-height: 50vh; +} diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss index 641fb19a57..69bd1ca9dd 100644 --- a/app/javascript/styles/mastodon/forms.scss +++ b/app/javascript/styles/mastodon/forms.scss @@ -1255,6 +1255,8 @@ code { } .app-form { + padding: 20px; + &__avatar-input, &__header-input { display: block; @@ -1370,4 +1372,55 @@ code { padding-inline-start: 16px; } } + + &__link { + display: flex; + gap: 16px; + padding: 8px 0; + align-items: center; + text-decoration: none; + color: $primary-text-color; + margin-bottom: 16px; + + &__text { + flex: 1 1 auto; + font-size: 14px; + line-height: 20px; + color: $darker-text-color; + + strong { + font-weight: 600; + display: block; + color: $primary-text-color; + } + } + } +} + +.avatar-pile { + display: flex; + align-items: center; + + img { + display: block; + border-radius: 8px; + width: 32px; + height: 32px; + border: 2px solid var(--background-color); + background: var(--surface-background-color); + margin-inline-end: -16px; + transform: rotate(0); + + &:first-child { + transform: rotate(-4deg); + } + + &:nth-child(2) { + transform: rotate(-2deg); + } + + &:last-child { + margin-inline-end: 0; + } + } } diff --git a/app/javascript/styles/mastodon/variables.scss b/app/javascript/styles/mastodon/variables.scss index fe36e16631..2036f01aff 100644 --- a/app/javascript/styles/mastodon/variables.scss +++ b/app/javascript/styles/mastodon/variables.scss @@ -119,4 +119,5 @@ $font-monospace: 'mastodon-font-monospace' !default; --rich-text-container-color: rgba(87, 24, 60, 100%); --rich-text-text-color: rgba(255, 175, 212, 100%); --rich-text-decorations-color: rgba(128, 58, 95, 100%); + --input-placeholder-color: #{$dark-text-color}; } diff --git a/app/javascript/svg-icons/squiggly_arrow.svg b/app/javascript/svg-icons/squiggly_arrow.svg new file mode 100644 index 0000000000..ae636d7dfd --- /dev/null +++ b/app/javascript/svg-icons/squiggly_arrow.svg @@ -0,0 +1,3 @@ + + +